Merge pull request #11163 from jibees/productsV3-searching-filtering-pagination

🚧 Products v3: viewing, searching,  filtering & pagination
This commit is contained in:
jibees
2023-07-25 15:22:42 +02:00
committed by GitHub
29 changed files with 1041 additions and 168 deletions

View File

@@ -191,6 +191,8 @@ module Spree
OpenFoodNetwork::Permissions.new(user).managed_product_enterprises.include? product.supplier
end
can [:admin, :index], :products_v3
can [:create], Spree::Variant
can [:admin, :index, :read, :edit,
:update, :search, :delete, :destroy], Spree::Variant do |variant|

View File

@@ -21,6 +21,12 @@ module Spree
NAME_FIELDS = ["display_name", "display_as", "weight", "unit_value", "unit_description"].freeze
SEARCH_KEY = "#{%w(name
meta_keywords
variants_display_as
variants_display_name
supplier_name).join('_or_')}_cont".freeze
belongs_to :product, -> { with_deleted }, touch: true, class_name: 'Spree::Product'
delegate_belongs_to :product, :name, :description, :tax_category_id, :shipping_category_id,

View File

@@ -1,48 +0,0 @@
# frozen_string_literal: true
module Admin
class ProductsV3Reflex < ApplicationReflex
before_reflex :fetch_products, only: [:fetch]
def fetch
cable_ready.replace(
selector: "#products-content",
html: render(partial: "admin/products_v3/content", locals: { products: @products })
).broadcast
morph :nothing
end
private
# copied from ProductsTableComponent
def fetch_products
product_query = OpenFoodNetwork::Permissions.new(current_user)
.editable_products.merge(product_scope)
@products = product_query.order(:name).limit(50)
end
def product_scope
scope = if current_user.has_spree_role?("admin") || current_user.enterprises.present?
Spree::Product
else
Spree::Product.active
end
scope.includes(product_query_includes)
end
# Optimise by pre-loading required columns
def product_query_includes
# TODO: add other fields used in columns? (eg supplier: [:name])
[
# variants: [
# :default_price,
# :stock_locations,
# :stock_items,
# :variant_overrides
# ]
]
end
end
end

View File

@@ -0,0 +1,133 @@
# frozen_string_literal: true
class ProductsReflex < ApplicationReflex
include Pagy::Backend
before_reflex :init_filters_params, :init_pagination_params
def fetch
fetch_and_render_products
end
def change_per_page
@per_page = element.value.to_i
@page = 1
fetch_and_render_products
end
def filter
@page = 1
fetch_and_render_products
end
def clear_search
@search_term = nil
@producer_id = nil
@category_id = nil
@page = 1
fetch_and_render_products
end
private
def init_filters_params
# params comes from the form
# _params comes from the url
# priority is given to params from the form (if present) over url params
@search_term = params[:search_term] || params[:_search_term]
@producer_id = params[:producer_id] || params[:_producer_id]
@category_id = params[:category_id] || params[:_category_id]
end
def init_pagination_params
# prority is given to element dataset (if present) over url params
@page = element.dataset.page || params[:_page] || 1
@per_page = element.dataset.perpage || params[:_per_page] || 15
end
def fetch_and_render_products
fetch_products
render_products
end
def render_products
cable_ready.replace(
selector: "#products-content",
html: render(partial: "admin/products_v3/content",
locals: { products: @products, pagy: @pagy, search_term: @search_term,
producer_options: producers, producer_id: @producer_id,
category_options: categories, category_id: @category_id })
).broadcast
cable_ready.replace_state(
url: current_url,
).broadcast_later
morph :nothing
end
def producers
producers = OpenFoodNetwork::Permissions.new(current_user)
.managed_product_enterprises.is_primary_producer.by_name
producers.map { |p| [p.name, p.id] }
end
def categories
Spree::Taxon.order(:name).map { |c| [c.name, c.id] }
end
# copied from ProductsTableComponent
def fetch_products
product_query = OpenFoodNetwork::Permissions.new(current_user)
.editable_products.merge(product_scope).ransack(ransack_query).result
@pagy, @products = pagy(product_query.order(:name), items: @per_page, page: @page)
end
def product_scope
scope = if current_user.has_spree_role?("admin") || current_user.enterprises.present?
Spree::Product
else
Spree::Product.active
end
scope.includes(product_query_includes)
end
def ransack_query
query = { s: "name desc" }
query.merge!(supplier_id_in: @producer_id) if @producer_id.present?
if @search_term.present?
query.merge!(Spree::Variant::SEARCH_KEY => @search_term)
end
query.merge!(primary_taxon_id_in: @category_id) if @category_id.present?
query
end
# Optimise by pre-loading required columns
def product_query_includes
# TODO: add other fields used in columns? (eg supplier: [:name])
[
# variants: [
# :default_price,
# :stock_locations,
# :stock_items,
# :variant_overrides
# ]
]
end
def current_url
url = URI(request.original_url)
url.query = url.query.present? ? "#{url.query}&" : ""
# add params with _ to avoid conflicts with params from the form
url.query += "_page=#{@page}"
url.query += "&_per_page=#{@per_page}"
url.query += "&_search_term=#{@search_term}" if @search_term.present?
url.query += "&_producer_id=#{@producer_id}" if @producer_id.present?
url.query += "&_category_id=#{@category_id}" if @category_id.present?
url.to_s
end
end

