diff --git a/app/models/spree/ability.rb b/app/models/spree/ability.rb index 9ffa81bae9..1da1374b9d 100644 --- a/app/models/spree/ability.rb +++ b/app/models/spree/ability.rb @@ -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| diff --git a/app/models/spree/variant.rb b/app/models/spree/variant.rb index df7a588f14..0c98ba005d 100644 --- a/app/models/spree/variant.rb +++ b/app/models/spree/variant.rb @@ -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, diff --git a/app/reflexes/admin/products_v3_reflex.rb b/app/reflexes/admin/products_v3_reflex.rb deleted file mode 100644 index 0e36ae2762..0000000000 --- a/app/reflexes/admin/products_v3_reflex.rb +++ /dev/null @@ -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 diff --git a/app/reflexes/products_reflex.rb b/app/reflexes/products_reflex.rb new file mode 100644 index 0000000000..803d0e7c54 --- /dev/null +++ b/app/reflexes/products_reflex.rb @@ -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 diff --git a/app/views/admin/products_v3/_content.html.haml b/app/views/admin/products_v3/_content.html.haml index 2c076973c6..b598120c14 100644 --- a/app/views/admin/products_v3/_content.html.haml +++ b/app/views/admin/products_v3/_content.html.haml @@ -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 } diff --git a/app/views/admin/products_v3/_filters.html.haml b/app/views/admin/products_v3/_filters.html.haml new file mode 100644 index 0000000000..7d0c9b3b28 --- /dev/null +++ b/app/views/admin/products_v3/_filters.html.haml @@ -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" diff --git a/app/views/admin/products_v3/_no_products.html.haml b/app/views/admin/products_v3/_no_products.html.haml new file mode 100644 index 0000000000..7b0bbb328c --- /dev/null +++ b/app/views/admin/products_v3/_no_products.html.haml @@ -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") diff --git a/app/views/admin/products_v3/_sort.html.haml b/app/views/admin/products_v3/_sort.html.haml new file mode 100644 index 0000000000..d3daa5eaa3 --- /dev/null +++ b/app/views/admin/products_v3/_sort.html.haml @@ -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" } diff --git a/app/views/admin/products_v3/_table.html.haml b/app/views/admin/products_v3/_table.html.haml index b582da532f..f5f14fad7d 100644 --- a/app/views/admin/products_v3/_table.html.haml +++ b/app/views/admin/products_v3/_table.html.haml @@ -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') diff --git a/app/views/admin/products_v3/index.html.haml b/app/views/admin/products_v3/index.html.haml index 2cb442b141..581dcc2605 100644 --- a/app/views/admin/products_v3/index.html.haml +++ b/app/views/admin/products_v3/index.html.haml @@ -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 diff --git a/app/views/admin/shared/v3/_pagy.html.haml b/app/views/admin/shared/v3/_pagy.html.haml new file mode 100644 index 0000000000..3ba80ea464 --- /dev/null +++ b/app/views/admin/shared/v3/_pagy.html.haml @@ -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 … + - 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 diff --git a/app/webpacker/controllers/loading_controller.js b/app/webpacker/controllers/loading_controller.js new file mode 100644 index 0000000000..4d93c2bd6c --- /dev/null +++ b/app/webpacker/controllers/loading_controller.js @@ -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"); + }; +} diff --git a/app/webpacker/controllers/productsV3_controller.js b/app/webpacker/controllers/productsV3_controller.js deleted file mode 100644 index 9a034b69e0..0000000000 --- a/app/webpacker/controllers/productsV3_controller.js +++ /dev/null @@ -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"); - }; -} diff --git a/app/webpacker/controllers/products_controller.js b/app/webpacker/controllers/products_controller.js new file mode 100644 index 0000000000..ed8d5e02c9 --- /dev/null +++ b/app/webpacker/controllers/products_controller.js @@ -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" + )); + }; +} diff --git a/app/webpacker/css/admin/products_v3.scss b/app/webpacker/css/admin/products_v3.scss index 7050cd9983..1a08f8a966 100644 --- a/app/webpacker/css/admin/products_v3.scss +++ b/app/webpacker/css/admin/products_v3.scss @@ -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; + } + } + } + } } diff --git a/app/webpacker/css/admin/variables.scss b/app/webpacker/css/admin/variables.scss index f93b93a57f..19221dc04e 100644 --- a/app/webpacker/css/admin/variables.scss +++ b/app/webpacker/css/admin/variables.scss @@ -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; diff --git a/app/webpacker/css/admin_v3/all.scss b/app/webpacker/css/admin_v3/all.scss index 2a56c8a6cc..4cbc555727 100644 --- a/app/webpacker/css/admin_v3/all.scss +++ b/app/webpacker/css/admin_v3/all.scss @@ -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"; diff --git a/app/webpacker/css/admin_v3/components/buttons.scss b/app/webpacker/css/admin_v3/components/buttons.scss new file mode 100644 index 0000000000..57683f72a6 --- /dev/null +++ b/app/webpacker/css/admin_v3/components/buttons.scss @@ -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; + } + } +} diff --git a/app/webpacker/css/admin_v3/components/navigation.scss b/app/webpacker/css/admin_v3/components/navigation.scss index ba50629d03..6b5a2518ae 100644 --- a/app/webpacker/css/admin_v3/components/navigation.scss +++ b/app/webpacker/css/admin_v3/components/navigation.scss @@ -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; } diff --git a/app/webpacker/css/admin_v3/components/pagination.scss b/app/webpacker/css/admin_v3/components/pagination.scss index dc269c4227..53167e9996 100644 --- a/app/webpacker/css/admin_v3/components/pagination.scss +++ b/app/webpacker/css/admin_v3/components/pagination.scss @@ -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 { diff --git a/app/webpacker/css/admin_v3/components/spinner.scss b/app/webpacker/css/admin_v3/components/spinner.scss index 1a965e3c22..d6694b3ed2 100644 --- a/app/webpacker/css/admin_v3/components/spinner.scss +++ b/app/webpacker/css/admin_v3/components/spinner.scss @@ -10,6 +10,7 @@ gap: 40px; font-size: 24px; background: rgba(255, 255, 255, 0.8); + z-index: 2; &.hidden { display: none; diff --git a/app/webpacker/css/admin_v3/globals/palette.scss b/app/webpacker/css/admin_v3/globals/palette.scss index a5f0777b9b..560e1074ac 100644 --- a/app/webpacker/css/admin_v3/globals/palette.scss +++ b/app/webpacker/css/admin_v3/globals/palette.scss @@ -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; diff --git a/app/webpacker/css/admin_v3/globals/variables.scss b/app/webpacker/css/admin_v3/globals/variables.scss index 434042f31f..3c9ef5faa7 100644 --- a/app/webpacker/css/admin_v3/globals/variables.scss +++ b/app/webpacker/css/admin_v3/globals/variables.scss @@ -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; diff --git a/app/webpacker/css/admin_v3/mixins.scss b/app/webpacker/css/admin_v3/mixins.scss new file mode 100644 index 0000000000..76f01458be --- /dev/null +++ b/app/webpacker/css/admin_v3/mixins.scss @@ -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); +} diff --git a/app/webpacker/css/admin_v3/shared/icons.scss b/app/webpacker/css/admin_v3/shared/icons.scss new file mode 100644 index 0000000000..0bc8f0341d --- /dev/null +++ b/app/webpacker/css/admin_v3/shared/icons.scss @@ -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; +} diff --git a/app/webpacker/css/admin_v3/shared/tables.scss b/app/webpacker/css/admin_v3/shared/tables.scss index 4b493c8acb..0611bd80da 100644 --- a/app/webpacker/css/admin_v3/shared/tables.scss +++ b/app/webpacker/css/admin_v3/shared/tables.scss @@ -205,3 +205,7 @@ table { } } } + +table + .pagination { + margin-top: -18px; +} diff --git a/app/webpacker/css/admin_v3/shared/typography.scss b/app/webpacker/css/admin_v3/shared/typography.scss new file mode 100644 index 0000000000..467ee248be --- /dev/null +++ b/app/webpacker/css/admin_v3/shared/typography.scss @@ -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; +} diff --git a/config/locales/en.yml b/config/locales/en.yml index 576ee71bd4..21a9671552 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -772,9 +772,27 @@ en: header: title: Bulk Edit Products loading: Loading your products + sort: + pagination: + total_html: "%{total} products 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: diff --git a/spec/system/admin/products_v3/products_spec.rb b/spec/system/admin/products_v3/products_spec.rb new file mode 100644 index 0000000000..c83e073fd9 --- /dev/null +++ b/spec/system/admin/products_v3/products_spec.rb @@ -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