Merge pull request #8996 from jibees/introduce-backoffice-ui-uplift

Introduce backoffice UI uplift
This commit is contained in:
Rachel Arnould
2022-12-14 14:20:43 +01:00
committed by GitHub
44 changed files with 908 additions and 18 deletions

View File

@@ -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'

View File

@@ -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'

View File

@@ -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

View 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

View File

@@ -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"

View File

@@ -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;
}
}
}
}
}

View 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

View File

@@ -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]

View 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

View File

@@ -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)))

View File

@@ -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");
}
}
}

View 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

View File

@@ -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

View File

@@ -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;
}
}

View 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

View File

@@ -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]

View 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;
}
}
}

View 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

View File

@@ -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]

View File

@@ -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%;
}
}
}
}

View 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

View File

@@ -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]

View File

@@ -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: "";
}
}
}
}

View File

@@ -0,0 +1,7 @@
# frozen_string_literal: true
module Admin
class ProductsController < Spree::Admin::BaseController
def index; end
end
end

View 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

View 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))

View File

@@ -3,6 +3,8 @@
= csrf_meta_tags
= action_cable_meta_tag
= action_cable_meta_tag
%title
- if content_for? :html_title
= yield :html_title

View File

@@ -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'

View 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 });

View 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");
}
}

View 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;
}
}

View 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");
};
}

View 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";
});
};
}

View File

@@ -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";

View File

@@ -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 } });

View File

@@ -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";

View File

@@ -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

View File

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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",

View File

@@ -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"