View File

@@ -1,10 +1,17 @@
- if products.any?
= render partial: 'table', locals: { products: products }
- else
#no-products
= t('.no_products_found')
#no-products-actions
%a{ href: "/admin/products/new", class: "button icon-plus", icon: "icon-plus" }
= t(:new_product)
%a{ href: "/admin/products/import", class: "button icon-upload secondary", icon: "icon-upload" }
= t(".import_products")
#products-content
.container
.sixteen.columns
= render partial: 'filters', locals: { search_term: search_term,
producer_id: producer_id,
producer_options: producer_options,
category_options: category_options,
category_id: category_id }
- if products.any?
.container
.sixteen.columns
= render partial: 'sort', locals: { pagy: pagy, search_term: search_term, producer_id: producer_id, category_id: category_id }
= render partial: 'table', locals: { products: products }
= render partial: 'admin/shared/v3/pagy', locals: { pagy: pagy, reflex: "click->Products#fetch" }
- else
#no-products
= render partial: "no_products", locals: { search_term: search_term, producer_id: producer_id, category_id: category_id }

View File

@@ -0,0 +1,13 @@
%form{ id: "filters", 'data-reflex-serialize-form': true, 'data-reflex': 'submit->products#filter' }
.query
.search-input
= text_field_tag :search_term, search_term, placeholder: t('.search_products')
.producers
.label= t('.producers.label')
= select_tag :producer_id, options_for_select(producer_options, producer_id), include_blank: t('.all_producers')
.categories
.label= t('.categories.label')
= select_tag :category_id, options_for_select(category_options, category_id), include_blank: t('.all_categories')
.submit
.search-button
= button_tag t(".search"), class: "secondary icon-search"

View File

@@ -0,0 +1,11 @@
- if search_term.present? || producer_id.present? || category_id.present?
= t('.no_products_found_for_search')
%a{ href: "#", class: "button disruptive", data: { reflex: "click->products#clear_search" } }
= t("admin.products_v3.sort.pagination.clear_search")
- else
= t('.no_products_found')
#no-products-actions
%a{ href: "/admin/products/new", class: "button icon-plus", icon: "icon-plus" }
= t(:new_product)
%a{ href: "/admin/products/import", class: "button icon-upload secondary", icon: "icon-upload" }
= t(".import_products")

View File

@@ -0,0 +1,9 @@
#sort
%div
= t(".pagination.total_html", total: pagy.count, from: pagy.from, to: pagy.to)
- if search_term.present? || producer_id.present? || category_id.present?
%a{ href: "#", class: "button disruptive medium", data: { reflex: "click->products#clear_search" } }
= t(".pagination.clear_search")
%div.with-dropdown
= t(".pagination.per_page.show")
= select_tag :per_page, options_for_select([15, 25, 50, 100].collect{|i| [t('.pagination.per_page.per_page', num: i), i]}, pagy.items), data: { reflex: "change->products#change_per_page" }

View File

