mirror of
https://github.com/openfoodfoundation/openfoodnetwork
synced 2026-02-26 01:33:22 +00:00
Merge pull request #8996 from jibees/introduce-backoffice-ui-uplift
Introduce backoffice UI uplift
This commit is contained in:
@@ -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'
|
||||
|
||||
1
Gemfile
1
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'
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
15
app/components/pagination_component.rb
Normal file
15
app/components/pagination_component.rb
Normal file
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
30
app/components/product_component.rb
Normal file
30
app/components/product_component.rb
Normal file
@@ -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
|
||||
@@ -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]
|
||||
152
app/components/products_table_component.rb
Normal file
152
app/components/products_table_component.rb
Normal file
@@ -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
|
||||
@@ -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)))
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
9
app/components/search_input_component.rb
Normal file
9
app/components/search_input_component.rb
Normal file
@@ -0,0 +1,9 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SearchInputComponent < ViewComponentReflex::Component
|
||||
def initialize(value: nil, data: {})
|
||||
super
|
||||
@value = value
|
||||
@data = data
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
17
app/components/selector_component.rb
Normal file
17
app/components/selector_component.rb
Normal file
@@ -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
|
||||
@@ -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]
|
||||
86
app/components/selector_component/selector_component.scss
Normal file
86
app/components/selector_component/selector_component.scss
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
app/components/selector_with_filter_component.rb
Normal file
11
app/components/selector_with_filter_component.rb
Normal file
@@ -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
|
||||
@@ -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]
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
10
app/components/table_header_component.rb
Normal file
10
app/components/table_header_component.rb
Normal file
@@ -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
|
||||
@@ -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]
|
||||
@@ -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: "⇩";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
7
app/controllers/admin/products_controller.rb
Normal file
7
app/controllers/admin/products_controller.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Admin
|
||||
class ProductsController < Spree::Admin::BaseController
|
||||
def index; end
|
||||
end
|
||||
end
|
||||
38
app/reflexes/example_reflex.rb
Normal file
38
app/reflexes/example_reflex.rb
Normal file
@@ -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
|
||||
8
app/views/admin/products/index.html.haml
Normal file
8
app/views/admin/products/index.html.haml
Normal file
@@ -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))
|
||||
@@ -3,6 +3,8 @@
|
||||
= csrf_meta_tags
|
||||
= action_cable_meta_tag
|
||||
|
||||
= action_cable_meta_tag
|
||||
|
||||
%title
|
||||
- if content_for? :html_title
|
||||
= yield :html_title
|
||||
|
||||
@@ -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'
|
||||
|
||||
16
app/webpacker/controllers/index.js
Normal file
16
app/webpacker/controllers/index.js
Normal file
@@ -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 });
|
||||
46
app/webpacker/controllers/products_table_controller.js
Normal file
46
app/webpacker/controllers/products_table_controller.js
Normal file
@@ -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");
|
||||
}
|
||||
}
|
||||
28
app/webpacker/controllers/search-input_controller.js
Normal file
28
app/webpacker/controllers/search-input_controller.js
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
52
app/webpacker/controllers/selector_controller.js
Normal file
52
app/webpacker/controllers/selector_controller.js
Normal file
@@ -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");
|
||||
};
|
||||
}
|
||||
15
app/webpacker/controllers/selector_with_filter_controller.js
Normal file
15
app/webpacker/controllers/selector_with_filter_controller.js
Normal file
@@ -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";
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
0
config/initializers/action_cable.rb
Normal file
0
config/initializers/action_cable.rb
Normal file
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user