Compare commits

..

52 Commits

Author SHA1 Message Date
Ahmed Ejaz
d1608a0288 Update all locales with the latest Transifex translations 2026-04-04 21:46:56 +05:00
Ahmed Ejaz
e4e7ef395b Merge pull request #14113 from gbathree/13817-fix-guest-order-cancellation
Fix guest order cancellation redirecting to home page
2026-04-04 21:42:44 +05:00
Ahmed Ejaz
ec24740c3b Merge pull request #14085 from dacook/admin-product-actions-fixes
[Admin Products] Action menu fixes
2026-04-04 21:42:12 +05:00
Gaetan Craig-Riou
783ac990bc Merge pull request #14132 from openfoodfoundation/dependabot/bundler/rack-2.2.23
Bump rack from 2.2.22 to 2.2.23
2026-04-03 09:33:02 +11:00
dependabot[bot]
a4cc2f17dc Bump rack from 2.2.22 to 2.2.23
Bumps [rack](https://github.com/rack/rack) from 2.2.22 to 2.2.23.
- [Release notes](https://github.com/rack/rack/releases)
- [Changelog](https://github.com/rack/rack/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rack/rack/compare/v2.2.22...v2.2.23)

---
updated-dependencies:
- dependency-name: rack
  dependency-version: 2.2.23
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-02 20:02:57 +00:00
Rachel Arnould
c19241ddd9 Merge pull request #14118 from dacook/linked-variants-14088
Prevent creating a linked variant from a linked variant
2026-04-02 17:44:34 +02:00
Maikel
eee9f61c38 Merge pull request #14129 from openfoodfoundation/dependabot/bundler/aws-sdk-s3-1.217.0
Bump aws-sdk-s3 from 1.215.0 to 1.217.0
2026-04-02 11:50:42 +11:00
Maikel
7f711d746f Merge pull request #14126 from dacook/dependabot-cooldown
Dependabot cooldown
2026-04-02 11:49:55 +11:00
dependabot[bot]
732234f1c0 Bump aws-sdk-s3 from 1.215.0 to 1.217.0
Bumps [aws-sdk-s3](https://github.com/aws/aws-sdk-ruby) from 1.215.0 to 1.217.0.
- [Release notes](https://github.com/aws/aws-sdk-ruby/releases)
- [Changelog](https://github.com/aws/aws-sdk-ruby/blob/version-3/gems/aws-sdk-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-ruby/commits)

---
updated-dependencies:
- dependency-name: aws-sdk-s3
  dependency-version: 1.217.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-01 11:11:34 +00:00
Maikel
450fe4ada1 Merge pull request #14119 from mkllnk/replace-whenever
Replace whenever with sidekiq scheduler
2026-04-01 15:37:33 +11:00
Maikel Linke
bcecbf9a0f Require rake dependency to run it within jobs 2026-04-01 15:18:49 +11:00
David Cook
15abea51ab Avoid unnecessary extra page visit
The second spec example below has to load the page after creating a record, so it's not helpful to load it here.
2026-04-01 14:48:18 +11:00
David Cook
cacb62f58c Style fix 2026-04-01 14:11:19 +11:00
David Cook
6048fcb053 Define function as member arrow function
This way it behaves as an instance method, and we don't have to pass in the object.
2026-04-01 14:11:19 +11:00
David Cook
6013b6be70 Remove transform at end of animation
During transform, any overflow on the element is clipped/hidden. This caused all dropdown menus to be clipped and unusable. Now, once the animation is complete, the overflow is visible, and menus are usable.

Mistral Vibe AI was used to find this solution. I tried to find a CSS solution last week but failed, then started to consider using JS to remove the class, but decided against it once I realised that the product clone JS was already doing that, and it didn't seem to solve the clipping issue.
So I asked Mistral Vibe and it suggested adding 'forwards' (before it had spent energy on evaluating the existing style rules). As you can see 'forwards' was already there, but removing it helped. So Mistral was wrong, but at least pointed me in the right direction, yay!
2026-04-01 14:11:19 +11:00
David Cook
22a1528ac7 Show unit in tooltip
Variants may have the same name, or no display_name at all. This helper method provides a more comprehensive way of describing the variant.
2026-04-01 14:11:19 +11:00
David Cook
ca3c0c98bf Don't group reviewdog output
Grouping is a nice feature, but it wasn't helpful here. If there's an error in rubocop for example, the rubocop section will be collapsed, and because we didn't close the group, the haml group was always open. So it wasn't clear where the error was.

Better to just show all the output, which isn't very long, so you can see where the problem is straight away.

Even better would be to add support for GitHub Actions annotations. I thought we used to have that turned on, not sure why it's not working now.
2026-04-01 14:11:19 +11:00
David Cook
19006d6c17 Close action menu when making a selection
But don't hide it immediately, because the user can't see if they made a selection, or accidentally closed it. Instead, fade slowly so that you can see the selected option momentarily (like system menus). This gives enough feedback while we wait for the selected action to perform.

I did attempt a blink on the item background colour, like my favourite OS does which is really helpful. But couldn't get the CSS to work.
2026-04-01 14:11:19 +11:00
David Cook
da69e2c383 Widen action menus slightly when needed 2026-04-01 14:11:19 +11:00
Maikel
2e6e4b665f Merge pull request #14122 from openfoodfoundation/dependabot/bundler/view_component-4.5.0
Bump view_component from 4.1.1 to 4.5.0
2026-04-01 10:31:41 +11:00
David Cook
e255bcc082 Formatting
Compacted and adjusted comments to make it a bit easier to read.
2026-04-01 10:31:34 +11:00
David Cook
51b4dc64cc Add cooldown for turbo_power
Ensure it's treated the same as other gems and packages.
2026-04-01 10:17:40 +11:00
Maikel
77b6bc15e7 Merge pull request #14121 from openfoodfoundation/dependabot/bundler/devise-i18n-1.16.0
Bump devise-i18n from 1.15.0 to 1.16.0
2026-04-01 10:11:37 +11:00
Ahmed Ejaz
9b145da898 Merge pull request #14040 from chahmedejaz/task/13797-improve-performance-of-products-page
Fix Admin Bulk Products screen performance issue
2026-04-01 00:37:40 +05:00
dependabot[bot]
00d600911d Bump view_component from 4.1.1 to 4.5.0
Bumps [view_component](https://github.com/viewcomponent/view_component) from 4.1.1 to 4.5.0.
- [Release notes](https://github.com/viewcomponent/view_component/releases)
- [Changelog](https://github.com/ViewComponent/view_component/blob/main/docs/CHANGELOG.md)
- [Commits](https://github.com/viewcomponent/view_component/compare/v4.1.1...v4.5.0)

---
updated-dependencies:
- dependency-name: view_component
  dependency-version: 4.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-31 09:55:00 +00:00
dependabot[bot]
10d6dd73f2 Bump devise-i18n from 1.15.0 to 1.16.0
Bumps [devise-i18n](https://github.com/devise-i18n/devise-i18n) from 1.15.0 to 1.16.0.
- [Release notes](https://github.com/devise-i18n/devise-i18n/releases)
- [Changelog](https://github.com/devise-i18n/devise-i18n/blob/main/CHANGELOG.md)
- [Commits](https://github.com/devise-i18n/devise-i18n/compare/v1.15.0...v1.16.0)

---
updated-dependencies:
- dependency-name: devise-i18n
  dependency-version: 1.16.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-31 09:41:05 +00:00
Maikel Linke
c74624cd57 Remove unused gem whenever 2026-03-31 14:58:57 +11:00
Maikel Linke
60edcada2c Remove whenever config 2026-03-31 14:56:41 +11:00
Maikel Linke
b61f6ab444 Schedule all jobs with Sidekiq 2026-03-31 14:53:26 +11:00
David Cook
ccc38367f3 Prevent creating a linked variant from a linked variant
It's just too confusing.
2026-03-31 14:34:17 +11:00
Maikel Linke
80a12db191 Move database clean from cron to Sidekiq scheduler
After moving the remaining tasks from schedule.rb to sidekiq.yml, we can
remove whenever and won't rely on cron any more. That will simplify the
setup and migration to a new server.
2026-03-31 12:34:47 +11:00
Maikel
5beed6f028 Merge pull request #14117 from openfoodfoundation/dependabot/bundler/whenever-1.1.2
Bump whenever from 1.1.0 to 1.1.2
2026-03-31 10:27:56 +11:00
Ahmed Ejaz
0a65322594 rename ajax_search_spec 2026-03-31 04:05:06 +05:00
Ahmed Ejaz
b7f154d289 revert back the bin/setup 2026-03-31 03:49:35 +05:00
Maikel
edb8a03436 Merge pull request #14116 from openfoodfoundation/dependabot/bundler/active_storage_validations-3.0.4
Bump active_storage_validations from 3.0.3 to 3.0.4
2026-03-31 09:35:28 +11:00
Ahmed Ejaz
3ee338fa8d Add ajax search controller 2026-03-31 01:54:02 +05:00
dependabot[bot]
e3da27ca12 Bump whenever from 1.1.0 to 1.1.2
Bumps [whenever](https://github.com/javan/whenever) from 1.1.0 to 1.1.2.
- [Release notes](https://github.com/javan/whenever/releases)
- [Changelog](https://github.com/javan/whenever/blob/main/CHANGELOG.md)
- [Commits](https://github.com/javan/whenever/compare/v1.1.0...v1.1.2)

---
updated-dependencies:
- dependency-name: whenever
  dependency-version: 1.1.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-30 10:17:45 +00:00
dependabot[bot]
4c8e6d8260 Bump active_storage_validations from 3.0.3 to 3.0.4
Bumps [active_storage_validations](https://github.com/igorkasyanchuk/active_storage_validations) from 3.0.3 to 3.0.4.
- [Release notes](https://github.com/igorkasyanchuk/active_storage_validations/releases)
- [Changelog](https://github.com/igorkasyanchuk/active_storage_validations/blob/master/CHANGES.md)
- [Commits](https://github.com/igorkasyanchuk/active_storage_validations/compare/3.0.03...3.0.4)

---
updated-dependencies:
- dependency-name: active_storage_validations
  dependency-version: 3.0.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-30 10:16:06 +00:00
Maikel
de28083007 Merge pull request #14112 from openfoodfoundation/dependabot/bundler/bootsnap-1.23.0
Bump bootsnap from 1.22.0 to 1.23.0
2026-03-30 11:59:05 +11:00
Greg Austic
cc608adddc Address review feedback: use referer in specs, remove demo screenshots
- Set HTTP_REFERER in cancel action specs so they test the redirect to
  the actual order page (not the cancel path, which was the implicit
  referer in controller specs)
- Keep response.body match pattern (consistent with checkout_controller_spec
  for CableReady redirects — redirect_to matcher does not work here since
  the cancel action uses cable_car.redirect_to, not a Rails redirect)
- Remove doc/demo screenshots; images to be added to PR description instead

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 19:03:47 -04:00
Gaetan Craig-Riou
01bfd72387 Merge pull request #14115 from openfoodfoundation/dependabot/npm_and_yarn/trix-2.1.18
Bump trix from 2.1.17 to 2.1.18
2026-03-30 09:57:33 +11:00
Gaetan Craig-Riou
69d9c52a53 Merge pull request #14111 from openfoodfoundation/dependabot/bundler/pg-1.6.3
Bump pg from 1.6.2 to 1.6.3
2026-03-30 09:44:59 +11:00
dependabot[bot]
5371361a74 Bump trix from 2.1.17 to 2.1.18
Bumps [trix](https://github.com/basecamp/trix) from 2.1.17 to 2.1.18.
- [Release notes](https://github.com/basecamp/trix/releases)
- [Commits](https://github.com/basecamp/trix/compare/v2.1.17...v2.1.18)

---
updated-dependencies:
- dependency-name: trix
  dependency-version: 2.1.18
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-29 18:49:52 +00:00
Greg Austic
a6f5f2c10d Add demo screenshots for guest order cancellation fix
Before/after screenshots showing the fix works end-to-end:
- step1: guest order confirmed, Cancel Order button visible
- step2: same page after cancellation, order shows Cancelled

These can be removed after the PR is reviewed and merged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 09:44:12 -04:00
Greg Austic
c72976b1e2 Fix guest order cancellation redirecting to home page
When a guest places an order and tries to cancel it from the order
confirmation page, the cancellation silently failed and redirected
to the home page. The guest was left unsure whether the order was
cancelled, and the hub received no cancellation notification.

Root cause: two missing pieces for guest (token-based) authorization:

1. The `:cancel` ability in Ability#add_shopping_abilities only checked
   `order.user == user`, ignoring the guest token. The `:read` and
   `:update` abilities already support `order.token && token == order.token`
   as a fallback — `:cancel` now does the same.

2. The `cancel` action called `authorize! :cancel, @order` without
   passing `session[:access_token]`, so even with the corrected ability
   the token was never evaluated.

Fixes #13817

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 09:05:47 -04:00
dependabot[bot]
b7c628dc2a Bump bootsnap from 1.22.0 to 1.23.0
Bumps [bootsnap](https://github.com/rails/bootsnap) from 1.22.0 to 1.23.0.
- [Release notes](https://github.com/rails/bootsnap/releases)
- [Changelog](https://github.com/rails/bootsnap/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rails/bootsnap/compare/v1.22.0...v1.23.0)

---
updated-dependencies:
- dependency-name: bootsnap
  dependency-version: 1.23.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-27 09:34:18 +00:00
dependabot[bot]
5bef61aa2e Bump pg from 1.6.2 to 1.6.3
Bumps [pg](https://github.com/ged/ruby-pg) from 1.6.2 to 1.6.3.
- [Changelog](https://github.com/ged/ruby-pg/blob/master/CHANGELOG.md)
- [Commits](https://github.com/ged/ruby-pg/compare/v1.6.2...v1.6.3)

---
updated-dependencies:
- dependency-name: pg
  dependency-version: 1.6.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-27 09:33:08 +00:00
Maikel
79c346acb1 Merge pull request #14109 from openfoodfoundation/dependabot/npm_and_yarn/node-forge-1.4.0
Bump node-forge from 1.3.3 to 1.4.0
2026-03-27 13:56:43 +11:00
dependabot[bot]
ca10ae2f5c Bump node-forge from 1.3.3 to 1.4.0
Bumps [node-forge](https://github.com/digitalbazaar/forge) from 1.3.3 to 1.4.0.
- [Changelog](https://github.com/digitalbazaar/forge/blob/main/CHANGELOG.md)
- [Commits](https://github.com/digitalbazaar/forge/compare/v1.3.3...v1.4.0)

---
updated-dependencies:
- dependency-name: node-forge
  dependency-version: 1.4.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-27 00:12:35 +00:00
Ahmed Ejaz
8ba0ab6b5a Update specs according to new remote search function on products page 2026-03-25 02:01:36 +05:00
Ahmed Ejaz
044f6131da fix aria_label translations 2026-03-25 01:30:06 +05:00
Ahmed Ejaz
062fcd317c Add searchable dropdowns for producers, categories, and tax categories in products_v3 2026-03-25 01:30:06 +05:00
35 changed files with 703 additions and 209 deletions

6
.env
View File

@@ -21,12 +21,6 @@ CHECKOUT_ZONE="Australia"
# Find currency codes at http://en.wikipedia.org/wiki/ISO_4217.
CURRENCY="AUD"
# The whenever gem can set the `MAILTO` variable for our cron jobs.
# You can define an email address to notify if any job outputs something.
# But you need a working mail server setup so that the message is delivered.
# See: config/schedule.rb
# SCHEDULE_NOTIFICATIONS="admin@example.com"
# Mail settings
MAIL_HOST="example.com"
MAIL_DOMAIN="example.com"

View File

@@ -9,32 +9,30 @@ multi-ecosystem-groups:
turbo_power:
schedule:
interval: "daily"
cooldown:
default-days: 7
updates:
# turbo_power: ensure gem and package are updated together
- package-ecosystem: "bundler"
directory: "/"
patterns: ["turbo_power"]
multi-ecosystem-group: "turbo_power"
# Only specific requirements are specified in Gemfile, so don't touch it.
versioning-strategy: lockfile-only
- package-ecosystem: "npm"
directory: "/"
patterns: ["turbo_power"]
multi-ecosystem-group: "turbo_power"
# Only specific requirements are specified in package.json, so don't touch it.
versioning-strategy: lockfile-only
# All others
- package-ecosystem: "bundler"
directory: "/"
schedule:
interval: "daily"
cooldown:
default-days: 7
# Only specific requirements are specified in Gemfile, so don't touch it.
# Only specific requirements are specified in Gemfile, so don't let Dependabot touch it.
versioning-strategy: lockfile-only
- package-ecosystem: "npm"
@@ -43,6 +41,5 @@ updates:
interval: "daily"
cooldown:
default-days: 7
# Only specific requirements are specified in package.json, so don't touch it.
# Only specific requirements are specified in package.json, so don't let Dependabot touch it.
versioning-strategy: lockfile-only

View File

@@ -118,8 +118,6 @@ gem 'immigrant'
gem 'roo' # read spreadsheets
gem 'spreadsheet_architect' # write spreadsheets
gem 'whenever', require: false
gem 'coffee-rails', '~> 5.0.0'
gem 'angular_rails_csrf'

View File

@@ -112,7 +112,7 @@ GEM
rails-html-sanitizer (~> 1.6)
active_model_serializers (0.8.4)
activemodel (>= 3.0)
active_storage_validations (3.0.3)
active_storage_validations (3.0.4)
activejob (>= 6.1.4)
activemodel (>= 6.1.4)
activestorage (>= 6.1.4)
@@ -185,8 +185,8 @@ GEM
ast (2.4.3)
attr_required (1.0.2)
aws-eventstream (1.4.0)
aws-partitions (1.1227.0)
aws-sdk-core (3.243.0)
aws-partitions (1.1233.0)
aws-sdk-core (3.244.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@@ -194,11 +194,11 @@ GEM
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.122.0)
aws-sdk-core (~> 3, >= 3.241.4)
aws-sdk-kms (1.123.0)
aws-sdk-core (~> 3, >= 3.244.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.215.0)
aws-sdk-core (~> 3, >= 3.243.0)
aws-sdk-s3 (1.217.0)
aws-sdk-core (~> 3, >= 3.244.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
@@ -210,7 +210,7 @@ GEM
bigdecimal (3.3.1)
bindata (2.5.1)
bindex (0.8.1)
bootsnap (1.22.0)
bootsnap (1.23.0)
msgpack (~> 1.2)
bugsnag (6.29.0)
concurrent-ruby (~> 1.0)
@@ -245,7 +245,6 @@ GEM
cgi (0.5.1)
childprocess (5.0.0)
choice (0.2.0)
chronic (0.10.2)
coderay (1.1.3)
coffee-rails (5.0.0)
coffee-script (>= 2.2.0)
@@ -290,8 +289,8 @@ GEM
warden (~> 1.2.3)
devise-encryptable (0.2.0)
devise (>= 2.1.0)
devise-i18n (1.15.0)
devise (>= 4.9.0)
devise-i18n (1.16.0)
devise (>= 5.0.0)
rails-i18n
diff-lcs (1.6.2)
digest (3.2.1)
@@ -591,7 +590,7 @@ GEM
hashery (~> 2.0)
ruby-rc4
ttfunk
pg (1.6.2)
pg (1.6.3)
pp (0.6.3)
prettyprint
prettyprint (0.2.0)
@@ -621,7 +620,7 @@ GEM
railties (>= 4.2)
raabro (1.4.0)
racc (1.8.1)
rack (2.2.22)
rack (2.2.23)
rack-mini-profiler (2.3.4)
rack (>= 1.2.0)
rack-oauth2 (2.3.0)
@@ -905,7 +904,7 @@ GEM
thor (1.5.0)
thread-local (1.1.0)
tilt (2.7.0)
timeout (0.6.0)
timeout (0.6.1)
tsort (0.2.0)
ttfunk (1.8.0)
bigdecimal (~> 3.1)
@@ -938,9 +937,9 @@ GEM
validates_lengths_from_database (0.8.0)
activerecord (>= 4)
vcr (6.4.0)
view_component (4.1.1)
actionview (>= 7.1.0, < 8.2)
activesupport (>= 7.1.0, < 8.2)
view_component (4.5.0)
actionview (>= 7.1.0)
activesupport (>= 7.1.0)
concurrent-ruby (~> 1)
view_component_reflex (3.1.14.pre9)
rails (>= 5.2, < 8.0)
@@ -968,8 +967,6 @@ GEM
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
whenever (1.1.0)
chronic (>= 0.6.3)
xml-simple (1.1.8)
xpath (3.2.0)
nokogiri (~> 1.8)
@@ -1127,7 +1124,6 @@ DEPENDENCIES
web!
web-console
webmock
whenever
wicked_pdf!
wkhtmltopdf-binary!

View File

@@ -19,7 +19,9 @@
background-color: white;
box-shadow: $box-shadow;
border-radius: 3px;
width: max-content;
min-width: 80px;
max-width: 110px;
display: none;
z-index: 100;
@@ -27,6 +29,15 @@
display: block;
}
// Fade out so user can see which option was selected
&.selected {
transition:
opacity 0.2s linear,
visibility 0.2s linear;
opacity: 0;
visibility: hidden;
}
& > a {
display: block;
padding: 5px 10px;

View File

@@ -6,6 +6,9 @@ export default class extends Controller {
connect() {
super.connect();
window.addEventListener("click", this.#hideIfClickedOutside);
// Close menu when making a selection
this.contentTarget.addEventListener("click", this.#selected);
}
disconnect() {
@@ -13,17 +16,22 @@ export default class extends Controller {
}
toggle() {
this.contentTarget.classList.toggle("show");
this.#toggleShow();
}
#selected = () => {
this.contentTarget.classList.add("selected");
};
#hideIfClickedOutside = (event) => {
if (this.element.contains(event.target)) {
return;
}
this.#hide();
this.#toggleShow(false);
};
#hide() {
this.contentTarget.classList.remove("show");
#toggleShow(force = undefined) {
this.contentTarget.classList.toggle("show", force);
this.contentTarget.classList.remove("selected");
}
}

View File

@@ -0,0 +1,56 @@
# frozen_string_literal: true
module Admin
class AjaxSearchController < Spree::Admin::BaseController
def producers
query = OpenFoodNetwork::Permissions.new(spree_current_user)
.managed_product_enterprises.is_primary_producer.by_name
render json: build_search_response(query)
end
def categories
query = Spree::Taxon.all
render json: build_search_response(query)
end
def tax_categories
query = Spree::TaxCategory.all
render json: build_search_response(query)
end
private
def build_search_response(query)
page = (params[:page] || 1).to_i
per_page = 30
filtered_query = apply_search_filter(query)
total_count = filtered_query.size
items = paginated_items(filtered_query, page, per_page)
results = format_results(items)
{ results: results, pagination: { more: (page * per_page) < total_count } }
end
def apply_search_filter(query)
search_term = params[:q]
return query if search_term.blank?
escaped_search_term = ActiveRecord::Base.sanitize_sql_like(search_term)
pattern = "%#{escaped_search_term}%"
query.where('name ILIKE ?', pattern)
end
def paginated_items(query, page, per_page)
query.order(:name).offset((page - 1) * per_page).limit(per_page).pluck(:name, :id)
end
def format_results(items)
items.map { |label, value| { value:, label: } }
end
end
end

View File

@@ -107,7 +107,7 @@ module Spree
def cancel
@order = Spree::Order.find_by!(number: params[:id])
authorize! :cancel, @order
authorize! :cancel, @order, session[:access_token]
if Orders::CustomerCancellationService.new(@order).call
flash[:success] = I18n.t(:orders_your_order_has_been_cancelled)

View File

@@ -52,5 +52,15 @@ module Admin
@allowed_source_producers ||= OpenFoodNetwork::Permissions.new(spree_current_user)
.enterprises_granting_linked_variants
end
# Query only name of the model to avoid loading the whole record
def selected_option(id, model)
return [] unless id
name = model.where(id: id).pick(:name)
return [] unless name
[[name, id]]
end
end
end

15
app/jobs/rake_job.rb Normal file
View File

@@ -0,0 +1,15 @@
# frozen_string_literal: true
require "rake"
# Executes a rake task
class RakeJob < ApplicationJob
def perform(task_string)
Rails.application.load_tasks if Rake::Task.tasks.empty?
Rake.application.invoke_task(task_string)
ensure
name, _args = Rake.application.parse_task_string(task_string)
Rake::Task[name].reenable
end
end

View File

@@ -113,7 +113,11 @@ module Spree
item.order.changes_allowed?
end
can [:cancel, :bulk_cancel], Spree::Order do |order|
can :cancel, Spree::Order do |order, token|
order.user == user || (order.token && token == order.token)
end
can :bulk_cancel, Spree::Order do |order|
order.user == user
end
@@ -225,9 +229,17 @@ module Spree
OpenFoodNetwork::Permissions.new(user).
enterprises_granting_linked_variants.include? variant.supplier
end
can [
:admin,
:index,
:bulk_update,
:destroy,
:destroy_variant,
:clone,
:create_linked_variant
], :products_v3
can [:admin, :index, :bulk_update, :destroy, :destroy_variant, :clone,
:create_linked_variant], :products_v3
can [:admin, :producers, :categories, :tax_categories], :ajax_search
can [:create], Spree::Variant
can [:admin, :index, :read, :edit,

View File

@@ -9,14 +9,22 @@
- if producer_options.many?
.producers
= label_tag :producer_id, t('.producers.label')
= select_tag :producer_id, options_for_select(producer_options, producer_id),
include_blank: t('.all_producers'), class: "fullwidth",
data: { "controller": "tom-select", 'tom-select-placeholder-value': t('.search_for_producers')}
= render(SearchableDropdownComponent.new(name: :producer_id,
aria_label: t('.producers.label'),
options: selected_option(producer_id, Enterprise),
selected_option: producer_id,
remote_url: admin_ajax_search_producers_url,
include_blank: t('.all_producers'),
placeholder_value: t('.search_for_producers')))
.categories
= label_tag :category_id, t('.categories.label')
= select_tag :category_id, options_for_select(category_options, category_id),
include_blank: t('.all_categories'), class: "fullwidth",
data: { "controller": "tom-select", 'tom-select-placeholder-value': t('.search_for_categories')}
= render(SearchableDropdownComponent.new(name: :category_id,
aria_label: t('.categories.label'),
options: selected_option(category_id, Spree::Taxon),
selected_option: category_id,
remote_url: admin_ajax_search_categories_url,
include_blank: t('.all_categories'),
placeholder_value: t('.search_for_categories')))
-if variant_tag_enabled?(spree_current_user)
.tags
= label_tag :tags_name_in, t('.tags.label')

View File

@@ -4,7 +4,7 @@
%td.col-image
-# empty
- variant.source_variants.each do |source_variant|
= content_tag(:span, "🔗", title: t('admin.products_page.variant_row.sourced_from', source_name: source_variant.display_name, source_id: source_variant.id, hub_name: variant.hub&.name))
= content_tag(:span, "🔗", title: t('admin.products_page.variant_row.sourced_from', source_name: source_variant.full_name, source_id: source_variant.id, hub_name: variant.hub&.name))
%td.col-name.field.naked_inputs
= f.hidden_field :id
= f.text_field :display_name, 'aria-label': t('admin.products_page.columns.name'), placeholder: variant.product.name
@@ -59,27 +59,28 @@
= render(SearchableDropdownComponent.new(form: f,
name: :supplier_id,
aria_label: t('.producer_field_name'),
options: producer_options,
options: variant.supplier_id ? [[variant.supplier.name, variant.supplier_id]] : [],
selected_option: variant.supplier_id,
include_blank: t('admin.products_v3.filters.select_producer'),
remote_url: admin_ajax_search_producers_url,
placeholder_value: t('admin.products_v3.filters.select_producer')))
= error_message_on variant, :supplier
%td.col-category.field.naked_inputs
= render(SearchableDropdownComponent.new(form: f,
name: :primary_taxon_id,
options: category_options,
options: variant.primary_taxon_id ? [[variant.primary_taxon.name, variant.primary_taxon_id]] : [],
selected_option: variant.primary_taxon_id,
aria_label: t('.category_field_name'),
include_blank: t('admin.products_v3.filters.select_category'),
remote_url: admin_ajax_search_categories_url,
placeholder_value: t('admin.products_v3.filters.select_category')))
= error_message_on variant, :primary_taxon
%td.col-tax_category.field.naked_inputs
= render(SearchableDropdownComponent.new(form: f,
name: :tax_category_id,
options: tax_category_options,
options: variant.tax_category_id ? [[variant.tax_category.name, variant.tax_category_id]] : [],
selected_option: variant.tax_category_id,
include_blank: t('.none_tax_category'),
aria_label: t('.tax_category_field_name'),
include_blank: t('.none_tax_category'),
remote_url: admin_ajax_search_tax_categories_url,
placeholder_value: t('.search_for_tax_categories')))
= error_message_on variant, :tax_category
- if variant_tag_enabled?(spree_current_user)
@@ -92,7 +93,7 @@
- if variant.persisted?
= link_to t('admin.products_page.actions.edit'), edit_admin_product_variant_path(variant.product, variant)
- if allowed_source_producers.include?(variant.supplier)
- if variant.source_variants.empty? && allowed_source_producers.include?(variant.supplier)
= link_to t('admin.products_page.actions.create_linked_variant'), admin_create_linked_variant_path(variant_id: variant.id, product_index:), 'data-turbo-method': :post
- if variant.product.variants.size > 1

View File

@@ -83,6 +83,9 @@ export default class extends Controller {
}
#addRemoteOptions(options) {
// by default, for dropdown_input plugin, it's true. Otherwise for multi-select it's false
// it should always be true so to invoke the onDropdownOpen to fetch options
options.shouldOpen = true;
this.openedByClick = false;
options.firstUrl = (query) => {
@@ -91,12 +94,9 @@ export default class extends Controller {
options.load = this.#fetchOptions.bind(this);
options.onFocus = function () {
this.control.load("", () => {});
}.bind(this);
options.onDropdownOpen = function () {
this.openedByClick = true;
this.control.load("", () => {});
}.bind(this);
options.onType = function () {

View File

@@ -375,6 +375,6 @@
.slide-in {
transform-origin: top;
animation: slideInTop 0.5s forwards;
animation: slideInTop 0.5s;
}
}

View File

@@ -7,6 +7,15 @@ redis_connection_settings = {
Sidekiq.configure_server do |config|
config.redis = redis_connection_settings
config.on(:startup) do
# Load schedule file similar to sidekiq/cli.rb loading the main config.
path = File.expand_path("../sidekiq_scheduler.yml", __dir__)
erb = ERB.new(File.read(path), trim_mode: "-")
Sidekiq.schedule =
YAML.safe_load(erb.result, permitted_classes: [Symbol], aliases: true)
SidekiqScheduler::Scheduler.instance.reload_schedule!
end
end
Sidekiq.configure_client do |config|

View File

@@ -193,6 +193,7 @@ de_DE:
not_available_to_shop: "ist nicht verfügbar für %{shop}"
card_details: "Kreditkartendaten"
card_type: "Kreditkartentyp"
use_new_cc: "Eine neue Kreditkarte verwenden"
cardholder_name: "Kreditkarteninhaber"
community_forum_url: "URL des Community-Forums"
customer_instructions: "Informationen für Kunden"
@@ -665,6 +666,7 @@ de_DE:
bill_address: "Rechnungsadresse"
ship_address: "Lieferadresse"
balance: "Saldo"
credit: "Verfügbares Guthaben"
update_address_success: "Adresse wurde erfolgreich aktualisiert."
update_address_error: "Bitte füllen Sie alle erforderlichen Felder aus."
edit_bill_address: "Rechnungsadresse bearbeiten"
@@ -683,6 +685,7 @@ de_DE:
has_associated_subscriptions: "Löschen fehlgeschlagen: Dieser Kunde hat aktive Abonnements. Bitte kündigen Sie diese zuerst."
customer_account_transaction:
index:
available_credit: "Verfügbares Guthaben: %{available_credit}"
description: Beschreibung
amount: Summe
running_balance: Fortlaufender Saldo
@@ -2203,6 +2206,7 @@ de_DE:
order_total: 'Gesamtsumme:'
order_payment: "Zahlungsart:"
no_payment_required: "Keine Zahlung erforderlich."
credit_used: "Verwendetes Guthaben: %{amount}"
customer_credit: Guthaben
order_billing_address: Rechnungsadresse
order_delivery_on: Lieferung am
@@ -4363,7 +4367,7 @@ de_DE:
date_picker:
flatpickr_date_format: "d.m.Y"
flatpickr_datetime_format: "d.m.Y H:i"
today: "heute"
today: "Heute"
now: "Jetzt"
close: "Schließen"
orders:

View File

@@ -208,6 +208,10 @@ hu:
no_default_card: "^Ennél az ügyfélnél nem áll rendelkezésre alapértelmezett kártya"
shipping_method:
not_available_to_shop: "nem érhető el a %{shop} számára"
user_invitation:
attributes:
email:
is_already_manager: már menedzser
card_details: "A kártya adatai"
card_type: "Kártyatípus"
card_type_is: "A kártya típusa: "
@@ -513,7 +517,7 @@ hu:
create: "Létrehozás"
cancel: "Mégsem"
cancel_order: "Törlés"
resume: "Összefoglaló"
resume: "Visszaállítás"
save: "Mentés"
edit: "Szerkesztés"
update: "Frissítés"
@@ -570,8 +574,11 @@ hu:
delete: Törlés
remove: Eltávolítás
preview: Előnézet
create_linked_variant: Kapcsolt termékváltozat létrehozása
image:
edit: Szerkesztés
variant_row:
sourced_from: "Forrás: %{source_name} ( %{source_id} ); Elosztó: %{hub_name}"
product_preview:
product_preview: Termék előnézet
shop_tab: Átvételi pont
@@ -1051,7 +1058,7 @@ hu:
back_to_my_inventory: Vissza a leltárhoz
orders:
edit:
order_sure_want_to: Biztosan %{event} akarod ezt a megrendelést?
order_sure_want_to: 'Biztosan végrehajtod a rendelésen ezt a műveletet? %{event} '
voucher_tax_included_in_price: "%{label}(a kupon tartalmazza az adót)"
tax_on_fees: "Díjak adója"
invoice_email_sent: 'Számla e-mail elküldve'
@@ -1561,7 +1568,7 @@ hu:
coordinator: Koordinátor
orders_close: A rendelések lezárulnak
row:
suppliers: beszállítók
suppliers: beszállító
distributors: elosztó
variants: változat
simple_form:

View File

@@ -83,6 +83,13 @@ Openfoodnetwork::Application.routes.draw do
delete 'products_v3/destroy_variant/:id', to: 'products_v3#destroy_variant', as: 'destroy_variant'
post 'clone/:id', to: 'products_v3#clone', as: 'clone_product'
post 'products/create_linked_variant', to: 'products_v3#create_linked_variant', as: 'create_linked_variant'
scope :ajax_search, as: :ajax_search, controller: :ajax_search do
get :producers
get :categories
get :tax_categories
end
resources :product_preview, only: [:show]
resources :variant_overrides do

View File

@@ -1,24 +0,0 @@
# Force manual loading of rails application to get all env variables from dotenv-rails when running whenever cmd
require File.expand_path('../environment', __FILE__)
require 'whenever'
require 'yaml'
# Learn more: http://github.com/javan/whenever
env "MAILTO", ENV["SCHEDULE_NOTIFICATIONS"] if ENV["SCHEDULE_NOTIFICATIONS"]
# If we use -e with a file containing specs, rspec interprets it and filters out our examples
job_type :run_file, "cd :path; :environment_variable=:environment bundle exec script/rails runner :task :output"
every 1.month, at: '4:30am' do
rake 'ofn:data:remove_transient_data'
end
every 1.day, at: '2:45am' do
rake 'db2fog:clean' if ENV['S3_BACKUPS_BUCKET']
end
every 4.hours do
rake 'db2fog:backup' if ENV['S3_BACKUPS_BUCKET']
end

View File

@@ -7,15 +7,8 @@
- default
- mailers
:scheduler:
:schedule:
HeartbeatJob:
every: ["5m", first_in: "0s"]
SubscriptionPlacementJob:
every: "5m"
SubscriptionConfirmJob:
every: "5m"
TriggerOrderCyclesToOpenJob:
every: "5m"
OrderCycleClosingJob:
every: "5m"
# This config is loaded by sidekiq before dotenv is loading our server config.
# Therefore we load the schedule later. See:
#
# - config/initializers/sidekiq.rb
# - config/sidekiq_scheduler.yml

View File

@@ -0,0 +1,36 @@
# Configure sidekiq-scheduler to run jobs.
#
# - https://github.com/sidekiq-scheduler/sidekiq-scheduler
#
# > Note that every and interval count from when the Sidekiq process (re)starts.
# > So every: '48h' will never run if the Sidekiq process is restarted daily,
# > for example. You can do every: ['48h', first_in: '0s'] to make the job run
# > immediately after a restart, and then have the worker check when it was
# > last run.
#
# Therefore, we use `cron` for jobs that should run at certain times like backups.
HeartbeatJob:
every: ["5m", first_in: "0s"]
SubscriptionPlacementJob:
every: "5m"
SubscriptionConfirmJob:
every: "5m"
TriggerOrderCyclesToOpenJob:
every: "5m"
OrderCycleClosingJob:
every: "5m"
backup:
class: "RakeJob"
args: ["db2fog:backup"]
cron: "0 */4 * * *" # every 4 hours
enabled: <%= ENV.fetch("S3_BACKUPS_BUCKET", false) && true %>
backup_clean:
class: "RakeJob"
args: ["db2fog:clean"]
cron: "45 2 * * *" # every day at 2:45am
enabled: <%= ENV.fetch("S3_BACKUPS_BUCKET", false) && true %>
ofn_clean:
class: "RakeJob"
args: ["ofn:data:remove_transient_data"]
cron: "30 4 1 * *" # every month on the first at 4:30am

View File

@@ -6,7 +6,7 @@
set -o pipefail
echo "::group:: Running prettier with reviewdog 🐶 ..."
echo -e "\nRunning prettier with reviewdog 🐶 ..."
"$(npm root)/.bin/prettier" --check . 2>&1 | sed --regexp-extended 's/(\[warn\].*)$/\1 File is not properly formatted./' \
| reviewdog \
@@ -25,7 +25,7 @@ echo "::group:: Running prettier with reviewdog 🐶 ..."
prettier=$?
echo "::group:: Running rubocop with reviewdog 🐶 ..."
echo -e "\nRunning rubocop with reviewdog 🐶 ..."
bundle exec rubocop \
--fail-level info \
@@ -39,7 +39,7 @@ bundle exec rubocop \
rubocop=$?
echo "::group:: Running haml-lint with reviewdog 🐶 ..."
echo -e "\nRunning haml-lint with reviewdog 🐶 ..."
bundle exec haml-lint \
--fail-level warning \

View File

@@ -461,14 +461,34 @@ RSpec.describe Spree::OrdersController do
end
end
context "when a guest user has the order token in session" do
let(:order) {
create(:completed_order_with_totals, user: nil, email: "guest@example.com",
distributor: create(:distributor_enterprise))
}
before do
allow(controller).to receive(:spree_current_user) { nil }
session[:access_token] = order.token
end
it "cancels the order and redirects to the order page" do
request.env['HTTP_REFERER'] = order_path(order)
spree_put :cancel, params
expect(response.body).to match(order_path(order)).and match("redirect")
expect(flash[:success]).to eq 'Your order has been cancelled'
end
end
context "when the user has permission to cancel the order" do
before { allow(controller).to receive(:spree_current_user) { user } }
context "when the order is not yet complete" do
it "responds with forbidden" do
request.env['HTTP_REFERER'] = order_path(order)
spree_put :cancel, params
expect(response).to have_http_status(:found)
expect(response.body).to match(order_path(order)).and match("redirect")
expect(flash[:error]).to eq 'Sorry, the order could not be cancelled'
end
@@ -481,9 +501,9 @@ RSpec.describe Spree::OrdersController do
}
it "responds with success" do
request.env['HTTP_REFERER'] = order_path(order)
spree_put :cancel, params
expect(response).to have_http_status(:found)
expect(response.body).to match(order_path(order)).and match("redirect")
expect(flash[:success]).to eq 'Your order has been cancelled'
end

View File

@@ -166,7 +166,6 @@ describe("TomSelectController", () => {
expect(settings.searchField).toBe("label");
expect(settings.load).toEqual(expect.any(Function));
expect(settings.firstUrl).toEqual(expect.any(Function));
expect(settings.onFocus).toEqual(expect.any(Function));
});
it("fetches page 1 on focus", async () => {

View File

@@ -16,12 +16,13 @@ describe("VerticalEllipsisMenuController test", () => {
<div data-controller="vertical-ellipsis-menu" id="root">
<div data-action="click->vertical-ellipsis-menu#toggle" id="button">...</div>
<div data-vertical-ellipsis-menu-target="content" id="content">
<a href="#" id="item"></a>
</div>
</div>
`;
const button = document.getElementById("button");
const content = document.getElementById("content");
const item = document.getElementById("item");
});
it("add show class to content when toggle is called", () => {
@@ -43,4 +44,15 @@ describe("VerticalEllipsisMenuController test", () => {
document.body.click();
expect(content.classList.contains("show")).toBe(false);
});
it("adds selected class to content when clicking a menu item", () => {
button.click();
expect(content.classList.contains("selected")).toBe(false);
item.click();
expect(content.classList.contains("selected")).toBe(true);
// and removes it again when clicking button again
button.click();
expect(content.classList.contains("selected")).toBe(false);
});
});

View File

@@ -0,0 +1,18 @@
# frozen_string_literal: true
require "tasks/data/remove_transient_data"
RSpec.describe RakeJob do
let(:task_string) { "ofn:data:remove_transient_data" }
it "calls the removal service" do
expect(RemoveTransientData).to receive(:new).and_call_original
RakeJob.perform_now(task_string)
end
it "can be called several times" do
expect(RemoveTransientData).to receive(:new).twice.and_call_original
RakeJob.perform_now(task_string)
RakeJob.perform_now(task_string)
end
end

View File

@@ -0,0 +1,285 @@
# frozen_string_literal: true
RSpec.describe "/admin/ajax_search" do
include AuthenticationHelper
let(:admin_user) { create(:admin_user) }
let(:regular_user) { create(:user) }
describe "GET /admin/ajax_search/producers" do
context "when user is not logged in" do
it "redirects to login" do
get admin_ajax_search_producers_path
expect(response).to redirect_to %r|#/login$|
end
end
context "when user is logged in without permissions" do
before { login_as regular_user }
it "redirects to unauthorized" do
get admin_ajax_search_producers_path
expect(response).to redirect_to('/unauthorized')
end
end
context "when user is an admin" do
before { login_as admin_user }
let!(:producer1) { create(:supplier_enterprise, name: "Apple Farm") }
let!(:producer2) { create(:supplier_enterprise, name: "Berry Farm") }
let!(:producer3) { create(:supplier_enterprise, name: "Cherry Orchard") }
let!(:distributor) { create(:distributor_enterprise, name: "Distributor") }
it "returns producers sorted alphabetically by name" do
get admin_ajax_search_producers_path
expect(response).to have_http_status(:ok)
json_response = response.parsed_body
expect(json_response["results"].pluck("label")).to eq(['Apple Farm', 'Berry Farm',
'Cherry Orchard'])
expect(json_response["pagination"]["more"]).to be false
end
it "filters producers by search query" do
get admin_ajax_search_producers_path, params: { q: "berry" }
json_response = response.parsed_body
expect(json_response["results"].pluck("label")).to eq(['Berry Farm'])
expect(json_response["results"].pluck("value")).to eq([producer2.id])
end
it "filters are case insensitive" do
get admin_ajax_search_producers_path, params: { q: "BERRY" }
json_response = response.parsed_body
expect(json_response["results"].pluck("label")).to eq(['Berry Farm'])
end
it "filters with partial matches" do
get admin_ajax_search_producers_path, params: { q: "Farm" }
json_response = response.parsed_body
expect(json_response["results"].pluck("label")).to eq(['Apple Farm', 'Berry Farm'])
end
it "excludes non-producer enterprises" do
get admin_ajax_search_producers_path
json_response = response.parsed_body
expect(json_response["results"].pluck("label")).not_to include('Distributor')
end
context "with more than 30 producers" do
before do
create_list(:supplier_enterprise, 35) do |enterprise, i|
enterprise.update!(name: "Producer #{(i + 1).to_s.rjust(2, '0')}")
end
end
it "returns first page with 30 results and more flag as true" do
get admin_ajax_search_producers_path, params: { page: 1 }
json_response = response.parsed_body
expect(json_response["results"].length).to eq(30)
expect(json_response["pagination"]["more"]).to be true
end
it "returns remaining results on second page with more flag as false" do
get admin_ajax_search_producers_path, params: { page: 2 }
json_response = response.parsed_body
expect(json_response["results"].length).to eq(8)
expect(json_response["pagination"]["more"]).to be false
end
end
end
context "when user has enterprise permissions" do
let!(:my_producer) { create(:supplier_enterprise, name: "My Producer") }
let!(:other_producer) { create(:supplier_enterprise, name: "Other Producer") }
let(:user_with_producer) { create(:user, enterprises: [my_producer]) }
before { login_as user_with_producer }
it "returns only managed producers" do
get admin_ajax_search_producers_path
json_response = response.parsed_body
expect(json_response["results"].pluck("label")).to eq(['My Producer'])
expect(json_response["results"].pluck("label")).not_to include('Other Producer')
end
end
end
describe "GET /admin/ajax_search/categories" do
context "when user is not logged in" do
it "redirects to login" do
get admin_ajax_search_categories_path
expect(response).to redirect_to %r|#/login$|
end
end
context "when user is logged in without permissions" do
before { login_as regular_user }
it "redirects to unauthorized" do
get admin_ajax_search_categories_path
expect(response).to redirect_to('/unauthorized')
end
end
context "when user is an admin" do
before { login_as admin_user }
let!(:category1) { create(:taxon, name: "Vegetables") }
let!(:category2) { create(:taxon, name: "Fruits") }
let!(:category3) { create(:taxon, name: "Dairy") }
it "returns categories sorted alphabetically by name" do
get admin_ajax_search_categories_path
expect(response).to have_http_status(:ok)
json_response = response.parsed_body
expect(json_response["results"].pluck("label")).to eq(['Dairy', 'Fruits', 'Vegetables'])
expect(json_response["pagination"]["more"]).to be false
end
it "filters categories by search query" do
get admin_ajax_search_categories_path, params: { q: "fruit" }
json_response = response.parsed_body
expect(json_response["results"].pluck("label")).to eq(['Fruits'])
expect(json_response["results"].pluck("value")).to eq([category2.id])
end
it "filters are case insensitive" do
get admin_ajax_search_categories_path, params: { q: "VEGETABLES" }
json_response = response.parsed_body
expect(json_response["results"].pluck("label")).to eq(['Vegetables'])
end
it "filters with partial matches" do
get admin_ajax_search_categories_path, params: { q: "ege" }
json_response = response.parsed_body
expect(json_response["results"].pluck("label")).to eq(['Vegetables'])
end
context "with more than 30 categories" do
before do
create_list(:taxon, 35) do |taxon, i|
taxon.update!(name: "Category #{(i + 1).to_s.rjust(2, '0')}")
end
end
it "returns first page with 30 results and more flag as true" do
get admin_ajax_search_categories_path, params: { page: 1 }
json_response = response.parsed_body
expect(json_response["results"].length).to eq(30)
expect(json_response["pagination"]["more"]).to be true
end
it "returns remaining results on second page with more flag as false" do
get admin_ajax_search_categories_path, params: { page: 2 }
json_response = response.parsed_body
expect(json_response["results"].length).to eq(8)
expect(json_response["pagination"]["more"]).to be false
end
end
end
end
describe "GET /admin/ajax_search/tax_categories" do
context "when user is not logged in" do
it "redirects to login" do
get admin_ajax_search_tax_categories_path
expect(response).to redirect_to %r|#/login$|
end
end
context "when user is logged in without permissions" do
before { login_as regular_user }
it "redirects to unauthorized" do
get admin_ajax_search_tax_categories_path
expect(response).to redirect_to('/unauthorized')
end
end
context "when user is an admin" do
before { login_as admin_user }
let!(:tax_cat1) { create(:tax_category, name: "GST") }
let!(:tax_cat2) { create(:tax_category, name: "VAT") }
let!(:tax_cat3) { create(:tax_category, name: "No Tax") }
it "returns tax categories sorted alphabetically by name" do
get admin_ajax_search_tax_categories_path
expect(response).to have_http_status(:ok)
json_response = response.parsed_body
expect(json_response["results"].pluck("label")).to eq(['GST', 'No Tax', 'VAT'])
expect(json_response["pagination"]["more"]).to be false
end
it "filters tax categories by search query" do
get admin_ajax_search_tax_categories_path, params: { q: "vat" }
json_response = response.parsed_body
expect(json_response["results"].pluck("label")).to eq(['VAT'])
expect(json_response["results"].pluck("value")).to eq([tax_cat2.id])
end
it "filters are case insensitive" do
get admin_ajax_search_tax_categories_path, params: { q: "GST" }
json_response = response.parsed_body
expect(json_response["results"].pluck("label")).to eq(['GST'])
end
it "filters with partial matches" do
get admin_ajax_search_tax_categories_path, params: { q: "tax" }
json_response = response.parsed_body
expect(json_response["results"].pluck("label")).to eq(['No Tax'])
end
context "with more than 30 tax categories" do
before do
create_list(:tax_category, 35) do |tax_cat, i|
tax_cat.update!(name: "Tax Category #{(i + 1).to_s.rjust(2, '0')}")
end
end
it "returns first page with 30 results and more flag as true" do
get admin_ajax_search_tax_categories_path, params: { page: 1 }
json_response = response.parsed_body
expect(json_response["results"].length).to eq(30)
expect(json_response["pagination"]["more"]).to be true
end
it "returns remaining results on second page with more flag as false" do
get admin_ajax_search_tax_categories_path, params: { page: 2 }
json_response = response.parsed_body
expect(json_response["results"].length).to eq(8)
expect(json_response["pagination"]["more"]).to be false
end
end
end
end
end

View File

@@ -19,12 +19,25 @@ module TomSelectHelper
tomselect_wrapper.find(:css, '.ts-dropdown div.create').click
end
# Searches for and selects an option in a TomSelect dropdown with search functionality.
# @param value [String] The text to search for and select from the dropdown
# @param options [Hash] Configuration options
# @option options [String] :from The name/id of the select field
# @option options [Boolean] :remote_search If true, waits for search loading after interactions
#
# @example
# tomselect_search_and_select("Apple", from: "fruit_selector")
# tomselect_search_and_select("California", from: "state", remote_search: true)
def tomselect_search_and_select(value, options)
tomselect_wrapper = page.find_field(options[:from]).sibling(".ts-wrapper")
tomselect_wrapper.find(".ts-control").click
expect_tomselect_loading_completion(tomselect_wrapper, options)
# Use send_keys as setting the value directly doesn't trigger the search
tomselect_wrapper.find(".ts-dropdown input.dropdown-input").send_keys(value)
tomselect_wrapper.find(".ts-dropdown .ts-dropdown-content .option.active", text: value).click
expect_tomselect_loading_completion(tomselect_wrapper, options)
tomselect_wrapper.find(".ts-dropdown .ts-dropdown-content .option", text: value).click
end
def tomselect_select(value, options)
@@ -64,4 +77,52 @@ module TomSelectHelper
end
end
end
# Validates both available options and selected options in a TomSelect dropdown.
# @param from [String] The name/id of the select field
# @param existing_options [Array<String>] List of options that should be available in the dropdown
# @param selected_options [Array<String>] List of options that should currently be selected
#
# @example
# expect_tomselect_existing_with_selected_options(
# from: "category_selector",
# existing_options: ["Fruit", "Vegetables", "Dairy"],
# selected_options: ["Fruit"]
# )
def expect_tomselect_existing_with_selected_options(from:, existing_options:, selected_options:)
tomselect_wrapper = page.find_field(from).sibling(".ts-wrapper")
tomselect_control = tomselect_wrapper.find('.ts-control')
tomselect_control.click # open the dropdown (would work for remote vs non-remote dropdowns)
# validate existing options are present in the dropdown
within(tomselect_wrapper) do
existing_options.each do |option|
expect(page).to have_css(
".ts-dropdown .ts-dropdown-content .option",
text: option
)
end
end
# validate selected options are selected in the dropdown
within(tomselect_wrapper) do
selected_options.each do |option|
expect(page).to have_css(
"div[data-ts-item]",
text: option
)
end
end
# close the dropdown by clicking on the already selected option
tomselect_wrapper.find(".ts-dropdown .ts-dropdown-content .option.active").click
end
def expect_tomselect_loading_completion(tomselect_wrapper, options)
return unless options[:remote_search]
expect(tomselect_wrapper).to have_css(".spinner")
expect(tomselect_wrapper).not_to have_css(".spinner")
end
end

View File

@@ -15,10 +15,6 @@ RSpec.describe 'As an enterprise user, I can perform actions on the products scr
login_as user
end
let(:producer_search_selector) { 'input[placeholder="Select producer"]' }
let(:categories_search_selector) { 'input[placeholder="Select category"]' }
let(:tax_categories_search_selector) { 'input[placeholder="Search for tax categories"]' }
describe "column selector" do
let!(:product) { create(:simple_product) }
@@ -102,54 +98,7 @@ RSpec.describe 'As an enterprise user, I can perform actions on the products scr
}
let!(:product_a) { create(:simple_product, name: "Apples", sku: "APL-00") }
context "when they are under 11" do
before do
create_list(:supplier_enterprise, 9, users: [user])
create_list(:tax_category, 9)
create_list(:taxon, 2)
visit admin_products_url
end
it "should not display search input, change the producers, category and tax category" do
producer_to_select = random_producer(variant_a1)
category_to_select = random_category(variant_a1)
tax_category_to_select = random_tax_category
within row_containing_name(variant_a1.display_name) do
validate_tomselect_without_search!(
page, "Producer",
producer_search_selector
)
tomselect_select(producer_to_select, from: "Producer")
end
within row_containing_name(variant_a1.display_name) do
validate_tomselect_without_search!(
page, "Category",
categories_search_selector
)
tomselect_select(category_to_select, from: "Category")
validate_tomselect_without_search!(
page, "Tax Category",
tax_categories_search_selector
)
tomselect_select(tax_category_to_select, from: "Tax Category")
end
click_button "Save changes"
expect(page).to have_content "Changes saved"
variant_a1.reload
expect(variant_a1.supplier.name).to eq(producer_to_select)
expect(variant_a1.primary_taxon.name).to eq(category_to_select)
expect(variant_a1.tax_category.name).to eq(tax_category_to_select)
end
end
context "when they are over 11" do
context "when there are products" do
before do
create_list(:supplier_enterprise, 11, users: [user])
create_list(:tax_category, 11)
@@ -167,9 +116,13 @@ RSpec.describe 'As an enterprise user, I can perform actions on the products scr
tax_category_to_select = random_tax_category
within row_containing_name(variant_a1.display_name) do
tomselect_search_and_select(producer_to_select, from: "Producer")
tomselect_search_and_select(category_to_select, from: "Category")
tomselect_search_and_select(tax_category_to_select, from: "Tax Category")
tomselect_search_and_select(producer_to_select, from: "Producer", remote_search: true)
tomselect_search_and_select(category_to_select, from: "Category", remote_search: true)
tomselect_search_and_select(
tax_category_to_select,
from: "Tax Category",
remote_search: true
)
end
click_button "Save changes"
@@ -285,8 +238,9 @@ RSpec.describe 'As an enterprise user, I can perform actions on the products scr
end
describe "Create linked variant" do
let!(:variant) {
create(:variant, display_name: "My box", supplier: producer)
let!(:variant) { create(:variant, display_name: "My box", supplier: producer) }
let!(:linked_variant) {
variant.create_linked_variant(user).tap{ |v| v.update! display_name: "My linked variant" }
}
let!(:other_producer) { create(:supplier_enterprise) }
let!(:other_variant) {
@@ -312,6 +266,15 @@ RSpec.describe 'As an enterprise user, I can perform actions on the products scr
expect(page).to have_link "Create linked variant"
end
close_action_menu
# Check my own linked variant
within row_containing_name("My linked variant") do
page.find(".vertical-ellipsis-menu").click
expect(page).not_to have_link "Create linked variant"
end
close_action_menu
# Create linked variant sourced from my friend
within row_containing_name("My friends box") do
@@ -332,12 +295,11 @@ RSpec.describe 'As an enterprise user, I can perform actions on the products scr
# Close action menu (shouldn't need this, it should close itself)
last_box.click
# And I can perform actions on the new product
# And I can perform actions on the new variant
within last_box do
page.find(".vertical-ellipsis-menu").click
expect(page).to have_link "Edit"
# expect(page).to have_link "Clone" # tofix: menu is partially obscured
# expect(page).to have_link "Delete" # it's not a proper link
expect(page).to have_selector "a", text: "Delete" # it's not a proper link
fill_in "Name", with: "My copy of Apples"
end
@@ -376,12 +338,10 @@ RSpec.describe 'As an enterprise user, I can perform actions on the products scr
}
describe "Actions columns (delete)" do
before do
visit admin_products_url
end
it "shows an actions menu with a delete link when clicking on icon for product. " \
"doesn't show delete link for the single variant" do
visit admin_products_url
within product_selector do
page.find(".vertical-ellipsis-menu").click
expect(page).to have_css(delete_option_selector)

View File

@@ -86,14 +86,14 @@ RSpec.describe 'As an enterprise user, I can manage my products' do
find('button[aria-label="On Hand"]').click
find('input[id$="_price"]').fill_in with: "11.1"
select supplier.name, from: 'Producer'
select taxon.name, from: 'Category'
if stock == "on_hand"
find('input[id$="_on_hand_desired"]').fill_in with: "66"
elsif stock == "on_demand"
find('input[id$="_on_demand_desired"]').check
end
tomselect_select supplier.name, from: 'Producer'
tomselect_select taxon.name, from: 'Category'
end
expect(page).to have_content "1 product modified."

View File

@@ -106,13 +106,19 @@ RSpec.describe 'As an enterprise user, I can browse my products' do
visit spree.admin_products_path
within row_containing_name "Variant1" do
expect(page).to have_select "Producer", with_options: ["Producer A", "Producer B"],
selected: "Producer A"
expect_tomselect_existing_with_selected_options(
from: 'Producer',
existing_options: ["Producer A", "Producer B"],
selected_options: ["Producer A"]
)
end
within row_containing_name "Variant2a" do
expect(page).to have_select "Producer", with_options: ["Producer A", "Producer B"],
selected: "Producer B"
expect_tomselect_existing_with_selected_options(
from: 'Producer',
existing_options: ["Producer A", "Producer B"],
selected_options: ["Producer B"]
)
end
end
end
@@ -543,24 +549,21 @@ RSpec.describe 'As an enterprise user, I can browse my products' do
it "shows only suppliers that I manage or have permission to" do
visit spree.admin_products_path
existing_options = [supplier_managed1.name, supplier_managed2.name, supplier_permitted.name]
within row_containing_placeholder(product_supplied.name) do
expect(page).to have_select(
'_products_0_variants_attributes_0_supplier_id',
options: [
'Select producer',
supplier_managed1.name, supplier_managed2.name, supplier_permitted.name
], selected: supplier_managed1.name
expect_tomselect_existing_with_selected_options(
existing_options:,
from: '_products_0_variants_attributes_0_supplier_id',
selected_options: [supplier_managed1.name]
)
end
within row_containing_placeholder(product_supplied_permitted.name) do
expect(page).to have_select(
'_products_1_variants_attributes_0_supplier_id',
options: [
'Select producer',
supplier_managed1.name, supplier_managed2.name, supplier_permitted.name
], selected: supplier_permitted.name
expect_tomselect_existing_with_selected_options(
existing_options:,
from: '_products_1_variants_attributes_0_supplier_id',
selected_options: [supplier_permitted.name]
)
end
end

View File

@@ -350,8 +350,8 @@ RSpec.describe 'As an enterprise user, I can update my products' do
click_on "On Hand" # activate popout
fill_in "On Hand", with: "3"
select producer.name, from: 'Producer'
select taxon.name, from: 'Category'
tomselect_select producer.name, from: 'Producer'
tomselect_select taxon.name, from: 'Category'
end
expect {
@@ -586,8 +586,8 @@ RSpec.describe 'As an enterprise user, I can update my products' do
fill_in "Name", with: "Nice box"
fill_in "SKU", with: "APL-02"
select producer.name, from: 'Producer'
select taxon.name, from: 'Category'
tomselect_select producer.name, from: 'Producer'
tomselect_select taxon.name, from: 'Category'
end
expect {

View File

@@ -17,7 +17,7 @@ RSpec.describe "admin/products_v3/_filters.html.haml" do
end
let(:spree_current_user) { build(:enterprise_user) }
it "shows the producer filter when there are options" do
it "shows the producer filter with the default option initially" do
allow(view).to receive_messages locals.merge(
producer_options: [
["Ada's Apples", 1],
@@ -27,9 +27,7 @@ RSpec.describe "admin/products_v3/_filters.html.haml" do
is_expected.to have_content "Producers"
is_expected.to have_select "producer_id", options: [
"All producers",
"Ada's Apples",
"Ben's Bananas",
"All producers"
], selected: nil
end

View File

@@ -5270,9 +5270,9 @@ node-addon-api@^7.0.0:
integrity sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==
node-forge@^1:
version "1.3.3"
resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.3.3.tgz#0ad80f6333b3a0045e827ac20b7f735f93716751"
integrity sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==
version "1.4.0"
resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.4.0.tgz#1c7b7d8bdc2d078739f58287d589d903a11b2fc2"
integrity sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==
node-int64@^0.4.0:
version "0.4.0"
@@ -7111,9 +7111,9 @@ tr46@^5.1.0:
punycode "^2.3.1"
trix@*:
version "2.1.17"
resolved "https://registry.yarnpkg.com/trix/-/trix-2.1.17.tgz#a47c4ee1925a7abb26aee5c094ec7ef9fe49575a"
integrity sha512-nkHg7VgIItGVx1CFA645dDlAoCgah+9gv80Yc+97aS8jkZmO5K4MxkSqmU9t/C8upqZB8uhfJs90epoDfYz/6Q==
version "2.1.18"
resolved "https://registry.yarnpkg.com/trix/-/trix-2.1.18.tgz#d87a5be64c10ecdab64f5af47f941b5318c08225"
integrity sha512-DWOdTsz3n9PO3YBc1R6pGh9MG1cXys/2+rouc/qsISncjc2MBew2UOW8nXh3NjUOjobKsXCIPR6LB02abg2EYg==
dependencies:
dompurify "^3.2.5"