@@ -8,19 +8,17 @@
%col{ width:"10%" }
%col{ width:"5%" }
%col{ width:"5%", style: "max-width:5em" }
%col{ width:"8%", style: "max-width:8em" }
%thead
%tr
%th.align-left= t('admin.product.name')
%th.align-right= t('admin.sku')
%th.align-right= t('admin.unit')
%th.align-right= t('admin.price')
%th.align-right= t('admin.on_hand')
%th.align-left= t('admin.producer')
%th.align-left= t('admin.category')
%th.align-left= t('admin.tax_category')
%th.align-left= t('admin.inherits_properties')
%th.align-right= t('admin.available_on')
%th.align-left= t('admin.products_page.columns.name')
%th.align-right= t('admin.products_page.columns.sku')
%th.align-right= t('admin.products_page.columns.unit')
%th.align-right= t('admin.products_page.columns.price')
%th.align-right= t('admin.products_page.columns.on_hand')
%th.align-left= t('admin.products_page.columns.producer')
%th.align-left= t('admin.products_page.columns.category')
%th.align-left= t('admin.products_page.columns.tax_category')
%th.align-left= t('admin.products_page.columns.inherits_properties')
- products.each do |product|
%tbody.relaxed
%tr
@@ -46,8 +44,6 @@
.line-clamp-1= product.tax_category&.name
%td.align-left
.line-clamp-1= product.inherits_properties ? 'YES' : 'NO' #TODO: consider using https://github.com/RST-J/human_attribute_values, else use I18n.t (also below)
%td.align-right
.line-clamp-1= product.available_on&.strftime('%F')
- product.variants.each do |variant|
%tr.condensed
%td.align-left
@@ -68,6 +64,4 @@
.line-clamp-1= variant.tax_category&.name
%td.align-left
.line-clamp-1= variant.product.inherits_properties ? 'YES' : 'NO' # same as product
%td.align-right
.line-clamp-1= variant.available_on&.strftime('%F')

View File

@@ -10,8 +10,8 @@
= render partial: 'spree/admin/shared/product_sub_menu'
#products_v3_page{"data-controller": "productsV3"}
#loading-spinner.spinner-container{"data-productsV3-target": "loading"}
#products_v3_page{ "data-controller": "products" }
#loading-spinner.spinner-container{ "data-controller": "loading", "data-products-target": "loading" }
.spinner
= t('.loading')
#products-content

View File

@@ -0,0 +1,21 @@
%nav.pagy_nav.pagination{"aria-label" => "pager", :role => "navigation"}
- if pagy.prev
%a.page.prev{ href: "#", id: "pagy-prev", "data-reflex": reflex, "data-perPage": pagy.items, "data-page": pagy.prev || 1, "aria-label": "previous"}
%i.icon-chevron-left
- else
%span.page.prev.disabled
%i.icon-chevron-left
- pagy.series.each do |item| # series example: [1, :gap, 7, 8, "9", 10, 11, :gap, 36]
- if item.is_a?(Integer) # page link
%a.page{ href: "#", id:"pagy-#{item}", "data-reflex": reflex, "data-perPage": pagy.items, "data-page": item, "aria-label": "page #{item}"}
= item
- elsif item.is_a?(String) # current page
%span.page.current= item
- elsif item == :gap # page gap
%span.page.gap &hellip;
- if pagy.next
%a.page.next{ href: "#", id:"pagy-next", "data-reflex": reflex, "data-perPage": pagy.items, "data-page": pagy.next || pagy.last, "aria-label": "next"}
%i.icon-chevron-right
- else
%span.page.next.disabled
%i.icon-chevron-right

View File

@@ -0,0 +1,15 @@
import ApplicationController from "./application_controller";
export default class extends ApplicationController {
connect() {
super.connect();
}
hideLoading = () => {
this.element.classList.add("hidden");
};
showLoading = () => {
this.element.classList.remove("hidden");
};
}

View File

@@ -1,24 +0,0 @@
import ApplicationController from "./application_controller";
export default class extends ApplicationController {
static targets = ["loading"];
connect() {
super.connect();
// Fetch the products on page load
this.load();
}
load = () => {
this.showLoading();
this.stimulate("Admin::ProductsV3#fetch").then(() => this.hideLoading());
};
hideLoading = () => {
this.loadingTarget.classList.add("hidden");
};
showLoading = () => {
this.loadingTarget.classList.remove("hidden");
};
}

