diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 0e57ab39af..79c5727eb1 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -440,6 +440,7 @@ Metrics/BlockNesting: # Configuration parameters: CountComments, Max, CountAsOne. Metrics/ClassLength: Exclude: + - 'app/components/products_table_component.rb' - 'app/controllers/admin/customers_controller.rb' - 'app/controllers/admin/enterprises_controller.rb' - 'app/controllers/admin/order_cycles_controller.rb' diff --git a/Gemfile b/Gemfile index 7295ee9f21..1aca227d53 100644 --- a/Gemfile +++ b/Gemfile @@ -135,6 +135,7 @@ gem 'flipper-active_record' gem 'flipper-ui' gem "view_component" +gem 'view_component_reflex', '3.1.14.pre9' gem 'mini_portile2', '~> 2.8' diff --git a/Gemfile.lock b/Gemfile.lock index 581d76df35..d0ab803126 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -714,6 +714,10 @@ GEM activesupport (>= 5.0.0, < 8.0) concurrent-ruby (~> 1.0) method_source (~> 1.0) + view_component_reflex (3.1.14.pre9) + rails (>= 5.2, < 8.0) + stimulus_reflex (>= 3.5.0.pre2) + view_component (>= 2.28.0) view_component_storybook (0.11.1) view_component (>= 2.36) warden (1.2.9) @@ -879,6 +883,7 @@ DEPENDENCIES valid_email2 vcr view_component + view_component_reflex (= 3.1.14.pre9) view_component_storybook web! web-console diff --git a/app/components/pagination_component.rb b/app/components/pagination_component.rb new file mode 100644 index 0000000000..af3664cbc0 --- /dev/null +++ b/app/components/pagination_component.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class PaginationComponent < ViewComponentReflex::Component + def initialize(pagy:, data:) + super + @count = pagy.count + @page = pagy.page + @per_page = pagy.items + @pages = pagy.pages + @next = pagy.next + @prev = pagy.prev + @data = data + @series = pagy.series + end +end diff --git a/app/components/pagination_component/pagination_component.html.haml b/app/components/pagination_component/pagination_component.html.haml new file mode 100644 index 0000000000..28b2eb8847 --- /dev/null +++ b/app/components/pagination_component/pagination_component.html.haml @@ -0,0 +1,16 @@ += component_controller do + %nav{"aria-label": "pagination"} + .pagination + .pagination-prev{data: @prev.nil? ? nil : @data, "data-page": @prev, class: "#{'inactive' if @prev.nil?}"} + = I18n.t "components.pagination.previous" + .pagination-pages + - @series.each do |page| + - if page == :gap + .pagination-gap + … + - else + .pagination-page{data: @data, "data-page": page, class: "#{'active' if page.to_i == @page}"} + = page + .pagination-next{data: @next.nil? ? nil : @data, "data-page": @next, class: "#{'inactive' if @next.nil?}"} + = I18n.t "components.pagination.next" + diff --git a/app/components/pagination_component/pagination_component.scss b/app/components/pagination_component/pagination_component.scss new file mode 100644 index 0000000000..437e0a55d1 --- /dev/null +++ b/app/components/pagination_component/pagination_component.scss @@ -0,0 +1,69 @@ +nav { + .pagination { + display: flex; + justify-content: space-between; + align-items: flex-start; + font-size: 14px; + + .pagination-prev, .pagination-next { + cursor: pointer; + + &:after, &:before { + font-size: 2em; + position: relative; + top: 3px; + } + + &.inactive { + cursor: default; + color: $disabled-dark; + } + } + + .pagination-prev { + margin-left: 10px; + + &:before { + content: "‹"; + margin-left: 10px; + margin-right: 10px; + } + } + + .pagination-next { + margin-right: 10px; + + &:after { + content: "›"; + margin-left: 10px; + margin-right: 10px; + } + } + + + .pagination-pages { + display: flex; + align-items: flex-end; + + .pagination-gap, .pagination-page { + padding: 0 0.5rem; + margin-left: 10px; + margin-right: 10px; + } + + .pagination-gap { + color: $disabled-dark; + } + + .pagination-page { + color: $color-4; + cursor: pointer; + &.active { + border-top: 3px solid $spree-blue; + color: $spree-blue; + cursor: default; + } + } + } + } +} diff --git a/app/components/product_component.rb b/app/components/product_component.rb new file mode 100644 index 0000000000..e90eaa33be --- /dev/null +++ b/app/components/product_component.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class ProductComponent < ViewComponentReflex::Component + def initialize(product:, columns:) + super + @product = product + @image = @product.images[0] if product.images.any? + @columns = columns.map { |c| + { + id: c[:value], + value: column_value(c[:value]) + } + } + end + + def column_value(column) + case column + when 'name' + @product.name + when 'price' + @product.price + when 'unit' + "#{@product.unit_value} #{@product.variant_unit}" + when 'producer' + @product.supplier.name + when 'category' + @product.taxons.map(&:name).join(', ') + end + end +end diff --git a/app/components/product_component/product_component.html.haml b/app/components/product_component/product_component.html.haml new file mode 100644 index 0000000000..e0d9d760a8 --- /dev/null +++ b/app/components/product_component/product_component.html.haml @@ -0,0 +1,6 @@ +%tr + - @columns.each do |column| + %td.products_column{class: column[:id]} + - if column[:id] == "name" && @image + = image_tag @image.url(:mini) + = column[:value] diff --git a/app/components/product_component/product_component.scss b/app/components/product_component/product_component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app/components/products_table_component.rb b/app/components/products_table_component.rb new file mode 100644 index 0000000000..6d9dc7de65 --- /dev/null +++ b/app/components/products_table_component.rb @@ -0,0 +1,152 @@ +# frozen_string_literal: true + +class ProductsTableComponent < ViewComponentReflex::Component + include Pagy::Backend + + SORTABLE_COLUMNS = ["name"].freeze + SELECTABLE_COMUMNS = [{ label: I18n.t("admin.products_page.columns_selector.price"), + value: "price" }, + { label: I18n.t("admin.products_page.columns_selector.unit"), + value: "unit" }, + { label: I18n.t("admin.products_page.columns_selector.producer"), + value: "producer" }, + { label: I18n.t("admin.products_page.columns_selector.category"), + value: "category" }].sort { |a, b| + a[:label] <=> b[:label] + }.freeze + PER_PAGE_VALUE = [10, 25, 50, 100].freeze + PER_PAGE = PER_PAGE_VALUE.map { |value| { label: value, value: value } } + NAME_COLUMN = { label: I18n.t("admin.products_page.columns.name"), value: "name", + sortable: true }.freeze + + def initialize(user:) + super + @user = user + @selectable_columns = SELECTABLE_COMUMNS + @columns_selected = ["price", "unit"] + @per_page = PER_PAGE + @per_page_selected = [10] + @categories = [{ label: "All", value: "all" }] + + Spree::Taxon.order(:name) + .map { |taxon| { label: taxon.name, value: taxon.id.to_s } } + @categories_selected = ["all"] + @producers = [{ label: "All", value: "all" }] + + OpenFoodNetwork::Permissions.new(@user) + .managed_product_enterprises.is_primary_producer.by_name + .map { |producer| { label: producer.name, value: producer.id.to_s } } + @producers_selected = ["all"] + @page = 1 + @sort = { column: "name", direction: "asc" } + @search_term = "" + end + + def before_render + fetch_products + refresh_columns + end + + def search_term + @search_term = element.dataset['value'] + end + + def toggle_column + column = element.dataset['value'] + @columns_selected = if @columns_selected.include?(column) + @columns_selected - [column] + else + @columns_selected + [column] + end + end + + def click_sort + @sort = { column: element.dataset['sort-value'], + direction: element.dataset['sort-direction'] == "asc" ? "desc" : "asc" } + end + + def toggle_per_page + selected = element.dataset['value'].to_i + @per_page_selected = [selected] if PER_PAGE_VALUE.include?(selected) + end + + def toggle_category + category_clicked = element.dataset['value'] + @categories_selected = toggle_selector_with_filter(category_clicked, @categories_selected) + end + + def toggle_producer + producer_clicked = element.dataset['value'] + @producers_selected = toggle_selector_with_filter(producer_clicked, @producers_selected) + end + + def change_page + page = element.dataset['page'].to_i + @page = page if page > 0 + end + + private + + def refresh_columns + @columns = @columns_selected.map { |column| + { label: I18n.t("admin.products_page.columns.#{column}"), value: column, + sortable: SORTABLE_COLUMNS.include?(column) } + }.sort! { |a, b| a[:label] <=> b[:label] } + @columns.unshift(NAME_COLUMN) + end + + def toggle_selector_with_filter(clicked, selected) + selected = if selected.include?(clicked) + selected - [clicked] + else + selected + [clicked] + end + + if clicked == "all" || selected.empty? + selected = ["all"] + elsif selected.include?("all") && selected.length > 1 + selected -= ["all"] + end + selected + end + + def fetch_products + product_query = OpenFoodNetwork::Permissions.new(@user).editable_products.merge(product_scope) + @products = product_query.ransack(ransack_query).result + @pagy, @products = pagy(@products, items: @per_page_selected.first, page: @page) + end + + def product_scope + scope = if @user.has_spree_role?("admin") || @user.enterprises.present? + Spree::Product + else + Spree::Product.active + end + + scope.includes(product_query_includes) + end + + def ransack_query + query = { s: "#{@sort[:column]} #{@sort[:direction]}" } + + query = if @producers_selected.include?("all") + query.merge({ supplier_id_eq: "" }) + else + query.merge({ supplier_id_in: @producers_selected }) + end + + query = query.merge({ name_cont: @search_term }) if @search_term.present? + + if @categories_selected.include?("all") + query.merge({ primary_taxon_id_eq: "" }) + else + query.merge({ primary_taxon_id_in: @categories_selected }) + end + end + + def product_query_includes + [ + master: [:images], + variants: [:default_price, :stock_locations, :stock_items, :variant_overrides, + { option_values: :option_type }] + ] + end +end diff --git a/app/components/products_table_component/products_table_component.html.haml b/app/components/products_table_component/products_table_component.html.haml new file mode 100644 index 0000000000..b010227636 --- /dev/null +++ b/app/components/products_table_component/products_table_component.html.haml @@ -0,0 +1,21 @@ += component_controller(class: "products-table") do + .products-table-form + .products-table-form_filter_results + = render(SearchInputComponent.new(value: @search_term, data: reflex_data_attributes(:search_term))) + .products-table-form_categories_selector + = render(SelectorWithFilterComponent.new(title: t("admin.products_page.filters.categories.title"), selected: @categories_selected, items: @categories, data: reflex_data_attributes(:toggle_category), selected_items_i18n_key: "admin.products_page.filters.categories.selected_categories")) + .products-table-form_producers_selector + = render(SelectorWithFilterComponent.new(title: t("admin.products_page.filters.producers.title"), selected: @producers_selected, items: @producers, data: reflex_data_attributes(:toggle_producer), selected_items_i18n_key: "admin.products_page.filters.producers.selected_producers")) + .products-table-form_per-page_selector + = render(SelectorComponent.new(title: t('admin.products_page.filters.per_page', count: @per_page_selected[0]), selected: @per_page_selected, items: @per_page, data: reflex_data_attributes(:toggle_per_page))) + .products-table-form_columns_selector + = render(SelectorComponent.new(title: t("admin.products_page.filters.columns"), selected: @columns_selected, items: @selectable_columns, data: reflex_data_attributes(:toggle_column))) + + .products-table_table + %table + = render(TableHeaderComponent.new(columns: @columns, sort: @sort, data: reflex_data_attributes(:click_sort))) + %tbody + = render(ProductComponent.with_collection(@products, columns: @columns)) + + .products-table-form_pagination + = render(PaginationComponent.new(pagy: @pagy, data: reflex_data_attributes(:change_page))) diff --git a/app/components/products_table_component/products_table_component.scss b/app/components/products_table_component/products_table_component.scss new file mode 100644 index 0000000000..f64c589d50 --- /dev/null +++ b/app/components/products_table_component/products_table_component.scss @@ -0,0 +1,47 @@ +.products-table { + .products-table-form { + display: grid; + grid-template-columns: repeat( auto-fit, minmax(250px, 1fr) ); + grid-gap: 10px; + margin-bottom: 10px; + } + + .products-table_table { + box-shadow: 0 10px 10px -1px rgb(0 0 0 / 10%); + } + + .products-table-form_pagination { + position: relative; + top: -15px; + + nav, .pagination { + margin-top: 0; + padding-top: 0; + } + } +} + +.products-table.loading { + .products-table-form_pagination, .products-table_table { + position: relative; + + &:before { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(255, 255, 255, 0.5); + } + } + + .products-table_table { + &:before { + background-position: center; + background-repeat: no-repeat; + background-size: 50px 50px; + background-image: url("../images/spinning-circles.svg"); + } + } +} diff --git a/app/components/search_input_component.rb b/app/components/search_input_component.rb new file mode 100644 index 0000000000..11756d443b --- /dev/null +++ b/app/components/search_input_component.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class SearchInputComponent < ViewComponentReflex::Component + def initialize(value: nil, data: {}) + super + @value = value + @data = data + end +end diff --git a/app/components/search_input_component/search_input_component.html.haml b/app/components/search_input_component/search_input_component.html.haml new file mode 100644 index 0000000000..7cabbe54cc --- /dev/null +++ b/app/components/search_input_component/search_input_component.html.haml @@ -0,0 +1,5 @@ += component_controller do + %div.search-input + %input{type: 'text', placeholder: t("components.search_input.placeholder"), id: 'search_query', data: {action: 'debounced:input->search-input#search'}, value: @value} + .search-button{data: @data} + %i.fa.fa-search diff --git a/app/components/search_input_component/search_input_component.scss b/app/components/search_input_component/search_input_component.scss new file mode 100644 index 0000000000..add821dd34 --- /dev/null +++ b/app/components/search_input_component/search_input_component.scss @@ -0,0 +1,23 @@ +.search-input { + border: 1px solid $disabled-light; + height: 3em; + display: flex; + line-height: 3em; + align-items: center; + + input { + border: none; + height: 3em; + width: 100%; + box-sizing: border-box; + padding-right: 5px; + } + + .search-button { + padding-right: 10px; + padding-left: 5px; + cursor: pointer; + color: $color-4; + } + +} diff --git a/app/components/selector_component.rb b/app/components/selector_component.rb new file mode 100644 index 0000000000..daa81d7663 --- /dev/null +++ b/app/components/selector_component.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class SelectorComponent < ViewComponentReflex::Component + def initialize(title:, selected:, items:, data: {}) + super + @title = title + @items = items.map do |item| + { + label: item[:label], + value: item[:value], + selected: selected.include?(item[:value]) + } + end + @selected = selected + @data = data + end +end diff --git a/app/components/selector_component/selector_component.html.haml b/app/components/selector_component/selector_component.html.haml new file mode 100644 index 0000000000..b6b1893ee6 --- /dev/null +++ b/app/components/selector_component/selector_component.html.haml @@ -0,0 +1,11 @@ += component_controller do + .selector.selector-close + .selector-main{ data: { action: "click->selector#toggle" } } + .selector-main-title + = @title + .selector-arrow + .selector-wrapper + .selector-items + - @items.each do |item| + .selector-item{ class: ("selected" if item[:selected]), data: @data, "data-value": item[:value] } + = item[:label] diff --git a/app/components/selector_component/selector_component.scss b/app/components/selector_component/selector_component.scss new file mode 100644 index 0000000000..e5ea18583e --- /dev/null +++ b/app/components/selector_component/selector_component.scss @@ -0,0 +1,86 @@ +.selector { + position: relative; + + .selector-main { + border: 1px solid $disabled-light; + height: 3em; + position: relative; + cursor: pointer; + + .selector-main-title { + line-height: 3em; + padding-left: 10px; + padding-right: 10px; + } + + .selector-arrow { + position: absolute; + right: 0px; + height: 3em; + width: 1.5em; + top: -1px; + + &:after { + content: ""; + position: absolute; + top: 50%; + right: 5px; + margin-top: -5px; + width: 0; + height: 0; + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-top: 5px solid $disabled-light; + } + } + } + + .selector-wrapper { + position: absolute; + left: 0px; + right: 0px; + z-index: 1; + background-color: white; + margin-top: -1px; + border: 1px solid $disabled-light; + + .selector-items { + overflow-y: auto; + min-height: 6em; + + .selector-item { + padding-left: 10px; + padding-right: 10px; + border-bottom: 1px solid $disabled-light; + position: relative; + height: 3em; + line-height: 3em; + + &:hover { + background-color: #eee; + cursor: pointer; + } + + &:last-child { + border-bottom: none; + } + + &.selected { + &:after { + content: "✓"; + display: inline-block; + position: absolute; + right: 10px; + } + } + } + } + } + + + &.selector-close { + .selector-wrapper { + display: none; + } + } +} diff --git a/app/components/selector_with_filter_component.rb b/app/components/selector_with_filter_component.rb new file mode 100644 index 0000000000..cae5182ce3 --- /dev/null +++ b/app/components/selector_with_filter_component.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class SelectorWithFilterComponent < SelectorComponent + def initialize(title:, selected:, items:, data: {}, + selected_items_i18n_key: 'components.selector_with_filter.selected_items') + super(title: title, selected: selected, items: items, data: data) + @selected_items = items.select { |item| @selected.include?(item[:value]) } + @selected_items_i18n_key = selected_items_i18n_key + @items = items + end +end diff --git a/app/components/selector_with_filter_component/selector_with_filter_component.html.haml b/app/components/selector_with_filter_component/selector_with_filter_component.html.haml new file mode 100644 index 0000000000..0a75bdd182 --- /dev/null +++ b/app/components/selector_with_filter_component/selector_with_filter_component.html.haml @@ -0,0 +1,22 @@ += component_controller do + .super-selector.selector.selector-close + .selector-main{ data: { action: "click->selector-with-filter#toggle" } } + .super-selector-label + = @title + .super-selector-selected-items + - case @selected_items.length + - when 1, 2 + - @selected_items.each do |item| + .super-selector-selected-item + = item[:label] + - else + .super-selector-selected-item + = t(@selected_items_i18n_key, count: @selected_items.length) + .selector-arrow + .selector-wrapper + .super-selector-search + %input{type: "text", placeholder: t("components.selector_with_filter.search_placeholder"), data: { action: "debounced:input->selector-with-filter#filter" } } + .selector-items + - @items.each do |item| + .selector-item{ class: ("selected" if item[:selected]), data: @data.merge({ "selector-with-filter-target": "items" }), "data-value": item[:value] } + = item[:label] diff --git a/app/components/selector_with_filter_component/selector_with_filter_component.scss b/app/components/selector_with_filter_component/selector_with_filter_component.scss new file mode 100644 index 0000000000..88f9e6c5f2 --- /dev/null +++ b/app/components/selector_with_filter_component/selector_with_filter_component.scss @@ -0,0 +1,51 @@ + +.super-selector { + position: relative; + + .selector-main { + .super-selector-label { + padding-left: 5px; + padding-right: 5px; + margin-left: 10px; + position: absolute; + top: -1em; + background-color: white; + } + } + + .super-selector-selected-items { + margin-left: 5px; + margin-right: 2em; + margin-top: 7px; + display: flex; + + .super-selector-selected-item { + border: 1px solid $pale-blue; + background-color: $spree-light-blue; + border-radius: 20px; + height: 2em; + padding-left: 10px; + padding-right: 10px; + display: inline-block; + margin-right: 5px; + padding-top: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + + .selector-wrapper { + .super-selector-search { + border-bottom: 1px solid $disabled-light; + padding: 10px 5px; + + input { + border: 1px solid $disabled-light; + box-sizing: border-box; + border-radius: 4px; + width: 100%; + } + } + } +} diff --git a/app/components/table_header_component.rb b/app/components/table_header_component.rb new file mode 100644 index 0000000000..d369e8e0dc --- /dev/null +++ b/app/components/table_header_component.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class TableHeaderComponent < ViewComponentReflex::Component + def initialize(columns:, sort:, data: {}) + super + @columns = columns + @sort = sort + @data = data + end +end diff --git a/app/components/table_header_component/table_header_component.html.haml b/app/components/table_header_component/table_header_component.html.haml new file mode 100644 index 0000000000..bcc2fd3c4d --- /dev/null +++ b/app/components/table_header_component/table_header_component.html.haml @@ -0,0 +1,7 @@ + += component_controller do + %thead.table-header + %tr + - @columns.each do |column| + %th{class: (column[:sortable] ? "th-sortable " : "" ) + (@sort[:column] == column[:value] ? " th-sorted-#{@sort[:direction]}" : ""), data: (@data if column[:sortable] == true), "data-sort-value": column[:value], "data-sort-direction": @sort[:direction]} + = column[:label] diff --git a/app/components/table_header_component/table_header_component.scss b/app/components/table_header_component/table_header_component.scss new file mode 100644 index 0000000000..b138d56668 --- /dev/null +++ b/app/components/table_header_component/table_header_component.scss @@ -0,0 +1,23 @@ +thead.table-header { + th { + &.th-sortable { + cursor: pointer; + } + &.th-sorted-asc, &.th-sorted-desc { + &:after { + display: inline-block; + padding-left: 10px; + } + } + &.th-sorted-asc { + &:after { + content: "⇧"; + } + } + &.th-sorted-desc { + &:after { + content: "⇩"; + } + } + } +} diff --git a/app/controllers/admin/products_controller.rb b/app/controllers/admin/products_controller.rb new file mode 100644 index 0000000000..123aea7531 --- /dev/null +++ b/app/controllers/admin/products_controller.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Admin + class ProductsController < Spree::Admin::BaseController + def index; end + end +end diff --git a/app/reflexes/example_reflex.rb b/app/reflexes/example_reflex.rb new file mode 100644 index 0000000000..85ba85fd80 --- /dev/null +++ b/app/reflexes/example_reflex.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class ExampleReflex < ApplicationReflex + # Add Reflex methods in this file. + # + # All Reflex instances include CableReady::Broadcaster and expose the following properties: + # + # - connection - the ActionCable connection + # - channel - the ActionCable channel + # - request - an ActionDispatch::Request proxy for the socket connection + # - session - the ActionDispatch::Session store for the current visitor + # - flash - the ActionDispatch::Flash::FlashHash for the current request + # - url - the URL of the page that triggered the reflex + # - params - parameters from the element's closest form (if any) + # - element - a Hash like object that represents the HTML element that triggered the reflex + # - signed - use a signed Global ID to map dataset attribute to a model + # eg. element.signed[:foo] + # - unsigned - use an unsigned Global ID to map dataset attribute to a model + # eg. element.unsigned[:foo] + # - cable_ready - a special cable_ready that can broadcast to the current visitor + # (no brackets needed) + # - reflex_id - a UUIDv4 that uniquely identies each Reflex + # - tab_id - a UUIDv4 that uniquely identifies the browser tab + # + # Example: + # + # before_reflex do + # # throw :abort # this will prevent the Reflex from continuing + # # learn more about callbacks at https://docs.stimulusreflex.com/rtfm/lifecycle + # end + # + # def example(argument=true) + # # Your logic here... + # # Any declared instance variables will be made available to the Rails controller and view. + # end + # + # Learn more at: https://docs.stimulusreflex.com/rtfm/reflex-classes +end diff --git a/app/views/admin/products/index.html.haml b/app/views/admin/products/index.html.haml new file mode 100644 index 0000000000..31db370aad --- /dev/null +++ b/app/views/admin/products/index.html.haml @@ -0,0 +1,8 @@ + +- content_for :page_title do + = t('admin.products_page.title') + += render partial: 'spree/admin/shared/product_sub_menu' + +#products_page + = render(ProductsTableComponent.new(user: spree_current_user)) diff --git a/app/views/spree/admin/shared/_head.html.haml b/app/views/spree/admin/shared/_head.html.haml index bad186fb78..254e893bfc 100644 --- a/app/views/spree/admin/shared/_head.html.haml +++ b/app/views/spree/admin/shared/_head.html.haml @@ -3,6 +3,8 @@ = csrf_meta_tags = action_cable_meta_tag += action_cable_meta_tag + %title - if content_for? :html_title = yield :html_title diff --git a/app/views/spree/admin/shared/_product_sub_menu.html.haml b/app/views/spree/admin/shared/_product_sub_menu.html.haml index 66859dec69..41a3f5b3b7 100644 --- a/app/views/spree/admin/shared/_product_sub_menu.html.haml +++ b/app/views/spree/admin/shared/_product_sub_menu.html.haml @@ -4,3 +4,5 @@ = tab :properties = tab :variant_overrides, url: main_app.admin_inventory_path, match_path: '/inventory' = tab :import, url: main_app.admin_product_import_path, match_path: '/product_import' + - if feature?(:new_products_page, spree_current_user) + = tab :new_products, url: main_app.admin_new_products_path, match_path: '/new_products' diff --git a/app/webpacker/controllers/index.js b/app/webpacker/controllers/index.js new file mode 100644 index 0000000000..64b87d935e --- /dev/null +++ b/app/webpacker/controllers/index.js @@ -0,0 +1,16 @@ +// Load all the controllers within this directory and all subdirectories. +// Controller files must be named *_controller.js. +import { Application } from "stimulus"; +import { definitionsFromContext } from "stimulus/webpack-helpers"; +import StimulusReflex from "stimulus_reflex"; +import consumer from "../channels/consumer"; +import controller from "../controllers/application_controller"; +import CableReady from "cable_ready"; + +const application = Application.start(); +const context = require.context("controllers", true, /_controller\.js$/); +application.load(definitionsFromContext(context)); +application.consumer = consumer; +StimulusReflex.initialize(application, { controller, isolate: true }); +StimulusReflex.debug = process.env.RAILS_ENV === "development"; +CableReady.initialize({ consumer }); diff --git a/app/webpacker/controllers/products_table_controller.js b/app/webpacker/controllers/products_table_controller.js new file mode 100644 index 0000000000..0823d1b617 --- /dev/null +++ b/app/webpacker/controllers/products_table_controller.js @@ -0,0 +1,46 @@ +import ApplicationController from "./application_controller"; + +export default class extends ApplicationController { + connect() { + super.connect(); + document.addEventListener( + "stimulus-reflex:before", + this.handleBeforeReflex.bind(this) + ); + document.addEventListener( + "stimulus-reflex:after", + this.handleAfterReflex.bind(this) + ); + } + + disconnect() { + super.disconnect(); + document.removeEventListener( + "stimulus-reflex:before", + this.handleBeforeReflex.bind(this) + ); + document.removeEventListener( + "stimulus-reflex:after", + this.handleAfterReflex.bind(this) + ); + } + + handleBeforeReflex(event) { + if (event.detail.reflex.indexOf("ProductsTableComponent#") !== -1) { + this.showLoading(); + } + } + + handleAfterReflex(event) { + if (event.detail.reflex.indexOf("ProductsTableComponent#") !== -1) { + this.hideLoading(); + } + } + + showLoading() { + this.element.classList.add("loading"); + } + hideLoading() { + this.element.classList.remove("loading"); + } +} diff --git a/app/webpacker/controllers/search-input_controller.js b/app/webpacker/controllers/search-input_controller.js new file mode 100644 index 0000000000..47aee17134 --- /dev/null +++ b/app/webpacker/controllers/search-input_controller.js @@ -0,0 +1,28 @@ +import ApplicationController from "./application_controller"; + +export default class extends ApplicationController { + connect() { + super.connect(); + this.element + .querySelector("input") + .addEventListener("keydown", this.searchOnEnter); + } + + disconnect() { + super.disconnect(); + this.element + .querySelector("input") + .removeEventListener("keydown", this.searchOnEnter); + } + + searchOnEnter = (e) => { + if (e.key === "Enter") { + this.element.querySelector(".search-button").click(); + } + }; + + search(e) { + this.element.querySelector(".search-button").dataset["value"] = + e.target.value; + } +} diff --git a/app/webpacker/controllers/selector_controller.js b/app/webpacker/controllers/selector_controller.js new file mode 100644 index 0000000000..cce4241643 --- /dev/null +++ b/app/webpacker/controllers/selector_controller.js @@ -0,0 +1,52 @@ +import ApplicationController from "./application_controller"; + +export default class extends ApplicationController { + connect() { + super.connect(); + window.addEventListener("click", this.closeOnClickOutside); + this.computeItemsHeight(); + } + + disconnect() { + super.disconnect(); + window.removeEventListener("click", this.closeOnClickOutside); + } + + initialize() { + this.close(); + } + + afterReflex() { + this.computeItemsHeight(); + } + + toggle = (event) => { + event.preventDefault(); + this.element.querySelector(".selector").classList.toggle("selector-close"); + }; + + // Private + closeOnClickOutside = (event) => { + if ( + !this.element.contains(event.target) && + this.isVisible(this.element.querySelector(".selector-wrapper")) + ) { + this.close(); + } + }; + + computeItemsHeight = () => { + const items = this.element.querySelector(".selector-items"); + const rect = items.getBoundingClientRect(); + items.style.maxHeight = `calc(100vh - ${rect.height}px)`; + }; + + isVisible = (element) => { + const style = window.getComputedStyle(element); + return style.display !== "none" && style.visibility !== "hidden"; + }; + + close = () => { + this.element.querySelector(".selector").classList.add("selector-close"); + }; +} diff --git a/app/webpacker/controllers/selector_with_filter_controller.js b/app/webpacker/controllers/selector_with_filter_controller.js new file mode 100644 index 0000000000..dc049305b6 --- /dev/null +++ b/app/webpacker/controllers/selector_with_filter_controller.js @@ -0,0 +1,15 @@ +import SelectorController from "./selector_controller"; + +export default class extends SelectorController { + static targets = ["items"]; + + filter = (event) => { + const query = event.target.value; + + this.itemsTargets.forEach((el, i) => { + el.style.display = el.textContent.toLowerCase().includes(query) + ? "" + : "none"; + }); + }; +} diff --git a/app/webpacker/css/admin/all.scss b/app/webpacker/css/admin/all.scss index 673d934971..35e7b70bc2 100644 --- a/app/webpacker/css/admin/all.scss +++ b/app/webpacker/css/admin/all.scss @@ -120,5 +120,12 @@ @import "components/tom_select"; @import 'app/components/help_modal_component/help_modal_component'; +@import "app/components/product_component/product_component"; +@import "app/components/selector_component/selector_component"; +@import "app/components/products_table_component/products_table_component"; +@import "app/components/selector_with_filter_component/selector_with_filter_component"; +@import "app/components/pagination_component/pagination_component"; +@import "app/components/table_header_component/table_header_component"; +@import "app/components/search_input_component/search_input_component"; @import "v2/main.scss"; diff --git a/app/webpacker/packs/admin.js b/app/webpacker/packs/admin.js index 02d879c4f1..b0a44a4fe9 100644 --- a/app/webpacker/packs/admin.js +++ b/app/webpacker/packs/admin.js @@ -13,3 +13,6 @@ application.consumer = consumer; StimulusReflex.initialize(application, { controller, isolate: true }); StimulusReflex.debug = process.env.RAILS_ENV === "development"; CableReady.initialize({ consumer }); + +import debounced from "debounced"; +debounced.initialize({ input: { wait: 300 } }); diff --git a/app/webpacker/packs/application.js b/app/webpacker/packs/application.js index ff8a4a56ae..21b1ffce26 100644 --- a/app/webpacker/packs/application.js +++ b/app/webpacker/packs/application.js @@ -1,13 +1,4 @@ /* eslint no-console:0 */ - -// StimulusJS -import { Application } from "stimulus"; -import { definitionsFromContext } from "stimulus/webpack-helpers"; - -const application = Application.start(); -const context = require.context("controllers", true, /.js$/); -application.load(definitionsFromContext(context)); - import CableReady from "cable_ready"; import mrujs from "mrujs"; import { CableCar } from "mrujs/plugins"; @@ -23,11 +14,4 @@ require.context("../fonts", true); const images = require.context("../images", true); const imagePath = (name) => images(name, true); -import StimulusReflex from "stimulus_reflex"; -import consumer from "../channels/consumer"; -import controller from "../controllers/application_controller"; - -application.consumer = consumer; -StimulusReflex.initialize(application, { controller, isolate: true }); -StimulusReflex.debug = process.env.RAILS_ENV === "development"; -CableReady.initialize({ consumer }); +import "controllers"; diff --git a/config/environments/development.rb b/config/environments/development.rb index 3d75500f51..8ead22c6a3 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -1,4 +1,5 @@ Openfoodnetwork::Application.configure do + config.action_controller.default_url_options = {host: "localhost", port: 3000} # Settings specified here will take precedence over those in config/application.rb # # PROFILE switches several settings to a more "production-like" value @@ -23,6 +24,8 @@ Openfoodnetwork::Application.configure do } end + config.session_store :cache_store, key: "_sessions_development", compress: true, pool_size: 5, expire_after: 1.year + config.eager_load = false # Show full error reports and disable caching diff --git a/config/initializers/action_cable.rb b/config/initializers/action_cable.rb new file mode 100644 index 0000000000..e69de29bb2 diff --git a/config/locales/en.yml b/config/locales/en.yml index 9c38d6b867..f433a4d425 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -458,6 +458,28 @@ en: # Admin # admin: + products_page: + title: Products + filters: + categories: + title: Categories + selected_categories: "%{count} categories selected" + producers: + title: Producers + selected_producers: "%{count} producers selected" + per_page: "%{count} items per page" + colums: Columns + columns: + name: Name + unit: Unit + price: Price + producer: Producer + category: Category + columns_selector: + unit: Unit + price: Price + producer: Producer + category: Category adjustments: skipped_changing_canceled_order: "You can't change a cancelled order." # Common properties / models @@ -4439,3 +4461,11 @@ See the %{link} to find out more about %{sitename}'s features and to start using components: multiple_checked_select: filter_placeholder: "Filter options" + search_input: + placeholder: Search + selector_with_filter: + selected_items: "%{count} selected" + search_placeholder: Search + pagination: + next: Next + previous: Previous diff --git a/config/routes/admin.rb b/config/routes/admin.rb index a7e900dce7..8bd8cf7620 100644 --- a/config/routes/admin.rb +++ b/config/routes/admin.rb @@ -67,6 +67,10 @@ Openfoodnetwork::Application.routes.draw do post '/product_import/save_data', to: 'product_import#save_data', as: 'product_import_save_async' post '/product_import/reset_absent', to: 'product_import#reset_absent_products', as: 'product_import_reset_async' + constraints FeatureToggleConstraint.new(:new_products_page) do + get '/new_products', to: 'products#index' + end + resources :variant_overrides do post :bulk_update, on: :collection post :bulk_reset, on: :collection diff --git a/config/webpacker.yml b/config/webpacker.yml index aaf278905b..55250fae6c 100644 --- a/config/webpacker.yml +++ b/config/webpacker.yml @@ -15,7 +15,8 @@ default: &default 'app/webpacker/css', 'app/webpacker/fonts', 'app/webpacker/images', - 'engines/web/app/assets/stylesheets' + 'engines/web/app/assets/stylesheets', + 'app/components' ] # Reload manifest.json on all requests so we reload latest compiled packs diff --git a/package.json b/package.json index b9ace90630..86970387fc 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@rails/webpacker": "5.4.3", "babel-loader": "^8.2.3", "cable_ready": "5.0.0-pre9", + "debounced": "^0.0.5", "flatpickr": "^4.6.9", "foundation-sites": "^5.5.2", "jquery-ui": "1.13.0", diff --git a/yarn.lock b/yarn.lock index 2bf4d756bd..1a58aa4fae 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5054,6 +5054,11 @@ date-format@^4.0.3: resolved "https://registry.yarnpkg.com/date-format/-/date-format-4.0.3.tgz#f63de5dc08dc02efd8ef32bf2a6918e486f35873" integrity sha512-7P3FyqDcfeznLZp2b+OMitV9Sz2lUnsT87WaTat9nVwqsBkTzPG3lPLNwW3en6F4pHUiWzr6vb8CLhjdK9bcxQ== +debounced@^0.0.5: + version "0.0.5" + resolved "https://registry.yarnpkg.com/debounced/-/debounced-0.0.5.tgz#e540b6eebfe703d93462711b4f3562ffd101b87f" + integrity sha512-8Bgheu1YxQB7ocqYmK2enbLGVoo4nCtu/V6UD/SMDOeV3g2LocG2CrA5oxudlyl79Ja07UiqGdp9pWZoJn52EQ== + debug@2.6.9, debug@^2.2.0, debug@^2.3.3, debug@^2.6.0: version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"