View File

@@ -0,0 +1,38 @@
import ApplicationController from "./application_controller";
export default class extends ApplicationController {
static targets = ["loading"];
connect() {
super.connect();
// Fetch the products on page load
this.stimulate("Products#fetch");
}
beforeReflex() {
this.showLoading();
}
afterReflex() {
this.hideLoading();
}
showLoading = () => {
if (this.getLoadingController()) {
this.getLoadingController().showLoading();
}
};
hideLoading = () => {
if (this.getLoadingController()) {
this.getLoadingController().hideLoading();
}
};
getLoadingController = () => {
return (this.loadongController = this.application.getControllerForElementAndIdentifier(
this.loadingTarget,
"loading"
));
};
}

View File

@@ -1,12 +1,16 @@
// Customisations for the new Bulk Edit Products page only
.products_v3_page {
#content .container {
#content > .row:first-child > .container:first-child {
// Allow table to extend to full width of available screen space
// TODO: move this to a generic rule, eg body.full-width{}. Then it can be included on any page.
// or even better, create a switch that allows you to yield the page content without the surrounding content class. then you still have control to add the .content div where needed.
max-width: none;
}
#products-content > .container:first-child {
position: static;
}
// Hopefully these rules will be moved to component(s).
table.products {
table-layout: fixed; // Column widths are based solely on col definitions (not content). This allows more efficient rendering.
@@ -78,4 +82,98 @@
gap: 20px;
}
}
#sort,
#filters {
margin-bottom: 1em;
display: flex;
justify-content: space-between;
align-items: center;
}
#sort {
line-height: $btn-medium-height;
height: $btn-medium-height;
.with-dropdown {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
}
}
#filters {
gap: 20px;
align-items: flex-end;
.producers,
.categories {
> .label {
margin-left: 3px;
margin-bottom: 2px;
}
}
.query {
flex-grow: 1;
}
.producers,
.categories {
flex-grow: 0;
}
.submit {
flex-grow: 0;
}
.query {
.search-input {
width: 100%;
position: relative;
background-color: $lighter-grey;
border: 1px solid $lighter-grey;
border-radius: 4px;
height: $btn-height;
line-height: $btn-height;
&:has(input:focus),
&:has(input:active) {
border: 1px solid $dark-blue;
}
> input {
background-color: $lighter-grey;
}
&:before {
font-family: FontAwesome;
content: "\f002";
color: $near-black;
font-size: 16px;
margin-left: 10px;
}
}
}
.producers,
.categories {
select {
width: 150px;
height: $btn-height;
}
}
.submit {
text-align: right;
.search-button {
position: relative;
> input {
padding-left: 30px;
}
}
}
}
}

View File

@@ -16,5 +16,3 @@ $admin-table-border: $pale-blue;
$modal-close-button-color: #de6060;
$modal-close-button-hover-color: #bf4545;
$disabled-button: $light-grey;
$border-radius: 3px;

View File

@@ -22,15 +22,16 @@
@import "globals/variables"; // admin_v3
@import "../admin/variables";
@import "../admin/globals/mixins";
@import "mixins"; // admin_v3
@import "../admin/plugins/font-awesome";
@import "../shared/variables/layout";
@import "../shared/variables/variables";
@import "../shared/utilities";
@import "../admin/shared/typography";
@import "shared/typography"; // admin_v3
@import "shared/tables"; // admin_v3
@import "../admin/shared/icons";
@import "shared/icons"; // admin_v3
@import "../admin/shared/forms";
@import "shared/layout"; // admin_v3
@import "../admin/shared/scroll_bar";
@@ -50,7 +51,7 @@
@import "../admin/components/actions";
@import "../admin/components/alert-box";
@import "../admin/components/alert_row";
@import "../admin/components/buttons";
@import "components/buttons"; // admin_v3
@import "../admin/components/date-picker";
@import "../admin/components/dialogs";
@import "../admin/components/input";

View File

@@ -0,0 +1,108 @@
input[type="submit"],
input[type="button"]:not(.trix-button),
button:not(.plain):not(.trix-button),
.button {
position: relative;
cursor: pointer;
font-size: 14px;
@include border-radius($border-radius);
display: inline-block;
padding: 0px 12px;
background-color: $color-btn-bg;
border: 1px solid $color-btn-bg;
color: $color-btn-text;
text-transform: uppercase;
line-height: 40px;
height: 40px;
font-weight: bold;
&:before {
font-weight: normal !important;
}
&:active,
&:focus {
outline: none;
border: 1px solid $color-btn-hover-border;
}
&:active:focus {
box-shadow: none;
}
&:hover {
background-color: $color-btn-hover-bg;
border: 1px solid $color-btn-hover-bg;
color: $color-btn-hover-text;
}
&.fullwidth {
width: 100%;
text-align: center;
}
&.secondary {
background-color: transparent;
border: 1px solid $color-btn-bg;
color: $color-btn-bg;
&:hover {
background-color: $color-11;
border: 1px solid $color-10;
color: $color-10;
}
&:active,
&:focus {
background-color: $color-11;
border: 1px solid $color-4;
color: $color-4;
}
}
&.disruptive {
background-color: transparent;
border: 1px solid $color-5;
color: $color-5;
&:hover {
background-color: $fair-pink;
border: 1px solid $color-5;
color: $color-5;
}
&:active,
&:focus {
background-color: $fair-pink;
border: 1px solid $roof-terracotta;
color: $roof-terracotta;
}
}
&.medium {
line-height: $btn-medium-height;
height: $btn-medium-height;
}
.badge {
position: absolute;
top: 0;
right: 0;
transform: translateY(-50%);
font-size: 10px;
text-transform: capitalize;
padding: 0px 5px;
border-radius: 3px;
&:before {
padding: 0;
}
&.danger {
background-color: $warning-red;
}
&.success {
background-color: $spree-green;
}
}
}

View File

@@ -61,46 +61,9 @@ nav.menu {
}
#admin-menu {
box-shadow: 0px 1px 0px rgba(0, 0, 0, 0.05), 0px 2px 2px rgba(0, 0, 0, 0.07);
ul {
display: flex;
}
@include defaultBoxShadow;
li {
min-width: 90px;
flex-grow: 1;
padding-left: 2px;
padding-right: 2px;
a {
display: block;
padding: 25px 5px;
color: $dark-grey !important;
position: relative;
text-align: center;
font-weight: 600;
font-size: 16px;
i {
display: inline;
}
&:hover {
color: $red !important;
border-bottom: 2px solid $red;
}
span.text {
font-weight: 600;
}
}
a::before {
font-weight: normal;
padding-top: 0;
}
.dropdown {
width: 300px;
background-color: $teal;
@@ -115,31 +78,71 @@ nav.menu {
}
}
}
&.selected a {
@extend a, :hover;
}
}
}
#sub-menu {
padding-bottom: 0;
box-shadow: 0px 1px 0px $light-grey;
box-shadow: 0px 1px 0px $color-7;
}
li {
a {
display: block;
padding: 12px 20px;
color: $dark-grey;
text-align: center;
position: relative;
font-size: 14px;
// Factorized rules on menu item for admin menu and sub menu
#admin-menu,
#sub-menu {
.container {
padding-left: 10px;
padding-right: 10px;
}
ul {
display: flex;
li {
a {
display: inline-block;
padding: 16px 20px;
color: $color-9 !important;
text-align: center;
position: relative;
font-size: 14px;
font-weight: 600;
&:hover {
color: $red !important;
&:after {
content: "";
position: absolute;
bottom: 0;
left: 20px;
right: 20px;
height: 3px;
background: $red;
}
}
}
&.selected a {
@extend a, :hover;
}
}
}
}
&.selected a,
a:hover {
color: $red;
border-bottom: 2px solid $red;
// Specific rules on menu item for admin menu and sub menu
#admin-menu {
ul {
justify-content: space-between;
li a {
font-size: 16px;
}
}
}
#sub-menu {
ul li a:hover {
&:after {
height: 2px;
}
}
}
@@ -148,6 +151,11 @@ nav.menu {
margin: 0.25em 0;
}
#header .container {
padding-left: 30px;
padding-right: 30px;
}
#login-nav {
line-height: 1.75em;
}

View File

@@ -1,21 +1,37 @@
.pagination {
text-align: center;
margin: 2em 0 1em;
margin: 0 0 1em;
padding: 10px 0;
background-color: $light-grey;
background-color: $color-7;
.page {
padding: 5px 8px;
width: 40px;
line-height: 40px;
text-align: center;
display: inline-block;
text-align: center;
background-color: $color-1;
@include defaultBoxShadow;
border-radius: 4px;
color: $color-9;
&.current {
background-color: $green;
border-radius: 3px;
background-color: $color-5;
color: $white;
}
&.prev {
margin-right: 20px;
}
&.next {
margin-left: 20px;
}
&.disabled {
cursor: default;
}
}
button {

View File

@@ -10,6 +10,7 @@
gap: 40px;
font-size: 24px;
background: rgba(255, 255, 255, 0.8);
z-index: 2;
&.hidden {
display: none;

View File

@@ -2,12 +2,17 @@
$white: #ffffff !default; // White
$green: #9fc820 !default; // Green
$teal: #008397 !default; // Teal (Allports)
$orient: #006878 !default; // Orient (Cerulean)
$dark-blue: #004e5b !default; // Dark Blue (Sherpa)
$red: #c85136 !default; // Red/Orange (Mojo)
$yellow: #ff9300 !default; // Yellow
$mystic: #d9e8eb !default; // Mystic
$lighter-grey: #f8f9fa !default; // Lighter grey
$light-grey: #eff1f2 !default; // Light grey
$near-black: #191c1d !default; // Near-black
$dark-grey: #2e3132 !default; // Dark Grey
$fair-pink: #ffefeb !default; // Fair Pink
$roof-terracotta: #b83b1f !default; // Roof Terracotta
// Old colour variables for backwards compatibility
$color-1: $white;
@@ -19,3 +24,7 @@ $color-6: $yellow;
$color-7: $light-grey;
$color-8: $near-black;
$color-9: $dark-grey;
$color-10: $orient;
$color-11: $mystic;
$color-12: $fair-pink;
$color-13: $roof-terracotta;

View File

@@ -39,8 +39,9 @@ $padding-tbl-cell-relaxed: 16px 12px;
$color-btn-bg: $teal !default;
$color-btn-text: $white !default;
$color-btn-shadow: 0px 1px 0px rgba(0, 0, 0, 0.05), 0px 2px 2px rgba(0, 0, 0, 0.07) !default;
$color-btn-hover-bg: lighten($color-btn-bg, 2) !default;
$color-btn-hover-bg: $orient !default;
$color-btn-hover-text: $white !default;
$color-btn-hover-border: $dark-blue !default;
// Actions colors
$color-action-edit-bg: very-light($color-success, 5 ) !default;
@@ -142,7 +143,10 @@ $h3-size: $h4-size + 2 !default;
$h2-size: $h3-size + 2 !default;
$h1-size: $h2-size + 2 !default;
$border-radius: 3px !default;
$border-radius: 4px !default;
$font-weight-bold: 600 !default;
$font-weight-normal: 400 !default;
$btn-height: 40px !default;
$btn-medium-height: 32px !default;

View File

@@ -0,0 +1,3 @@
@mixin defaultBoxShadow {
box-shadow: 0px 1px 0px rgba(0, 0, 0, 0.05), 0px 2px 2px rgba(0, 0, 0, 0.07);
}

View File

@@ -0,0 +1,42 @@
// Some fixes for fontwesome stylesheets
[class*="icon-"] {
&:before {
padding-right: 5px;
}
&.button,
&.icon_link {
&:before {
padding-right: 8px;
}
}
}
// for the button tagname as well
button[class*="icon-"] {
&:before {
padding-right: 8px;
}
}
.icon-email:before {
@extend .icon-envelope, :before;
}
.icon-resend_authorization_email:before {
@extend .icon-envelope, :before;
}
.icon-resume:before {
@extend .icon-refresh, :before;
}
.icon-cancel:before,
.icon-void:before {
@extend .icon-remove, :before;
}
.icon-capture {
@extend .icon-ok;
}
.icon-credit:before {
@extend .icon-ok, :before;
}

View File

@@ -205,3 +205,7 @@ table {
}
}
}
table + .pagination {
margin-top: -18px;
}

View File

@@ -0,0 +1,226 @@
// Base
//--------------------------------------------------------------
body,
div,
dl,
dt,
dd,
ul,
ol,
li,
h1,
h2,
h3,
h4,
h5,
h6,
pre,
form,
p,
blockquote,
th,
td {
margin: 0;
padding: 0;
font-size: $body-font-size;
}
body {
font-family: $base-font-family;
font-size: $body-font-size;
font-weight: 400;
color: $color-body-text;
text-rendering: optimizeLegibility;
}
hr {
border-top: 1px solid $color-border;
border-bottom: 1px solid white;
border-left: none;
}
strong,
b {
font-weight: 600;
}
// links
//--------------------------------------------------------------
a:not(.button) {
color: $color-link;
text-decoration: none;
line-height: inherit;
&,
&:hover,
&:active,
&:visited,
&:focus {
outline: none;
}
&:visited {
color: $color-link-visited;
}
&:focus {
color: $color-link-focus;
}
&:active {
color: $color-link-active;
}
&:hover {
color: $color-link-hover;
}
}
// Headings
//--------------------------------------------------------------
h1,
h2,
h3,
h4,
h5,
h6 {
font-weight: 600;
color: $color-headers;
line-height: 1.1;
}
h1 {
font-size: $h1-size;
line-height: $h1-size + 6;
}
h2 {
font-size: $h2-size;
line-height: $h1-size + 4;
}
h3 {
font-size: $h3-size;
line-height: $h1-size + 2;
}
h4 {
font-size: $h4-size;
line-height: $h1-size;
}
h5 {
font-size: $h5-size;
line-height: $h1-size;
}
h6 {
font-size: $h6-size;
line-height: $h1-size;
}
// Lists
//--------------------------------------------------------------
ul {
&.inline-menu {
li {
display: inline-block;
}
}
&.fields {
list-style: none;
padding: 0;
margin: 0;
}
}
dl {
width: 100%;
overflow: hidden;
margin: 5px 0;
color: lighten($color-body-text, 15);
dt,
dd {
float: left;
line-height: 16px;
padding: 5px;
text-align: justify;
}
dt {
width: 40%;
font-weight: 600;
padding-left: 0;
text-transform: uppercase;
font-size: 85%;
}
dd {
width: 60%;
padding-right: 0;
}
dd:after {
content: "";
clear: both;
}
}
// Helpers
.align-center {
text-align: center;
}
.align-right {
text-align: right;
}
.align-left {
text-align: left;
}
.align-justify {
text-align: justify;
}
.uppercase {
text-transform: uppercase;
}
.green {
color: $color-2;
}
.blue {
color: $color-3;
}
.red {
color: $color-5;
}
.yellow {
color: $color-6;
}
.no-objects-found {
text-align: center;
font-size: 120%;
text-transform: uppercase;
padding: 40px 0px;
color: lighten($color-body-text, 15);
}
.text-normal {
font-size: 1rem;
font-weight: 300;
}
.text-big {
font-size: 1.2rem;
font-weight: 300;
}
.text-red {
color: $warning-red;
}
input.text-big {
font-size: 1.1rem;
}
.pad-top {
padding-top: 1em;
}
.white-space-nowrap {
white-space: nowrap;
}

View File

@@ -772,9 +772,27 @@ en:
header:
title: Bulk Edit Products
loading: Loading your products
sort:
pagination:
total_html: "<strong>%{total} products</strong> in your catalogue. Showing %{from} to %{to}."
per_page:
show: Show
per_page: "%{num} per page"
clear_search: Clear search
filters:
search_products: Search for products
all_producers: All producers
all_categories: All categories
producers:
label: Producers
categories:
label: Categories
search: Search
content:
no_products:
no_products_found: No products found
import_products: Import multiple products
no_products_found_for_search: No products found for your search criteria
product_import:
title: Product Import
file_not_found: File not found or could not be opened
@@ -4248,7 +4266,6 @@ See the %{link} to find out more about %{sitename}'s features and to start using
category: Category
tax_category: Tax Category
inherits_properties?: Inherits Properties?
available_on: Available On
av_on: "Av. On"
import_date: "Import Date"
products_variant:

View File

@@ -0,0 +1,161 @@
# frozen_string_literal: true
require "system_helper"
describe 'As an admin, I can see the new product page' do
include WebHelper
include AuthenticationHelper
include FileHelper
# create lot of products
70.times do |i|
let!("product_#{i}".to_sym) { create(:simple_product, name: "product #{i}") }
end
# create a product with a name that can be searched
let!(:product_by_name) { create(:simple_product, name: "searchable product") }
# create a product with a supplier that can be searched
let!(:producer) { create(:supplier_enterprise, name: "Producer 1") }
let!(:product_by_supplier) { create(:simple_product, supplier: producer) }
# create a product with a category that can be searched
let!(:product_by_category) {
create(:simple_product, primary_taxon: create(:taxon, name: "Category 1"))
}
before do
# activate feature toggle admin_style_v3 to use new admin interface
Flipper.enable(:admin_style_v3)
login_as_admin
end
it "can see the new product page" do
visit "/admin/products_v3"
expect(page).to have_content "Bulk Edit Products"
end
context "pagination" do
before :each do
visit "/admin/products_v3"
end
it "has a pagination, has 15 products per page by default and can change the page" do
expect(page).to have_selector ".pagination"
expect_products_count_to_be 15
within ".pagination" do
click_link "2"
end
expect_page_to_be 2
expect_per_page_to_be 15
expect_products_count_to_be 15
end
it "can change the number of products per page" do
select "50", from: "per_page"
expect_page_to_be 1
expect_per_page_to_be 50
expect_products_count_to_be 50
end
end
context "search" do
before :each do
visit "/admin/products_v3"
end
context "search by search term" do
it "can search for a product" do
search_for "searchable product"
expect(page).to have_field "search_term", with: "searchable product"
expect_page_to_be 1
expect_products_count_to_be 1
end
it "reset the page when searching" do
within ".pagination" do
click_link "2"
end
expect_page_to_be 2
expect_per_page_to_be 15
expect_products_count_to_be 15
search_for "searchable product"
expect_page_to_be 1
expect_products_count_to_be 1
end
end
context "search by producer" do
it "has a producer select" do
expect(page).to have_selector "select#producer_id"
end
it "can search for a product" do
search_by_producer "Producer 1"
expect(page).to have_select "producer_id", selected: "Producer 1"
expect_page_to_be 1
expect_products_count_to_be 1
end
end
context "search by category" do
it "can search for a product" do
search_by_category "Category 1"
expect(page).to have_select "category_id", selected: "Category 1"
expect_page_to_be 1
expect_products_count_to_be 1
expect(page).to have_selector "table.products tbody tr td", text: product_by_category.name
end
end
context "clear filters" do
it "can clear filters" do
search_for "searchable product"
expect(page).to have_field "search_term", with: "searchable product"
expect_page_to_be 1
expect_products_count_to_be 1
expect(page).to have_selector "table.products tbody tr td", text: product_by_name.name
click_link "Clear search"
expect(page).to have_field "search_term", with: ""
expect_page_to_be 1
expect_products_count_to_be 15
end
end
context "no results" do
it "shows a message when there are no results" do
search_for "no results"
expect(page).to have_content "No products found for your search criteria"
expect(page).to have_link "Clear search"
end
end
end
def expect_page_to_be(page_number)
expect(page).to have_selector ".pagination span.page.current", text: page_number.to_s
end
def expect_per_page_to_be(per_page)
expect(page).to have_selector "#per_page", text: per_page.to_s
end
def expect_products_count_to_be(count)
expect(page).to have_selector("table.products tbody", count:)
end
def search_for(term)
fill_in "search_term", with: term
click_button "Search"
end
def search_by_producer(producer)
select producer, from: "producer_id"
click_button "Search"
end
def search_by_category(category)
select category, from: "category_id"
click_button "Search"
end
end