From 5d2bd250a8bdae22e5d4897c684f5dc903bb4c8b Mon Sep 17 00:00:00 2001 From: Sylvain Date: Wed, 14 Sep 2022 17:32:54 +0200 Subject: [PATCH 01/10] (quality) improved comments --- .../javascript/components/store/products.tsx | 33 +++++++++++-------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/app/frontend/src/javascript/components/store/products.tsx b/app/frontend/src/javascript/components/store/products.tsx index 2938c15ec..c8e4232ac 100644 --- a/app/frontend/src/javascript/components/store/products.tsx +++ b/app/frontend/src/javascript/components/store/products.tsx @@ -113,36 +113,41 @@ const Products: React.FC = ({ onSuccess, onError }) => { }; /** - * Filter: by categories + * Callback triggered when a category filter is selected or unselected. + * This may cause other filters to be selected or unselected accordingly. */ - const handleSelectCategory = (c: ProductCategory, checked: boolean, instantUpdate?: boolean) => { + const handleSelectCategory = (currentCategory: ProductCategory, checked: boolean, instantUpdate?: boolean) => { let list = [...filters.categories]; const children = productCategories - .filter(el => el.parent_id === c.id); + .filter(el => el.parent_id === currentCategory.id); const siblings = productCategories - .filter(el => el.parent_id === c.parent_id && el.parent_id !== null); + .filter(el => el.parent_id === currentCategory.parent_id && el.parent_id !== null); if (checked) { - list.push(c); + list.push(currentCategory); if (children.length) { - const unique = Array.from(new Set([...list, ...children])); - list = [...unique]; + // if a parent category is selected, we automatically select all its children + list = [...Array.from(new Set([...list, ...children]))]; } if (siblings.length && siblings.every(el => list.includes(el))) { + // if a child category is selected, with every sibling of it, we automatically select its parent list.push(productCategories.find(p => p.id === siblings[0].parent_id)); } } else { - list.splice(list.indexOf(c), 1); - const parent = productCategories.find(p => p.id === c.parent_id); - if (c.parent_id && list.includes(parent)) { + list.splice(list.indexOf(currentCategory), 1); + const parent = productCategories.find(p => p.id === currentCategory.parent_id); + if (currentCategory.parent_id && list.includes(parent)) { + // if a child category is unselected, we unselect its parent list.splice(list.indexOf(parent), 1); } if (children.length) { + // if a parent category is unselected, we unselect all its children children.forEach(child => { list.splice(list.indexOf(child), 1); }); } } + setFilters(draft => { return { ...draft, categories: list }; }); @@ -152,13 +157,13 @@ const Products: React.FC = ({ onSuccess, onError }) => { }; /** - * Filter: by machines + * Callback triggered when a machine filter is seleced or unselected. */ - const handleSelectMachine = (m: checklistOption, checked, instantUpdate?) => { + const handleSelectMachine = (currentMachine: checklistOption, checked: boolean, instantUpdate?: boolean) => { const list = [...filters.machines]; checked - ? list.push(m) - : list.splice(list.indexOf(m), 1); + ? list.push(currentMachine) + : list.splice(list.indexOf(currentMachine), 1); setFilters(draft => { return { ...draft, machines: list }; }); From b7e128a63c9a6c5617f9d8fec7378b23ee3c959b Mon Sep 17 00:00:00 2001 From: vincent Date: Fri, 16 Sep 2022 11:54:58 +0200 Subject: [PATCH 02/10] (feat) Add filters --- .../javascript/components/store/orders.tsx | 6 +- .../javascript/components/store/products.tsx | 175 +++++++++++------- .../modules/store/store-filters.scss | 27 ++- config/locales/app.admin.en.yml | 5 + 4 files changed, 136 insertions(+), 77 deletions(-) diff --git a/app/frontend/src/javascript/components/store/orders.tsx b/app/frontend/src/javascript/components/store/orders.tsx index 96370d303..2506b9a4d 100644 --- a/app/frontend/src/javascript/components/store/orders.tsx +++ b/app/frontend/src/javascript/components/store/orders.tsx @@ -314,15 +314,15 @@ const Orders: React.FC = ({ currentUser, onError }) => { >
-
- from +
- to = ({ onSuccess, onError }) => { const { t } = useTranslation('admin'); + const { register, control, getValues } = useForm(); + const [filteredProductsList, setFilteredProductList] = useImmer>([]); const [features, setFeatures] = useImmer(initFilters); const [filterVisible, setFilterVisible] = useState(false); @@ -48,7 +51,7 @@ const Products: React.FC = ({ onSuccess, onError }) => { const [currentPage, setCurrentPage] = useState(1); useEffect(() => { - ProductAPI.index({ page: 1 }).then(data => { + ProductAPI.index({ page: 1, is_active: filterVisible }).then(data => { setPageCount(data.total_pages); setFilteredProductList(data.products); }); @@ -62,30 +65,32 @@ const Products: React.FC = ({ onSuccess, onError }) => { }).catch(onError); }, []); - useEffect(() => { - ProductAPI.index({ page: currentPage }).then(data => { - setFilteredProductList(data.products); - setPageCount(data.total_pages); - window.document.getElementById('content-main').scrollTo({ top: 100, behavior: 'smooth' }); - }); - }, [currentPage]); - useEffect(() => { applyFilters(); setClearFilters(false); setUpdate(false); }, [filterVisible, clearFilters, update === true]); - /** - * Goto edit product page - */ + /** Handle products pagination */ + const handlePagination = (page: number) => { + if (page !== currentPage) { + ProductAPI.index({ page, is_active: filterVisible }).then(data => { + setCurrentPage(page); + setFilteredProductList(data.products); + setPageCount(data.total_pages); + window.document.getElementById('content-main').scrollTo({ top: 100, behavior: 'smooth' }); + }).catch(() => { + onError(t('app.admin.store.products.unexpected_error_occurred')); + }); + } + }; + + /** Goto edit product page */ const editProduct = (product: Product) => { window.location.href = `/#!/admin/store/products/${product.id}/edit`; }; - /** - * Delete a product - */ + /** Delete a product */ const deleteProduct = async (productId: number): Promise => { try { await ProductAPI.destroy(productId); @@ -97,16 +102,12 @@ const Products: React.FC = ({ onSuccess, onError }) => { } }; - /** - * Goto new product page - */ + /** Goto new product page */ const newProduct = (): void => { window.location.href = '/#!/admin/store/products/new'; }; - /** - * Filter: toggle non-available products visibility - */ + /** Filter: toggle non-available products visibility */ const toggleVisible = (checked: boolean) => { setFilterVisible(!filterVisible); console.log('Display on the shelf product only:', checked); @@ -172,16 +173,39 @@ const Products: React.FC = ({ onSuccess, onError }) => { } }; - /** - * Display option: sorting - */ + /** Filter: by keyword or ref */ + const handleKeyword = (evt: React.ChangeEvent) => { + setFilters(draft => { + return { ...draft, keywords: evt.target.value }; + }); + }; + + /** Filter: by stock range */ + const handleStockRange = () => { + setFilters(draft => { + return { + ...draft, + stock_type: buildStockOptions()[getValues('stock_type')].label, + stock_from: getValues('stock_from'), + stock_to: getValues('stock_to') + }; + }); + }; + + /** Creates sorting options to the react-select format */ + const buildStockOptions = (): Array => { + return [ + { value: 0, label: t('app.admin.store.products.stock_internal') }, + { value: 1, label: t('app.admin.store.products.stock_external') } + ]; + }; + + /** Display option: sorting */ const handleSorting = (option: selectOption) => { console.log('Sort option:', option); }; - /** - * Apply filters - */ + /** Apply filters */ const applyFilters = () => { let tags = initFilters; @@ -197,19 +221,15 @@ const Products: React.FC = ({ onSuccess, onError }) => { console.log('Apply filters:', filters); }; - /** - * Clear filters - */ + /** Clear filters */ const clearAllFilters = () => { setFilters(initFilters); setClearFilters(true); console.log('Clear all filters'); }; - /** - * Creates sorting options to the react-select format - */ - const buildOptions = (): Array => { + /** Creates sorting options to the react-select format */ + const buildSortOptions = (): Array => { return [ { value: 0, label: t('app.admin.store.products.sort.name_az') }, { value: 1, label: t('app.admin.store.products.sort.name_za') }, @@ -218,9 +238,7 @@ const Products: React.FC = ({ onSuccess, onError }) => { ]; }; - /** - * Open/close accordion items - */ + /** Open/close accordion items */ const handleAccordion = (id, state) => { setAccordion({ ...accordion, [id]: state }); }; @@ -276,12 +294,54 @@ const Products: React.FC = ({ onSuccess, onError }) => { setUpdate(true)} className="is-info">{t('app.admin.store.products.filter_apply')}
+ + +
+
+ handleKeyword(event)} /> + setUpdate(true)} className="is-info">{t('app.admin.store.products.filter_apply')} +
+
+
+ + +
+
+ +
+ + +
+ {t('app.admin.store.products.filter_apply')} +
+
+
= ({ onSuccess, onError }) => { ))}
{pageCount > 1 && - + }
@@ -329,45 +389,30 @@ const ProductsWrapper: React.FC = ({ onSuccess, onError }) => { Application.Components.component('products', react2angular(ProductsWrapper, ['onSuccess', 'onError'])); -/** - * Option format, expected by checklist - */ +/** Option format, expected by checklist */ type checklistOption = { value: number, label: string }; -/** - * Convert the provided array of items to the checklist format - */ +/** Convert the provided array of items to the checklist format */ const buildChecklistOptions = (items: Array<{ id?: number, name: string }>): Array => { return items.map(t => { return { value: t.id, label: t.name }; }); }; -interface Stock { - from: number, - to: number -} - interface Filters { - instant: boolean, categories: ProductCategory[], machines: checklistOption[], keywords: string[], - internalStock: Stock, - externalStock: Stock + stock_type: 'internal' | 'external', + stock_from: number, + stock_to: number } const initFilters: Filters = { - instant: false, categories: [], machines: [], keywords: [], - internalStock: { - from: 0, - to: null - }, - externalStock: { - from: 0, - to: null - } + stock_type: 'internal', + stock_from: 0, + stock_to: 0 }; diff --git a/app/frontend/src/stylesheets/modules/store/store-filters.scss b/app/frontend/src/stylesheets/modules/store/store-filters.scss index b8009054d..64135358d 100644 --- a/app/frontend/src/stylesheets/modules/store/store-filters.scss +++ b/app/frontend/src/stylesheets/modules/store/store-filters.scss @@ -103,17 +103,19 @@ display: flex; flex-direction: column; opacity: 1; - &.u-scrollbar { overflow: hidden auto; } + &.u-scrollbar { + overflow: hidden auto; + label { + margin: 0 0.8rem 0 0; + padding: 0.6rem; + &:hover { background-color: var(--gray-soft-light); } + } + } - label { - margin: 0 0.8rem 0 0; - padding: 0.6rem; + & > label { display: flex; align-items: center; - &:hover { - background-color: var(--gray-soft-light); - cursor: pointer; - } + cursor: pointer; input[type=checkbox] { margin: 0 0.8rem 0 0; } p { margin: 0; @@ -121,9 +123,11 @@ } &.offset { margin-left: 1.6rem; } } + .form-item-field { width: 100%; } } input[type="text"] { + width: 100%; width: 100%; min-height: 4rem; padding: 0 0.8rem; @@ -144,9 +148,14 @@ } } - .period { + .range { display: flex; justify-content: center; align-items: center; + gap: 1.6rem; + label { + margin: 0; + flex-direction: column; + } } } \ No newline at end of file diff --git a/config/locales/app.admin.en.yml b/config/locales/app.admin.en.yml index d153b3918..191a8fc87 100644 --- a/config/locales/app.admin.en.yml +++ b/config/locales/app.admin.en.yml @@ -1934,6 +1934,7 @@ en: required: "This field is required" slug_pattern: "Only lowercase alphanumeric groups of characters separated by an hyphen" products: + unexpected_error_occurred: "An unexpected error occurred. Please try again later." all_products: "All products" create_a_product: "Create a product" successfully_deleted: "The product has been successfully deleted" @@ -1945,6 +1946,8 @@ en: filter_machines: "By machines" filter_keywords_reference: "By keywords or reference" filter_stock: "By stock status" + stock_internal: "Private stock" + stock_external: "Public stock" filter_stock_from: "From" filter_stock_to: "to" sort: @@ -2047,6 +2050,8 @@ en: filter_status: "By status" filter_client: "By client" filter_period: "By period" + filter_period_from: "From" + filter_period_to: "to" state: cart: 'Cart' in_progress: 'Under preparation' From d0e68fbf21b9613ad97b43e6bfb7441677abcf6b Mon Sep 17 00:00:00 2001 From: vincent Date: Fri, 16 Sep 2022 15:58:18 +0200 Subject: [PATCH 03/10] (quality) Refacto/style active orders list's filters --- .../javascript/components/store/orders.tsx | 41 ++++++++++++++----- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/app/frontend/src/javascript/components/store/orders.tsx b/app/frontend/src/javascript/components/store/orders.tsx index 2506b9a4d..216d77474 100644 --- a/app/frontend/src/javascript/components/store/orders.tsx +++ b/app/frontend/src/javascript/components/store/orders.tsx @@ -15,6 +15,7 @@ import { FormInput } from '../form/form-input'; import OrderAPI from '../../api/order'; import { Order, OrderIndexFilter } from '../../models/order'; import { FabPagination } from '../base/fab-pagination'; +import { X } from 'phosphor-react'; declare const Application: IApplication; @@ -121,7 +122,7 @@ const Orders: React.FC = ({ currentUser, onError }) => { /** * Clear filter by type */ - const removefilter = (filterType: string) => { + const removeFilter = (filterType: string, state?: string) => { return () => { setFilters(draft => { draft.page = 1; @@ -131,10 +132,13 @@ const Orders: React.FC = ({ currentUser, onError }) => { draft.reference = ''; setReference(''); break; - case 'states': - draft.states = []; - setStates([]); + case 'states': { + const s = [...draft.states]; + s.splice(states.indexOf(state), 1); + setStates(s); + draft.states = s; break; + } case 'user': delete draft.user_id; delete draft.user; @@ -259,12 +263,6 @@ const Orders: React.FC = ({ currentUser, onError }) => { {t('app.admin.store.orders.filter_clear')} -
- {filters.reference &&
{filters.reference} x
} - {filters.states.length > 0 &&
{filters.states.join(', ')} x
} - {filters.user_id > 0 &&
{user?.name} x
} - {filters.period_from &&
{filters.period_from} - {filters.period_to} x
} -
= ({ currentUser, onError }) => { selectValue={filters.sort === 'ASC' ? 1 : 0} onSelectOptionsChange={handleSorting} /> +
+ {filters.reference &&
+

{filters.reference}

+ +
} + {filters.states?.map((f, index) => ( +
+

{f}

+ +
+ ))} + {filters.user_id > 0 &&
+

{user?.name}

+ +
} + {filters.period_from &&
+

{filters.period_from} - {filters.period_to}

+ +
} +
+
{orders.map(order => ( ))}
- {orders.length > 0 && + {pageCount > 1 && }
From b5c924a8e0254090e548df397ed14528b793805b Mon Sep 17 00:00:00 2001 From: vincent Date: Fri, 16 Sep 2022 16:06:58 +0200 Subject: [PATCH 04/10] (bug) Fix filter name --- app/frontend/src/javascript/components/store/orders.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/frontend/src/javascript/components/store/orders.tsx b/app/frontend/src/javascript/components/store/orders.tsx index 216d77474..0e946720b 100644 --- a/app/frontend/src/javascript/components/store/orders.tsx +++ b/app/frontend/src/javascript/components/store/orders.tsx @@ -345,10 +345,10 @@ const Orders: React.FC = ({ currentUser, onError }) => {

{filters.reference}

} - {filters.states?.map((f, index) => ( + {filters.states?.map((status, index) => (
-

{f}

- +

{t(`app.admin.store.orders.state.${status}`)}

+
))} {filters.user_id > 0 &&
From 9742558cfa02c5ba3af908540a7fa5eb66379f42 Mon Sep 17 00:00:00 2001 From: vincent Date: Fri, 16 Sep 2022 17:01:56 +0200 Subject: [PATCH 05/10] (feat) Save product categories' positions --- .../api/product_categories_controller.rb | 2 +- .../categories/product-categories-item.tsx | 6 +- .../categories/product-categories-tree.tsx | 106 +++++++++++------- .../store/categories/product-categories.tsx | 17 ++- .../src/javascript/components/store/store.tsx | 4 +- 5 files changed, 85 insertions(+), 50 deletions(-) diff --git a/app/controllers/api/product_categories_controller.rb b/app/controllers/api/product_categories_controller.rb index 0a60e477d..c126e9616 100644 --- a/app/controllers/api/product_categories_controller.rb +++ b/app/controllers/api/product_categories_controller.rb @@ -40,7 +40,7 @@ class API::ProductCategoriesController < API::ApiController if @product_category.insert_at(params[:position]) render :show else - render json: @product_category.errors.full_messages, status: :unprocessable_entity + render json: @product_category.errors, status: :unprocessable_entity end end diff --git a/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx b/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx index 76e603246..0a591f278 100644 --- a/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx +++ b/app/frontend/src/javascript/components/store/categories/product-categories-item.tsx @@ -1,11 +1,9 @@ -// TODO: Remove next eslint-disable -/* eslint-disable @typescript-eslint/no-unused-vars */ import React from 'react'; import { ProductCategory } from '../../../models/product-category'; import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { ManageProductCategory } from './manage-product-category'; -import { ArrowElbowDownRight, ArrowElbowLeftUp, CaretDown, DotsSixVertical } from 'phosphor-react'; +import { ArrowElbowDownRight, ArrowLeft, CaretDown, DotsSixVertical } from 'phosphor-react'; interface ProductCategoriesItemProps { productCategories: Array, @@ -42,7 +40,7 @@ export const ProductCategoriesItem: React.FC = ({ pr {((isDragging && offset) || status === 'child') &&
{(offset === 'down') && } - {(offset === 'up') && } + {(offset === 'up') && }
}
diff --git a/app/frontend/src/javascript/components/store/categories/product-categories-tree.tsx b/app/frontend/src/javascript/components/store/categories/product-categories-tree.tsx index 5bd94fa7e..ba503d5dd 100644 --- a/app/frontend/src/javascript/components/store/categories/product-categories-tree.tsx +++ b/app/frontend/src/javascript/components/store/categories/product-categories-tree.tsx @@ -1,6 +1,4 @@ -// TODO: Remove next eslint-disable -/* eslint-disable @typescript-eslint/no-unused-vars */ -import React, { useEffect, useState } from 'react'; +import React, { useEffect } from 'react'; import { useImmer } from 'use-immer'; import { ProductCategory } from '../../../models/product-category'; import { DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors, closestCenter, DragMoveEvent } from '@dnd-kit/core'; @@ -10,7 +8,7 @@ import { ProductCategoriesItem } from './product-categories-item'; interface ProductCategoriesTreeProps { productCategories: Array, - onDnd: (list: Array) => void, + onDnd: (list: Array, activeCategory: ProductCategory, position: number) => void, onSuccess: (message: string) => void, onError: (message: string) => void, } @@ -67,6 +65,10 @@ export const ProductCategoriesTree: React.FC = ({ pr setActiveData(draft => { return { ...draft, offset: 'down' }; }); + } else if (Math.ceil(delta.x) < -32 && getStatus(over.id) === 'child') { + setActiveData(draft => { + return { ...draft, offset: 'up' }; + }); } else { setActiveData(draft => { return { ...draft, offset: null }; @@ -98,47 +100,60 @@ export const ProductCategoriesTree: React.FC = ({ pr let newOrder = [...categoriesList]; const currentIdsOrder = over?.data.current.sortable.items; let newIndex = over.data.current.sortable.index; + let droppedItem = getCategory(active.id); + const activeStatus = getStatus(active.id); + const overStatus = getStatus(over.id); + let newPosition = getCategory(over.id).position; - // [A] Single |> [B] Single - if (getStatus(active.id) === 'single' && getStatus(over.id) === 'single') { + // [A]:Single dropped over [B]:Single + if (activeStatus === 'single' && overStatus === 'single') { const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex); newOrder = newIdsOrder.map(sortedId => { let category = getCategory(sortedId); if (activeData.offset === 'down' && sortedId === active.id && activeData.index < newIndex && active.id !== over.id) { category = { ...category, parent_id: Number(over.id) }; + droppedItem = category; } else if (activeData.offset === 'down' && sortedId === active.id && (activeData.index > newIndex || active.id === over.id)) { category = { ...category, parent_id: getPreviousAdopter(over.id) }; + droppedItem = category; } return category; }); } - // [A] Child |> [B] Single - if ((getStatus(active.id) === 'child') && getStatus(over.id) === 'single') { + // [A]:Child dropped over [B]:Single + if ((activeStatus === 'child') && overStatus === 'single') { const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex); newOrder = newIdsOrder.map(sortedId => { let category = getCategory(sortedId); if (activeData.offset === 'down' && sortedId === active.id && activeData.index < newIndex) { category = { ...category, parent_id: Number(over.id) }; + droppedItem = category; + newPosition = 0; } else if (activeData.offset === 'down' && sortedId === active.id && activeData.index > newIndex) { category = { ...category, parent_id: getPreviousAdopter(over.id) }; + droppedItem = category; + newPosition = getChildren(getPreviousAdopter(over.id))?.length || 0; } else if (sortedId === active.id) { category = { ...category, parent_id: null }; + droppedItem = category; } return category; }); } - // [A] Single || Child |>… - if (getStatus(active.id) === 'single' || getStatus(active.id) === 'child') { - // [B] Parent - if (getStatus(over.id) === 'parent') { + // [A]:Single || [A]:Child dropped over… + if (activeStatus === 'single' || activeStatus === 'child') { + // [B]:Parent + if (overStatus === 'parent') { const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex); if (activeData.index < newIndex) { newOrder = newIdsOrder.map(sortedId => { let category = getCategory(sortedId); if (sortedId === active.id) { category = { ...category, parent_id: Number(over.id) }; + droppedItem = category; + newPosition = 0; } return category; }); @@ -147,38 +162,54 @@ export const ProductCategoriesTree: React.FC = ({ pr let category = getCategory(sortedId); if (sortedId === active.id && !activeData.offset) { category = { ...category, parent_id: null }; + droppedItem = category; } else if (sortedId === active.id && activeData.offset === 'down') { category = { ...category, parent_id: getPreviousAdopter(over.id) }; + droppedItem = category; + newPosition = getChildren(getPreviousAdopter(over.id))?.length || 0; } return category; }); } } - // [B] Child - if (getStatus(over.id) === 'child') { - const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex); - newOrder = newIdsOrder.map(sortedId => { - let category = getCategory(sortedId); - if (sortedId === active.id) { - category = { ...category, parent_id: getCategory(over.id).parent_id }; - } - return category; - }); + // [B]:Child + if (overStatus === 'child') { + if (activeData.offset === 'up') { + const lastChildIndex = newOrder.findIndex(c => c.id === getChildren(getCategory(over.id).parent_id).pop().id); + const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, lastChildIndex); + newOrder = newIdsOrder.map(sortedId => { + let category = getCategory(sortedId); + if (sortedId === active.id) { + category = { ...category, parent_id: null }; + droppedItem = category; + } + return category; + }); + } else { + const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex); + newOrder = newIdsOrder.map(sortedId => { + let category = getCategory(sortedId); + if (sortedId === active.id) { + category = { ...category, parent_id: getCategory(over.id).parent_id }; + droppedItem = category; + } + return category; + }); + } } } - // [A] Parent |>… - if (getStatus(active.id) === 'parent') { - // [B] Single - if (getStatus(over.id) === 'single') { + // [A]:Parent dropped over… + if (activeStatus === 'parent') { + // [B]:Single + if (overStatus === 'single') { const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex); newOrder = newIdsOrder.map(sortedId => getCategory(sortedId)); } - // [B] Parent - if (getStatus(over.id) === 'parent') { + // [B]:Parent + if (overStatus === 'parent') { if (activeData.index < newIndex) { - const lastOverChildIndex = newOrder.findIndex(c => c.id === getChildren(over.id).pop().id); - newIndex = lastOverChildIndex; + newIndex = newOrder.findIndex(c => c.id === getChildren(over.id).pop().id); const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex); newOrder = newIdsOrder.map(sortedId => getCategory(sortedId)); } else { @@ -186,17 +217,15 @@ export const ProductCategoriesTree: React.FC = ({ pr newOrder = newIdsOrder.map(sortedId => getCategory(sortedId)); } } - // [B] Child - if (getStatus(over.id) === 'child') { + // [B]:Child + if (overStatus === 'child') { if (activeData.index < newIndex) { const parent = newOrder.find(c => c.id === getCategory(over.id).parent_id); - const lastSiblingIndex = newOrder.findIndex(c => c.id === getChildren(parent.id).pop().id); - newIndex = lastSiblingIndex; + newIndex = newOrder.findIndex(c => c.id === getChildren(parent.id).pop().id); const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex); newOrder = newIdsOrder.map(sortedId => getCategory(sortedId)); } else { - const parentIndex = currentIdsOrder.indexOf(getCategory(over.id).parent_id); - newIndex = parentIndex; + newIndex = currentIdsOrder.indexOf(getCategory(over.id).parent_id); const newIdsOrder = arrayMove(currentIdsOrder, activeData.index, newIndex); newOrder = newIdsOrder.map(sortedId => getCategory(sortedId)); } @@ -204,8 +233,9 @@ export const ProductCategoriesTree: React.FC = ({ pr // insert children back newOrder = showChildren(active.id, newOrder, newIndex); } + setActiveData(initActiveData); - onDnd(newOrder); + onDnd(newOrder, droppedItem, newPosition); }; /** @@ -283,7 +313,7 @@ export const ProductCategoriesTree: React.FC = ({ pr }; /** - * Toggle parent category by hidding/showing its children + * Toggle parent category by hiding/showing its children */ const handleCollapse = (id) => { const i = collapsed.findIndex(el => el === id); diff --git a/app/frontend/src/javascript/components/store/categories/product-categories.tsx b/app/frontend/src/javascript/components/store/categories/product-categories.tsx index be45f7b8e..2fda32f85 100644 --- a/app/frontend/src/javascript/components/store/categories/product-categories.tsx +++ b/app/frontend/src/javascript/components/store/categories/product-categories.tsx @@ -46,8 +46,16 @@ const ProductCategories: React.FC = ({ onSuccess, onErro /** * Update state after drop */ - const handleDnd = (data: ProductCategory[]) => { - setProductCategories(data); + const handleDnd = (list: ProductCategory[], activeCategory: ProductCategory, position: number) => { + setProductCategories(list); + ProductCategoryAPI + .update(activeCategory) + .then(c => { + ProductCategoryAPI + .updatePosition(c, position) + .catch(error => onError(error)); + }) + .catch(error => onError(error)); }; /** @@ -60,10 +68,9 @@ const ProductCategories: React.FC = ({ onSuccess, onErro }; /** - * Save list's new order + * tmp: check list */ const handleSave = () => { - // TODO: index to position -> send to API console.log('save order:', productCategories); }; @@ -75,7 +82,7 @@ const ProductCategories: React.FC = ({ onSuccess, onErro - Save + [log]
diff --git a/app/frontend/src/javascript/components/store/store.tsx b/app/frontend/src/javascript/components/store/store.tsx index 221172cff..01c15afed 100644 --- a/app/frontend/src/javascript/components/store/store.tsx +++ b/app/frontend/src/javascript/components/store/store.tsx @@ -49,7 +49,7 @@ const Store: React.FC = ({ onError, onSuccess, currentUser }) => { const [currentPage, setCurrentPage] = useState(1); useEffect(() => { - ProductAPI.index({ page: 1, is_active: true }).then(data => { + ProductAPI.index({ page: 1, is_active: filterVisible }).then(data => { setPageCount(data.total_pages); setProducts(data.products); }).catch(() => { @@ -151,7 +151,7 @@ const Store: React.FC = ({ onError, onSuccess, currentUser }) => { */ const handlePagination = (page: number) => { if (page !== currentPage) { - ProductAPI.index({ page, is_active: true }).then(data => { + ProductAPI.index({ page, is_active: filterVisible }).then(data => { setCurrentPage(page); setProducts(data.products); setPageCount(data.total_pages); From 945428e71c65f73c5c79c93f6daa131e2e0e3fb5 Mon Sep 17 00:00:00 2001 From: vincent Date: Fri, 16 Sep 2022 17:58:19 +0200 Subject: [PATCH 06/10] (quality) Refacto tooltip --- .../components/form/abstract-form-item.tsx | 4 +-- .../components/store/order-item.tsx | 14 ++++++++- .../javascript/components/store/orders.tsx | 2 +- app/frontend/src/stylesheets/application.scss | 1 + .../stylesheets/modules/base/fab-tooltip.scss | 29 ++++++++++++++++++ .../modules/form/abstract-form-item.scss | 30 ------------------- .../stylesheets/modules/form/form-switch.scss | 2 +- .../stylesheets/modules/store/order-item.scss | 15 +++++++++- config/locales/app.shared.en.yml | 2 ++ 9 files changed, 63 insertions(+), 36 deletions(-) create mode 100644 app/frontend/src/stylesheets/modules/base/fab-tooltip.scss diff --git a/app/frontend/src/javascript/components/form/abstract-form-item.tsx b/app/frontend/src/javascript/components/form/abstract-form-item.tsx index afa91a07a..d4e6f49fc 100644 --- a/app/frontend/src/javascript/components/form/abstract-form-item.tsx +++ b/app/frontend/src/javascript/components/form/abstract-form-item.tsx @@ -63,7 +63,7 @@ export const AbstractFormItem = ({ id, label, <> {(label && !inLine) &&

{label}

- {tooltip &&
+ {tooltip &&
{tooltip}
} @@ -71,7 +71,7 @@ export const AbstractFormItem = ({ id, label,
{inLine &&

{label}

- {tooltip &&
+ {tooltip &&
{tooltip}
} diff --git a/app/frontend/src/javascript/components/store/order-item.tsx b/app/frontend/src/javascript/components/store/order-item.tsx index b1afd5bbf..103ff2127 100644 --- a/app/frontend/src/javascript/components/store/order-item.tsx +++ b/app/frontend/src/javascript/components/store/order-item.tsx @@ -6,6 +6,7 @@ import { FabButton } from '../base/fab-button'; import { User } from '../../models/user'; import { FabStateLabel } from '../base/fab-state-label'; import OrderLib from '../../lib/order'; +import { ArrowClockwise } from 'phosphor-react'; interface OrderItemProps { order?: Order, @@ -47,7 +48,18 @@ export const OrderItem: React.FC = ({ order, currentUser }) => {

{order?.user?.name || ''}

} -

{FormatLib.date(order.created_at)}

+
+ {t('app.shared.store.order_item.created_at')} +

{FormatLib.date(order.created_at)} +

+ +
+ {t('app.shared.store.order_item.last_update')}
+ {FormatLib.date(order.updated_at)} +
+
+

+
{t('app.shared.store.order_item.total')}

{FormatLib.price(order.state === 'cart' ? order.total : order.paid_total)}

diff --git a/app/frontend/src/javascript/components/store/orders.tsx b/app/frontend/src/javascript/components/store/orders.tsx index 0e946720b..5461937e0 100644 --- a/app/frontend/src/javascript/components/store/orders.tsx +++ b/app/frontend/src/javascript/components/store/orders.tsx @@ -356,7 +356,7 @@ const Orders: React.FC = ({ currentUser, onError }) => {
} {filters.period_from &&
-

{filters.period_from} - {filters.period_to}

+

{filters.period_from} {'>'} {filters.period_to}

}
diff --git a/app/frontend/src/stylesheets/application.scss b/app/frontend/src/stylesheets/application.scss index 04d5e9189..1dce80690 100644 --- a/app/frontend/src/stylesheets/application.scss +++ b/app/frontend/src/stylesheets/application.scss @@ -30,6 +30,7 @@ @import "modules/base/fab-popover"; @import "modules/base/fab-state-label"; @import "modules/base/fab-text-editor"; +@import "modules/base/fab-tooltip"; @import "modules/base/labelled-input"; @import "modules/calendar/calendar"; @import "modules/cart/cart-button"; diff --git a/app/frontend/src/stylesheets/modules/base/fab-tooltip.scss b/app/frontend/src/stylesheets/modules/base/fab-tooltip.scss new file mode 100644 index 000000000..91c9ea07b --- /dev/null +++ b/app/frontend/src/stylesheets/modules/base/fab-tooltip.scss @@ -0,0 +1,29 @@ +.fab-tooltip { + position: relative; + cursor: help; + + .trigger i { display: block; } + .content { + position: absolute; + top: 0; + right: 0; + display: none; + width: max-content; + max-width: min(75vw, 65ch); + padding: 1rem; + background-color: var(--information-lightest); + color: var(--information); + border: 1px solid var(--information); + border-radius: var(--border-radius); + font-size: 14px; + font-weight: normal; + line-height: 1.2em; + z-index: 1; + & > span { display: block; } + a { + color: var(--gray-hard); + text-decoration: underline; + } + } + &:hover .content { display: block; } +} \ No newline at end of file diff --git a/app/frontend/src/stylesheets/modules/form/abstract-form-item.scss b/app/frontend/src/stylesheets/modules/form/abstract-form-item.scss index 55abc566a..775fb4a2f 100644 --- a/app/frontend/src/stylesheets/modules/form/abstract-form-item.scss +++ b/app/frontend/src/stylesheets/modules/form/abstract-form-item.scss @@ -15,36 +15,6 @@ cursor: pointer; &::first-letter { text-transform: uppercase; } } - - .item-tooltip { - position: relative; - cursor: help; - - .trigger i { display: block; } - .content { - position: absolute; - top: 0; - right: 0; - display: none; - width: max-content; - max-width: min(75vw, 65ch); - padding: 1rem; - background-color: var(--information-lightest); - color: var(--information); - border: 1px solid var(--information); - border-radius: var(--border-radius); - font-size: 14px; - font-weight: normal; - line-height: 1.2em; - z-index: 1; - & > span { display: block; } - a { - color: var(--gray-hard); - text-decoration: underline; - } - } - &:hover .content { display: block; } - } } &.is-hidden { display: none; diff --git a/app/frontend/src/stylesheets/modules/form/form-switch.scss b/app/frontend/src/stylesheets/modules/form/form-switch.scss index c08e3dc34..c191c8c32 100644 --- a/app/frontend/src/stylesheets/modules/form/form-switch.scss +++ b/app/frontend/src/stylesheets/modules/form/form-switch.scss @@ -6,7 +6,7 @@ margin-left: 1.5rem; } - .item-tooltip .content { + .fab-tooltip .content { max-width: min(75vw, 30ch); } } diff --git a/app/frontend/src/stylesheets/modules/store/order-item.scss b/app/frontend/src/stylesheets/modules/store/order-item.scss index 50522ddf3..46174cbc0 100644 --- a/app/frontend/src/stylesheets/modules/store/order-item.scss +++ b/app/frontend/src/stylesheets/modules/store/order-item.scss @@ -36,7 +36,20 @@ } p { @include text-sm; } } - .date { @include text-sm; } + .date { + & > span { + @include text-xs; + color: var(--gray-hard-light); + } + p { + display: flex; + @include text-sm; + .fab-tooltip { + margin-left: 1rem; + color: var(--information); + } + } + } .price { flex: 0 1 30%; display: flex; diff --git a/config/locales/app.shared.en.yml b/config/locales/app.shared.en.yml index 263d061ff..bfa293431 100644 --- a/config/locales/app.shared.en.yml +++ b/config/locales/app.shared.en.yml @@ -564,6 +564,8 @@ en: order_item: total: "Total" client: "Client" + created_at: "Order creation" + last_update: "Last update" state: cart: 'Cart' in_progress: 'Under preparation' From 586dd5f9b51a6eae7fa1dc1afde19656498c9cab Mon Sep 17 00:00:00 2001 From: Sylvain Date: Mon, 19 Sep 2022 10:54:41 +0200 Subject: [PATCH 07/10] (quality) extract filter in separate components --- .../{store => base}/accordion-item.tsx | 2 +- .../store/filters/categories-filter.tsx | 109 +++++++++ .../store/filters/keyword-filter.tsx | 63 +++++ .../store/filters/machines-filter.tsx | 82 +++++++ .../components/store/filters/stock-filter.tsx | 98 ++++++++ .../javascript/components/store/orders.tsx | 2 +- .../javascript/components/store/products.tsx | 216 ++++-------------- .../src/javascript/components/store/store.tsx | 2 +- .../modules/store/store-filters.scss | 8 +- config/locales/app.admin.en.yml | 25 +- 10 files changed, 414 insertions(+), 193 deletions(-) rename app/frontend/src/javascript/components/{store => base}/accordion-item.tsx (93%) create mode 100644 app/frontend/src/javascript/components/store/filters/categories-filter.tsx create mode 100644 app/frontend/src/javascript/components/store/filters/keyword-filter.tsx create mode 100644 app/frontend/src/javascript/components/store/filters/machines-filter.tsx create mode 100644 app/frontend/src/javascript/components/store/filters/stock-filter.tsx diff --git a/app/frontend/src/javascript/components/store/accordion-item.tsx b/app/frontend/src/javascript/components/base/accordion-item.tsx similarity index 93% rename from app/frontend/src/javascript/components/store/accordion-item.tsx rename to app/frontend/src/javascript/components/base/accordion-item.tsx index 711933500..63eb6c9e1 100644 --- a/app/frontend/src/javascript/components/store/accordion-item.tsx +++ b/app/frontend/src/javascript/components/base/accordion-item.tsx @@ -1,4 +1,3 @@ -/* eslint-disable fabmanager/scoped-translation */ import React, { useState, useEffect } from 'react'; import { CaretDown } from 'phosphor-react'; @@ -14,6 +13,7 @@ interface AccordionItemProps { */ export const AccordionItem: React.FC = ({ isOpen, onChange, id, label, children }) => { const [state, setState] = useState(isOpen); + useEffect(() => { onChange(id, state); }, [state]); diff --git a/app/frontend/src/javascript/components/store/filters/categories-filter.tsx b/app/frontend/src/javascript/components/store/filters/categories-filter.tsx new file mode 100644 index 000000000..a72017e35 --- /dev/null +++ b/app/frontend/src/javascript/components/store/filters/categories-filter.tsx @@ -0,0 +1,109 @@ +import React, { useEffect, useState } from 'react'; +import _ from 'lodash'; +import ProductCategoryAPI from '../../../api/product-category'; +import ProductLib from '../../../lib/product'; +import { ProductCategory } from '../../../models/product-category'; +import { FabButton } from '../../base/fab-button'; +import { AccordionItem } from '../../base/accordion-item'; +import { useTranslation } from 'react-i18next'; + +interface CategoriesFilterProps { + onError: (message: string) => void, + onApplyFilters: (categories: Array) => void, + currentFilters: Array, + openDefault?: boolean, + instantUpdate?: boolean, +} + +/** + * Component to filter the products list by categories + */ +export const CategoriesFilter: React.FC = ({ onError, onApplyFilters, currentFilters, openDefault = false, instantUpdate = false }) => { + const { t } = useTranslation('admin'); + + const [productCategories, setProductCategories] = useState([]); + const [openedAccordion, setOpenedAccordion] = useState(openDefault); + const [selectedCategories, setSelectedCategories] = useState(currentFilters || []); + + useEffect(() => { + ProductCategoryAPI.index().then(data => { + setProductCategories(ProductLib.sortCategories(data)); + }).catch(onError); + }, []); + + useEffect(() => { + if (currentFilters && !_.isEqual(currentFilters, selectedCategories)) { + setSelectedCategories(currentFilters); + } + }, [currentFilters]); + + /** + * Open/close the accordion item + */ + const handleAccordion = (id, state: boolean) => { + setOpenedAccordion(state); + }; + + /** + * Callback triggered when a category filter is selected or unselected. + * This may cause other categories to be selected or unselected accordingly. + */ + const handleSelectCategory = (currentCategory: ProductCategory, checked: boolean) => { + let list = [...selectedCategories]; + const children = productCategories + .filter(el => el.parent_id === currentCategory.id); + const siblings = productCategories + .filter(el => el.parent_id === currentCategory.parent_id && el.parent_id !== null); + + if (checked) { + list.push(currentCategory); + if (children.length) { + // if a parent category is selected, we automatically select all its children + list = [...Array.from(new Set([...list, ...children]))]; + } + if (siblings.length && siblings.every(el => list.includes(el))) { + // if a child category is selected, with every sibling of it, we automatically select its parent + list.push(productCategories.find(p => p.id === siblings[0].parent_id)); + } + } else { + list.splice(list.indexOf(currentCategory), 1); + const parent = productCategories.find(p => p.id === currentCategory.parent_id); + if (currentCategory.parent_id && list.includes(parent)) { + // if a child category is unselected, we unselect its parent + list.splice(list.indexOf(parent), 1); + } + if (children.length) { + // if a parent category is unselected, we unselect all its children + children.forEach(child => { + list.splice(list.indexOf(child), 1); + }); + } + } + + setSelectedCategories(list); + if (instantUpdate) { + onApplyFilters(list); + } + }; + + return ( + <> + +
+
+ {productCategories.map(pc => ( + + ))} +
+ onApplyFilters(selectedCategories)} className="is-info">{t('app.admin.store.categories_filter.filter_apply')} +
+
+ + ); +}; diff --git a/app/frontend/src/javascript/components/store/filters/keyword-filter.tsx b/app/frontend/src/javascript/components/store/filters/keyword-filter.tsx new file mode 100644 index 000000000..63d25daed --- /dev/null +++ b/app/frontend/src/javascript/components/store/filters/keyword-filter.tsx @@ -0,0 +1,63 @@ +import React, { useEffect, useState } from 'react'; +import { FabButton } from '../../base/fab-button'; +import { AccordionItem } from '../../base/accordion-item'; +import { useTranslation } from 'react-i18next'; +import _ from 'lodash'; + +interface KeywordFilterProps { + onApplyFilters: (keywork: string) => void, + currentFilters: string, + openDefault?: boolean, + instantUpdate?: boolean, +} + +/** + * Component to filter the products list by keyword or product reference + */ +export const KeywordFilter: React.FC = ({ onApplyFilters, currentFilters, openDefault = false, instantUpdate = false }) => { + const { t } = useTranslation('admin'); + + const [openedAccordion, setOpenedAccordion] = useState(openDefault); + const [keyword, setKeyword] = useState(currentFilters); + + useEffect(() => { + if (currentFilters && !_.isEqual(currentFilters, keyword)) { + setKeyword(currentFilters); + } + }, [currentFilters]); + + /** + * Open/close the accordion item + */ + const handleAccordion = (id, state: boolean) => { + setOpenedAccordion(state); + }; + + /** + * Callback triggered when the user types anything in the input + */ + const handleKeywordTyping = (evt: React.ChangeEvent) => { + setKeyword(evt.target.value); + + if (instantUpdate) { + onApplyFilters(evt.target.value); + } + }; + + return ( + <> + +
+
+ handleKeywordTyping(event)} /> + onApplyFilters(keyword)} className="is-info">{t('app.admin.store.keyword_filter.filter_apply')} +
+
+
+ + ); +}; diff --git a/app/frontend/src/javascript/components/store/filters/machines-filter.tsx b/app/frontend/src/javascript/components/store/filters/machines-filter.tsx new file mode 100644 index 000000000..2a0d28482 --- /dev/null +++ b/app/frontend/src/javascript/components/store/filters/machines-filter.tsx @@ -0,0 +1,82 @@ +import React, { useEffect, useState } from 'react'; +import { FabButton } from '../../base/fab-button'; +import { AccordionItem } from '../../base/accordion-item'; +import { useTranslation } from 'react-i18next'; +import { Machine } from '../../../models/machine'; +import MachineAPI from '../../../api/machine'; +import _ from 'lodash'; + +interface MachinesFilterProps { + onError: (message: string) => void, + onApplyFilters: (categories: Array) => void, + currentFilters: Array, + openDefault?: boolean, + instantUpdate?: boolean, +} + +/** + * Component to filter the products list by associated machine + */ +export const MachinesFilter: React.FC = ({ onError, onApplyFilters, currentFilters, openDefault = false, instantUpdate = false }) => { + const { t } = useTranslation('admin'); + + const [machines, setMachines] = useState([]); + const [openedAccordion, setOpenedAccordion] = useState(openDefault); + const [selectedMachines, setSelectedMachines] = useState(currentFilters || []); + + useEffect(() => { + MachineAPI.index({ disabled: false }).then(data => { + setMachines(data); + }).catch(onError); + }, []); + + useEffect(() => { + if (currentFilters && !_.isEqual(currentFilters, selectedMachines)) { + setSelectedMachines(currentFilters); + } + }, [currentFilters]); + + /** + * Open/close the accordion item + */ + const handleAccordion = (id, state: boolean) => { + setOpenedAccordion(state); + }; + + /** + * Callback triggered when a machine filter is seleced or unselected. + */ + const handleSelectMachine = (currentMachine: Machine, checked: boolean) => { + const list = [...machines]; + checked + ? list.push(currentMachine) + : list.splice(list.indexOf(currentMachine), 1); + + setSelectedMachines(list); + if (instantUpdate) { + onApplyFilters(list); + } + }; + + return ( + <> + +
+
+ {machines.map(m => ( + + ))} +
+ onApplyFilters(selectedMachines)} className="is-info">{t('app.admin.store.machines_filter.filter_apply')} +
+
+ + ); +}; diff --git a/app/frontend/src/javascript/components/store/filters/stock-filter.tsx b/app/frontend/src/javascript/components/store/filters/stock-filter.tsx new file mode 100644 index 000000000..6d7d94283 --- /dev/null +++ b/app/frontend/src/javascript/components/store/filters/stock-filter.tsx @@ -0,0 +1,98 @@ +import React, { useEffect, useState } from 'react'; +import { FabButton } from '../../base/fab-button'; +import { AccordionItem } from '../../base/accordion-item'; +import { useTranslation } from 'react-i18next'; +import { StockType } from '../../../models/product'; +import { FormSelect } from '../../form/form-select'; +import { FormInput } from '../../form/form-input'; +import { useForm } from 'react-hook-form'; +import _ from 'lodash'; + +export interface StockFilterData { + stock_type: StockType, + stock_from: number, + stock_to: number +} + +interface StockFilterProps { + onApplyFilters: (filters: StockFilterData) => void, + currentFilters: StockFilterData, + openDefault?: boolean +} + +/** + * Option format, expected by react-select + * @see https://github.com/JedWatson/react-select + */ +type selectOption = { value: StockType, label: string }; + +/** + * Component to filter the products list by stock + */ +export const StockFilter: React.FC = ({ onApplyFilters, currentFilters, openDefault = false }) => { + const { t } = useTranslation('admin'); + + const [openedAccordion, setOpenedAccordion] = useState(openDefault); + + const { register, control, handleSubmit, getValues, reset } = useForm({ defaultValues: { ...currentFilters } }); + + useEffect(() => { + if (currentFilters && !_.isEqual(currentFilters, getValues())) { + reset(currentFilters); + } + }, [currentFilters]); + + /** + * Open/close the accordion item + */ + const handleAccordion = (id, state: boolean) => { + setOpenedAccordion(state); + }; + + /** + * Callback triggered when the user clicks on "apply" to apply teh current filters. + */ + const onSubmit = (data: StockFilterData) => { + onApplyFilters(data); + }; + + /** Creates sorting options to the react-select format */ + const buildStockOptions = (): Array => { + return [ + { value: 'internal', label: t('app.admin.store.stock_filter.stock_internal') }, + { value: 'external', label: t('app.admin.store.stock_filter.stock_external') } + ]; + }; + + return ( + <> + +
+
+ +
+ + +
+ {t('app.admin.store.stock_filter.filter_apply')} +
+
+
+ + ); +}; diff --git a/app/frontend/src/javascript/components/store/orders.tsx b/app/frontend/src/javascript/components/store/orders.tsx index 5461937e0..b77ca01ab 100644 --- a/app/frontend/src/javascript/components/store/orders.tsx +++ b/app/frontend/src/javascript/components/store/orders.tsx @@ -7,7 +7,7 @@ import { IApplication } from '../../models/application'; import { useForm } from 'react-hook-form'; import { FabButton } from '../base/fab-button'; import { StoreListHeader } from './store-list-header'; -import { AccordionItem } from './accordion-item'; +import { AccordionItem } from '../base/accordion-item'; import { OrderItem } from './order-item'; import { MemberSelect } from '../user/member-select'; import { User } from '../../models/user'; diff --git a/app/frontend/src/javascript/components/store/products.tsx b/app/frontend/src/javascript/components/store/products.tsx index 50540dc45..b959c8ee2 100644 --- a/app/frontend/src/javascript/components/store/products.tsx +++ b/app/frontend/src/javascript/components/store/products.tsx @@ -6,19 +6,17 @@ import { Loader } from '../base/loader'; import { IApplication } from '../../models/application'; import { Product } from '../../models/product'; import { ProductCategory } from '../../models/product-category'; -import { useForm } from 'react-hook-form'; import { FabButton } from '../base/fab-button'; import { ProductItem } from './product-item'; import ProductAPI from '../../api/product'; -import ProductCategoryAPI from '../../api/product-category'; -import MachineAPI from '../../api/machine'; -import { AccordionItem } from './accordion-item'; import { X } from 'phosphor-react'; import { StoreListHeader } from './store-list-header'; import { FabPagination } from '../base/fab-pagination'; -import ProductLib from '../../lib/product'; -import { FormInput } from '../form/form-input'; -import { FormSelect } from '../form/form-select'; +import { CategoriesFilter } from './filters/categories-filter'; +import { Machine } from '../../models/machine'; +import { MachinesFilter } from './filters/machines-filter'; +import { KeywordFilter } from './filters/keyword-filter'; +import { StockFilter, StockFilterData } from './filters/stock-filter'; declare const Application: IApplication; @@ -36,17 +34,12 @@ interface ProductsProps { const Products: React.FC = ({ onSuccess, onError }) => { const { t } = useTranslation('admin'); - const { register, control, getValues } = useForm(); - const [filteredProductsList, setFilteredProductList] = useImmer>([]); const [features, setFeatures] = useImmer(initFilters); const [filterVisible, setFilterVisible] = useState(false); const [filters, setFilters] = useImmer(initFilters); const [clearFilters, setClearFilters] = useState(false); - const [productCategories, setProductCategories] = useState([]); - const [machines, setMachines] = useState([]); const [update, setUpdate] = useState(false); - const [accordion, setAccordion] = useState({}); const [pageCount, setPageCount] = useState(0); const [currentPage, setCurrentPage] = useState(1); @@ -55,14 +48,6 @@ const Products: React.FC = ({ onSuccess, onError }) => { setPageCount(data.total_pages); setFilteredProductList(data.products); }); - - ProductCategoryAPI.index().then(data => { - setProductCategories(ProductLib.sortCategories(data)); - }).catch(onError); - - MachineAPI.index({ disabled: false }).then(data => { - setMachines(buildChecklistOptions(data)); - }).catch(onError); }, []); useEffect(() => { @@ -114,92 +99,42 @@ const Products: React.FC = ({ onSuccess, onError }) => { }; /** - * Callback triggered when a category filter is selected or unselected. - * This may cause other filters to be selected or unselected accordingly. + * Update the list of applied filters with the given categories */ - const handleSelectCategory = (currentCategory: ProductCategory, checked: boolean, instantUpdate?: boolean) => { - let list = [...filters.categories]; - const children = productCategories - .filter(el => el.parent_id === currentCategory.id); - const siblings = productCategories - .filter(el => el.parent_id === currentCategory.parent_id && el.parent_id !== null); - - if (checked) { - list.push(currentCategory); - if (children.length) { - // if a parent category is selected, we automatically select all its children - list = [...Array.from(new Set([...list, ...children]))]; - } - if (siblings.length && siblings.every(el => list.includes(el))) { - // if a child category is selected, with every sibling of it, we automatically select its parent - list.push(productCategories.find(p => p.id === siblings[0].parent_id)); - } - } else { - list.splice(list.indexOf(currentCategory), 1); - const parent = productCategories.find(p => p.id === currentCategory.parent_id); - if (currentCategory.parent_id && list.includes(parent)) { - // if a child category is unselected, we unselect its parent - list.splice(list.indexOf(parent), 1); - } - if (children.length) { - // if a parent category is unselected, we unselect all its children - children.forEach(child => { - list.splice(list.indexOf(child), 1); - }); - } - } - + const handleCategoriesFilterUpdate = (categories: Array) => { setFilters(draft => { - return { ...draft, categories: list }; + return { ...draft, categories }; }); - if (instantUpdate) { - setUpdate(true); - } }; /** - * Callback triggered when a machine filter is seleced or unselected. + * Update the list of applied filters with the given machines */ - const handleSelectMachine = (currentMachine: checklistOption, checked: boolean, instantUpdate?: boolean) => { - const list = [...filters.machines]; - checked - ? list.push(currentMachine) - : list.splice(list.indexOf(currentMachine), 1); + const handleMachinesFilterUpdate = (machines: Array) => { setFilters(draft => { - return { ...draft, machines: list }; + return { ...draft, machines }; }); - if (instantUpdate) { - setUpdate(true); - } }; - /** Filter: by keyword or ref */ - const handleKeyword = (evt: React.ChangeEvent) => { + /** + * Update the list of applied filters with the given keywords (or reference) + */ + const handleKeywordFilterUpdate = (keywords: Array) => { setFilters(draft => { - return { ...draft, keywords: evt.target.value }; + return { ...draft, keywords }; }); }; /** Filter: by stock range */ - const handleStockRange = () => { + const handleStockFilterUpdate = (filters: StockFilterData) => { setFilters(draft => { return { ...draft, - stock_type: buildStockOptions()[getValues('stock_type')].label, - stock_from: getValues('stock_from'), - stock_to: getValues('stock_to') + ...filters }; }); }; - /** Creates sorting options to the react-select format */ - const buildStockOptions = (): Array => { - return [ - { value: 0, label: t('app.admin.store.products.stock_internal') }, - { value: 1, label: t('app.admin.store.products.stock_external') } - ]; - }; - /** Display option: sorting */ const handleSorting = (option: selectOption) => { console.log('Sort option:', option); @@ -238,11 +173,6 @@ const Products: React.FC = ({ onSuccess, onError }) => { ]; }; - /** Open/close accordion items */ - const handleAccordion = (id, state) => { - setAccordion({ ...accordion, [id]: state }); - }; - return (
@@ -259,83 +189,19 @@ const Products: React.FC = ({ onSuccess, onError }) => {
- -
-
- {productCategories.map(pc => ( - - ))} -
- setUpdate(true)} className="is-info">{t('app.admin.store.products.filter_apply')} -
-
+ - -
-
- {machines.map(m => ( - - ))} -
- setUpdate(true)} className="is-info">{t('app.admin.store.products.filter_apply')} -
-
+ - -
-
- handleKeyword(event)} /> - setUpdate(true)} className="is-info">{t('app.admin.store.products.filter_apply')} -
-
-
+ handleKeywordFilterUpdate([...filters.keywords, keyword])} + currentFilters={filters.keywords[0]} /> - -
-
- -
- - -
- {t('app.admin.store.products.filter_apply')} -
-
-
+
@@ -350,13 +216,19 @@ const Products: React.FC = ({ onSuccess, onError }) => { {features.categories.map(c => (

{c.name}

- +
))} {features.machines.map(m => ( -
-

{m.label}

- +
+

{m.name}

+ +
+ ))} + {features.keywords.map(k => ( +
+

{k}

+
))}
@@ -389,19 +261,9 @@ const ProductsWrapper: React.FC = ({ onSuccess, onError }) => { Application.Components.component('products', react2angular(ProductsWrapper, ['onSuccess', 'onError'])); -/** Option format, expected by checklist */ -type checklistOption = { value: number, label: string }; - -/** Convert the provided array of items to the checklist format */ -const buildChecklistOptions = (items: Array<{ id?: number, name: string }>): Array => { - return items.map(t => { - return { value: t.id, label: t.name }; - }); -}; - interface Filters { categories: ProductCategory[], - machines: checklistOption[], + machines: Machine[], keywords: string[], stock_type: 'internal' | 'external', stock_from: number, diff --git a/app/frontend/src/javascript/components/store/store.tsx b/app/frontend/src/javascript/components/store/store.tsx index 01c15afed..fa36d21f8 100644 --- a/app/frontend/src/javascript/components/store/store.tsx +++ b/app/frontend/src/javascript/components/store/store.tsx @@ -13,7 +13,7 @@ import { StoreProductItem } from './store-product-item'; import useCart from '../../hooks/use-cart'; import { User } from '../../models/user'; import { Order } from '../../models/order'; -import { AccordionItem } from './accordion-item'; +import { AccordionItem } from '../base/accordion-item'; import { StoreListHeader } from './store-list-header'; import { FabPagination } from '../base/fab-pagination'; diff --git a/app/frontend/src/stylesheets/modules/store/store-filters.scss b/app/frontend/src/stylesheets/modules/store/store-filters.scss index 64135358d..1214f1f09 100644 --- a/app/frontend/src/stylesheets/modules/store/store-filters.scss +++ b/app/frontend/src/stylesheets/modules/store/store-filters.scss @@ -77,7 +77,7 @@ * { opacity: 0; } } } - + header { width: 100%; padding: 0; @@ -98,7 +98,7 @@ align-items: stretch; transition: max-height 500ms ease-in-out; * { transition: opacity 250ms ease-in-out 300ms; } - + .group { display: flex; flex-direction: column; @@ -111,7 +111,7 @@ &:hover { background-color: var(--gray-soft-light); } } } - + & > label { display: flex; align-items: center; @@ -158,4 +158,4 @@ flex-direction: column; } } -} \ No newline at end of file +} diff --git a/config/locales/app.admin.en.yml b/config/locales/app.admin.en.yml index 191a8fc87..a9cd74a8e 100644 --- a/config/locales/app.admin.en.yml +++ b/config/locales/app.admin.en.yml @@ -1933,6 +1933,22 @@ en: save: "Save" required: "This field is required" slug_pattern: "Only lowercase alphanumeric groups of characters separated by an hyphen" + categories_filter: + filter_categories: "By categories" + filter_apply: "Apply" + machines_filter: + filter_machines: "By machines" + filter_apply: "Apply" + keyword_filter: + filter_keywords_reference: "By keywords or reference" + filter_apply: "Apply" + stock_filter: + stock_internal: "Private stock" + stock_external: "Public stock" + filter_stock: "By stock status" + filter_stock_from: "From" + filter_stock_to: "to" + filter_apply: "Apply" products: unexpected_error_occurred: "An unexpected error occurred. Please try again later." all_products: "All products" @@ -1941,15 +1957,6 @@ en: unable_to_delete: "Unable to delete the product: " filter: "Filter" filter_clear: "Clear all" - filter_apply: "Apply" - filter_categories: "By categories" - filter_machines: "By machines" - filter_keywords_reference: "By keywords or reference" - filter_stock: "By stock status" - stock_internal: "Private stock" - stock_external: "Public stock" - filter_stock_from: "From" - filter_stock_to: "to" sort: name_az: "A-Z" name_za: "Z-A" From a41e5a93e5592e23729887ada418330e9f6299a8 Mon Sep 17 00:00:00 2001 From: Sylvain Date: Tue, 20 Sep 2022 15:30:44 +0200 Subject: [PATCH 08/10] (feat) products filtering for admin view --- .rubocop.yml | 2 +- app/controllers/api/products_controller.rb | 1 - app/frontend/src/javascript/api/product.ts | 3 +- .../store/filters/active-filters-tags.tsx | 45 +++++ .../store/filters/categories-filter.tsx | 43 +---- .../store/filters/keyword-filter.tsx | 12 +- .../store/filters/machines-filter.tsx | 7 +- .../components/store/filters/stock-filter.tsx | 16 +- .../javascript/components/store/products.tsx | 150 ++++++++-------- app/frontend/src/javascript/lib/product.ts | 56 +++++- app/frontend/src/javascript/models/api.ts | 11 +- app/frontend/src/javascript/models/order.ts | 11 +- app/frontend/src/javascript/models/product.ts | 24 ++- app/models/machine.rb | 15 +- app/models/machines_product.rb | 7 + app/models/product.rb | 3 +- app/models/projects_machine.rb | 7 + app/models/trainings_machine.rb | 7 + app/services/checkout/payment_service.rb | 10 +- app/services/orders/order_service.rb | 161 +++++++++++------- app/services/product_service.rb | 63 +++++-- app/views/api/orders/index.json.jbuilder | 5 +- app/views/api/products/index.json.jbuilder | 4 +- config/locales/app.shared.en.yml | 3 + ...0220920131912_add_index_on_product_slug.rb | 8 + db/schema.rb | 23 +-- 26 files changed, 425 insertions(+), 272 deletions(-) create mode 100644 app/frontend/src/javascript/components/store/filters/active-filters-tags.tsx create mode 100644 app/models/machines_product.rb create mode 100644 app/models/projects_machine.rb create mode 100644 app/models/trainings_machine.rb create mode 100644 db/migrate/20220920131912_add_index_on_product_slug.rb diff --git a/.rubocop.yml b/.rubocop.yml index f423c323f..987794c3d 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -9,7 +9,7 @@ Metrics/MethodLength: Metrics/CyclomaticComplexity: Max: 13 Metrics/PerceivedComplexity: - Max: 11 + Max: 12 Metrics/AbcSize: Max: 45 Metrics/ClassLength: diff --git a/app/controllers/api/products_controller.rb b/app/controllers/api/products_controller.rb index a77d06e67..8068c6f82 100644 --- a/app/controllers/api/products_controller.rb +++ b/app/controllers/api/products_controller.rb @@ -10,7 +10,6 @@ class API::ProductsController < API::ApiController def index @products = ProductService.list(params) - @pages = ProductService.pages(params) if params[:page].present? end def show diff --git a/app/frontend/src/javascript/api/product.ts b/app/frontend/src/javascript/api/product.ts index d12167b45..f760e1a29 100644 --- a/app/frontend/src/javascript/api/product.ts +++ b/app/frontend/src/javascript/api/product.ts @@ -3,10 +3,11 @@ import { AxiosResponse } from 'axios'; import { serialize } from 'object-to-formdata'; import { Product, ProductIndexFilter, ProductsIndex, ProductStockMovement } from '../models/product'; import ApiLib from '../lib/api'; +import ProductLib from '../lib/product'; export default class ProductAPI { static async index (filters?: ProductIndexFilter): Promise { - const res: AxiosResponse = await apiClient.get(`/api/products${ApiLib.filtersToQuery(filters)}`); + const res: AxiosResponse = await apiClient.get(`/api/products${ApiLib.filtersToQuery(ProductLib.indexFiltersToIds(filters))}`); return res?.data; } diff --git a/app/frontend/src/javascript/components/store/filters/active-filters-tags.tsx b/app/frontend/src/javascript/components/store/filters/active-filters-tags.tsx new file mode 100644 index 000000000..702ad3203 --- /dev/null +++ b/app/frontend/src/javascript/components/store/filters/active-filters-tags.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { ProductIndexFilter } from '../../../models/product'; +import { X } from 'phosphor-react'; +import { ProductCategory } from '../../../models/product-category'; +import { Machine } from '../../../models/machine'; +import { useTranslation } from 'react-i18next'; + +interface ActiveFiltersTagsProps { + filters: ProductIndexFilter, + onRemoveCategory: (category: ProductCategory) => void, + onRemoveMachine: (machine: Machine) => void, + onRemoveKeyword: () => void, + onRemoveStock: () => void, +} + +/** + * Some tags listing the currently actives filters for a product list + */ +export const ActiveFiltersTags: React.FC = ({ filters, onRemoveCategory, onRemoveMachine, onRemoveKeyword, onRemoveStock }) => { + const { t } = useTranslation('shared'); + return ( + <> + {filters.categories.map(c => ( +
+

{c.name}

+ +
+ ))} + {filters.machines.map(m => ( +
+

{m.name}

+ +
+ ))} + {filters.keywords[0] &&
+

{filters.keywords[0]}

+ +
} + {(filters.stock_to !== 0 || filters.stock_from !== 0) &&
+

{t(`app.shared.active_filters_tags.stock_${filters.stock_type}`)} [{filters.stock_from || '…'} ⟶ {filters.stock_to || '…'}]

+ +
} + + ); +}; diff --git a/app/frontend/src/javascript/components/store/filters/categories-filter.tsx b/app/frontend/src/javascript/components/store/filters/categories-filter.tsx index a72017e35..967ae51c0 100644 --- a/app/frontend/src/javascript/components/store/filters/categories-filter.tsx +++ b/app/frontend/src/javascript/components/store/filters/categories-filter.tsx @@ -1,6 +1,5 @@ import React, { useEffect, useState } from 'react'; import _ from 'lodash'; -import ProductCategoryAPI from '../../../api/product-category'; import ProductLib from '../../../lib/product'; import { ProductCategory } from '../../../models/product-category'; import { FabButton } from '../../base/fab-button'; @@ -8,7 +7,7 @@ import { AccordionItem } from '../../base/accordion-item'; import { useTranslation } from 'react-i18next'; interface CategoriesFilterProps { - onError: (message: string) => void, + productCategories: Array, onApplyFilters: (categories: Array) => void, currentFilters: Array, openDefault?: boolean, @@ -18,19 +17,12 @@ interface CategoriesFilterProps { /** * Component to filter the products list by categories */ -export const CategoriesFilter: React.FC = ({ onError, onApplyFilters, currentFilters, openDefault = false, instantUpdate = false }) => { +export const CategoriesFilter: React.FC = ({ productCategories, onApplyFilters, currentFilters, openDefault = false, instantUpdate = false }) => { const { t } = useTranslation('admin'); - const [productCategories, setProductCategories] = useState([]); const [openedAccordion, setOpenedAccordion] = useState(openDefault); const [selectedCategories, setSelectedCategories] = useState(currentFilters || []); - useEffect(() => { - ProductCategoryAPI.index().then(data => { - setProductCategories(ProductLib.sortCategories(data)); - }).catch(onError); - }, []); - useEffect(() => { if (currentFilters && !_.isEqual(currentFilters, selectedCategories)) { setSelectedCategories(currentFilters); @@ -49,36 +41,7 @@ export const CategoriesFilter: React.FC = ({ onError, onA * This may cause other categories to be selected or unselected accordingly. */ const handleSelectCategory = (currentCategory: ProductCategory, checked: boolean) => { - let list = [...selectedCategories]; - const children = productCategories - .filter(el => el.parent_id === currentCategory.id); - const siblings = productCategories - .filter(el => el.parent_id === currentCategory.parent_id && el.parent_id !== null); - - if (checked) { - list.push(currentCategory); - if (children.length) { - // if a parent category is selected, we automatically select all its children - list = [...Array.from(new Set([...list, ...children]))]; - } - if (siblings.length && siblings.every(el => list.includes(el))) { - // if a child category is selected, with every sibling of it, we automatically select its parent - list.push(productCategories.find(p => p.id === siblings[0].parent_id)); - } - } else { - list.splice(list.indexOf(currentCategory), 1); - const parent = productCategories.find(p => p.id === currentCategory.parent_id); - if (currentCategory.parent_id && list.includes(parent)) { - // if a child category is unselected, we unselect its parent - list.splice(list.indexOf(parent), 1); - } - if (children.length) { - // if a parent category is unselected, we unselect all its children - children.forEach(child => { - list.splice(list.indexOf(child), 1); - }); - } - } + const list = ProductLib.categoriesSelectionTree(productCategories, selectedCategories, currentCategory, checked ? 'add' : 'remove'); setSelectedCategories(list); if (instantUpdate) { diff --git a/app/frontend/src/javascript/components/store/filters/keyword-filter.tsx b/app/frontend/src/javascript/components/store/filters/keyword-filter.tsx index 63d25daed..87683933d 100644 --- a/app/frontend/src/javascript/components/store/filters/keyword-filter.tsx +++ b/app/frontend/src/javascript/components/store/filters/keyword-filter.tsx @@ -6,7 +6,7 @@ import _ from 'lodash'; interface KeywordFilterProps { onApplyFilters: (keywork: string) => void, - currentFilters: string, + currentFilters?: string, openDefault?: boolean, instantUpdate?: boolean, } @@ -14,14 +14,14 @@ interface KeywordFilterProps { /** * Component to filter the products list by keyword or product reference */ -export const KeywordFilter: React.FC = ({ onApplyFilters, currentFilters, openDefault = false, instantUpdate = false }) => { +export const KeywordFilter: React.FC = ({ onApplyFilters, currentFilters = '', openDefault = false, instantUpdate = false }) => { const { t } = useTranslation('admin'); const [openedAccordion, setOpenedAccordion] = useState(openDefault); - const [keyword, setKeyword] = useState(currentFilters); + const [keyword, setKeyword] = useState(currentFilters || ''); useEffect(() => { - if (currentFilters && !_.isEqual(currentFilters, keyword)) { + if (!_.isEqual(currentFilters, keyword)) { setKeyword(currentFilters); } }, [currentFilters]); @@ -53,8 +53,8 @@ export const KeywordFilter: React.FC = ({ onApplyFilters, cu >
- handleKeywordTyping(event)} /> - onApplyFilters(keyword)} className="is-info">{t('app.admin.store.keyword_filter.filter_apply')} + handleKeywordTyping(event)} value={keyword} /> + onApplyFilters(keyword || undefined)} className="is-info">{t('app.admin.store.keyword_filter.filter_apply')}
diff --git a/app/frontend/src/javascript/components/store/filters/machines-filter.tsx b/app/frontend/src/javascript/components/store/filters/machines-filter.tsx index 2a0d28482..ab4a2bf1f 100644 --- a/app/frontend/src/javascript/components/store/filters/machines-filter.tsx +++ b/app/frontend/src/javascript/components/store/filters/machines-filter.tsx @@ -47,7 +47,7 @@ export const MachinesFilter: React.FC = ({ onError, onApply * Callback triggered when a machine filter is seleced or unselected. */ const handleSelectMachine = (currentMachine: Machine, checked: boolean) => { - const list = [...machines]; + const list = [...selectedMachines]; checked ? list.push(currentMachine) : list.splice(list.indexOf(currentMachine), 1); @@ -63,13 +63,12 @@ export const MachinesFilter: React.FC = ({ onError, onApply + label={t('app.admin.store.machines_filter.filter_machines')}>
{machines.map(m => ( ))} diff --git a/app/frontend/src/javascript/components/store/filters/stock-filter.tsx b/app/frontend/src/javascript/components/store/filters/stock-filter.tsx index 6d7d94283..db210dd43 100644 --- a/app/frontend/src/javascript/components/store/filters/stock-filter.tsx +++ b/app/frontend/src/javascript/components/store/filters/stock-filter.tsx @@ -2,21 +2,15 @@ import React, { useEffect, useState } from 'react'; import { FabButton } from '../../base/fab-button'; import { AccordionItem } from '../../base/accordion-item'; import { useTranslation } from 'react-i18next'; -import { StockType } from '../../../models/product'; +import { ProductIndexFilter, StockType } from '../../../models/product'; import { FormSelect } from '../../form/form-select'; import { FormInput } from '../../form/form-input'; import { useForm } from 'react-hook-form'; import _ from 'lodash'; -export interface StockFilterData { - stock_type: StockType, - stock_from: number, - stock_to: number -} - interface StockFilterProps { - onApplyFilters: (filters: StockFilterData) => void, - currentFilters: StockFilterData, + onApplyFilters: (filters: ProductIndexFilter) => void, + currentFilters: ProductIndexFilter, openDefault?: boolean } @@ -34,7 +28,7 @@ export const StockFilter: React.FC = ({ onApplyFilters, curren const [openedAccordion, setOpenedAccordion] = useState(openDefault); - const { register, control, handleSubmit, getValues, reset } = useForm({ defaultValues: { ...currentFilters } }); + const { register, control, handleSubmit, getValues, reset } = useForm({ defaultValues: { ...currentFilters } }); useEffect(() => { if (currentFilters && !_.isEqual(currentFilters, getValues())) { @@ -52,7 +46,7 @@ export const StockFilter: React.FC = ({ onApplyFilters, curren /** * Callback triggered when the user clicks on "apply" to apply teh current filters. */ - const onSubmit = (data: StockFilterData) => { + const onSubmit = (data: ProductIndexFilter) => { onApplyFilters(data); }; diff --git a/app/frontend/src/javascript/components/store/products.tsx b/app/frontend/src/javascript/components/store/products.tsx index b959c8ee2..591dde8fe 100644 --- a/app/frontend/src/javascript/components/store/products.tsx +++ b/app/frontend/src/javascript/components/store/products.tsx @@ -4,19 +4,21 @@ import { useTranslation } from 'react-i18next'; import { react2angular } from 'react2angular'; import { Loader } from '../base/loader'; import { IApplication } from '../../models/application'; -import { Product } from '../../models/product'; +import { Product, ProductIndexFilter, ProductsIndex } from '../../models/product'; import { ProductCategory } from '../../models/product-category'; import { FabButton } from '../base/fab-button'; import { ProductItem } from './product-item'; import ProductAPI from '../../api/product'; -import { X } from 'phosphor-react'; import { StoreListHeader } from './store-list-header'; import { FabPagination } from '../base/fab-pagination'; import { CategoriesFilter } from './filters/categories-filter'; import { Machine } from '../../models/machine'; import { MachinesFilter } from './filters/machines-filter'; import { KeywordFilter } from './filters/keyword-filter'; -import { StockFilter, StockFilterData } from './filters/stock-filter'; +import { StockFilter } from './filters/stock-filter'; +import ProductCategoryAPI from '../../api/product-category'; +import ProductLib from '../../lib/product'; +import { ActiveFiltersTags } from './filters/active-filters-tags'; declare const Application: IApplication; @@ -34,42 +36,57 @@ interface ProductsProps { const Products: React.FC = ({ onSuccess, onError }) => { const { t } = useTranslation('admin'); - const [filteredProductsList, setFilteredProductList] = useImmer>([]); - const [features, setFeatures] = useImmer(initFilters); - const [filterVisible, setFilterVisible] = useState(false); - const [filters, setFilters] = useImmer(initFilters); - const [clearFilters, setClearFilters] = useState(false); - const [update, setUpdate] = useState(false); + const [productCategories, setProductCategories] = useState>([]); + const [productsList, setProductList] = useState>([]); + const [filters, setFilters] = useImmer(initFilters); const [pageCount, setPageCount] = useState(0); const [currentPage, setCurrentPage] = useState(1); + const [productsCount, setProductsCount] = useState(0); useEffect(() => { - ProductAPI.index({ page: 1, is_active: filterVisible }).then(data => { - setPageCount(data.total_pages); - setFilteredProductList(data.products); - }); + fetchProducts().then(scrollToProducts); + ProductCategoryAPI.index().then(data => { + setProductCategories(ProductLib.sortCategories(data)); + }).catch(onError); }, []); useEffect(() => { - applyFilters(); - setClearFilters(false); - setUpdate(false); - }, [filterVisible, clearFilters, update === true]); + fetchProducts().then(scrollToProducts); + }, [filters]); /** Handle products pagination */ const handlePagination = (page: number) => { if (page !== currentPage) { - ProductAPI.index({ page, is_active: filterVisible }).then(data => { - setCurrentPage(page); - setFilteredProductList(data.products); - setPageCount(data.total_pages); - window.document.getElementById('content-main').scrollTo({ top: 100, behavior: 'smooth' }); - }).catch(() => { - onError(t('app.admin.store.products.unexpected_error_occurred')); + setFilters(draft => { + return { ...draft, page }; }); } }; + /** + * Fetch the products from the API, according to the current filters + */ + const fetchProducts = async (): Promise => { + try { + const data = await ProductAPI.index(filters); + setCurrentPage(data.page); + setProductList(data.data); + setPageCount(data.total_pages); + setProductsCount(data.total_count); + return data; + } catch (error) { + onError(t('app.admin.store.products.unexpected_error_occurred')); + console.error(error); + } + }; + + /** + * Scroll the view to the product list + */ + const scrollToProducts = () => { + window.document.getElementById('content-main').scrollTo({ top: 100, behavior: 'smooth' }); + }; + /** Goto edit product page */ const editProduct = (product: Product) => { window.location.href = `/#!/admin/store/products/${product.id}/edit`; @@ -79,8 +96,8 @@ const Products: React.FC = ({ onSuccess, onError }) => { const deleteProduct = async (productId: number): Promise => { try { await ProductAPI.destroy(productId); - const data = await ProductAPI.index(); - setFilteredProductList(data.products); + await fetchProducts(); + scrollToProducts(); onSuccess(t('app.admin.store.products.successfully_deleted')); } catch (e) { onError(t('app.admin.store.products.unable_to_delete') + e); @@ -94,8 +111,9 @@ const Products: React.FC = ({ onSuccess, onError }) => { /** Filter: toggle non-available products visibility */ const toggleVisible = (checked: boolean) => { - setFilterVisible(!filterVisible); - console.log('Display on the shelf product only:', checked); + setFilters(draft => { + return { ...draft, is_active: checked }; + }); }; /** @@ -107,6 +125,14 @@ const Products: React.FC = ({ onSuccess, onError }) => { }); }; + /** + * Remove the provided category from the filters selection + */ + const handleRemoveCategory = (category: ProductCategory) => { + const list = ProductLib.categoriesSelectionTree(productCategories, filters.categories, category, 'remove'); + handleCategoriesFilterUpdate(list); + }; + /** * Update the list of applied filters with the given machines */ @@ -126,7 +152,7 @@ const Products: React.FC = ({ onSuccess, onError }) => { }; /** Filter: by stock range */ - const handleStockFilterUpdate = (filters: StockFilterData) => { + const handleStockFilterUpdate = (filters: ProductIndexFilter) => { setFilters(draft => { return { ...draft, @@ -140,27 +166,9 @@ const Products: React.FC = ({ onSuccess, onError }) => { console.log('Sort option:', option); }; - /** Apply filters */ - const applyFilters = () => { - let tags = initFilters; - - if (filters.categories.length) { - tags = { ...tags, categories: [...filters.categories] }; - } - - if (filters.machines.length) { - tags = { ...tags, machines: [...filters.machines] }; - } - - setFeatures(tags); - console.log('Apply filters:', filters); - }; - /** Clear filters */ const clearAllFilters = () => { setFilters(initFilters); - setClearFilters(true); - console.log('Clear all filters'); }; /** Creates sorting options to the react-select format */ @@ -189,7 +197,7 @@ const Products: React.FC = ({ onSuccess, onError }) => {
- @@ -197,7 +205,7 @@ const Products: React.FC = ({ onSuccess, onError }) => { onApplyFilters={handleMachinesFilterUpdate} currentFilters={filters.machines} /> - handleKeywordFilterUpdate([...filters.keywords, keyword])} + handleKeywordFilterUpdate([keyword])} currentFilters={filters.keywords[0]} /> = ({ onSuccess, onError }) => {
- {features.categories.map(c => ( -
-

{c.name}

- -
- ))} - {features.machines.map(m => ( -
-

{m.name}

- -
- ))} - {features.keywords.map(k => ( -
-

{k}

- -
- ))} + handleMachinesFilterUpdate(filters.machines.filter(machine => machine !== m))} + onRemoveKeyword={() => handleKeywordFilterUpdate([])} + onRemoveStock={() => handleStockFilterUpdate({ stock_type: 'internal', stock_to: 0, stock_from: 0 })} />
- {filteredProductsList.map((product) => ( + {productsList.map((product) => ( = ({ onSuccess, onError }) => { Application.Components.component('products', react2angular(ProductsWrapper, ['onSuccess', 'onError'])); -interface Filters { - categories: ProductCategory[], - machines: Machine[], - keywords: string[], - stock_type: 'internal' | 'external', - stock_from: number, - stock_to: number -} - -const initFilters: Filters = { +const initFilters: ProductIndexFilter = { categories: [], machines: [], keywords: [], stock_type: 'internal', stock_from: 0, - stock_to: 0 + stock_to: 0, + is_active: false, + page: 1 }; diff --git a/app/frontend/src/javascript/lib/product.ts b/app/frontend/src/javascript/lib/product.ts index af377fede..56fdfce38 100644 --- a/app/frontend/src/javascript/lib/product.ts +++ b/app/frontend/src/javascript/lib/product.ts @@ -1,5 +1,10 @@ import { ProductCategory } from '../models/product-category'; -import { stockMovementInReasons, stockMovementOutReasons, StockMovementReason } from '../models/product'; +import { + ProductIndexFilter, ProductIndexFilterIds, + stockMovementInReasons, + stockMovementOutReasons, + StockMovementReason +} from '../models/product'; export default class ProductLib { /** @@ -48,4 +53,53 @@ export default class ProductLib { return `-${quantity}`; } }; + + /** + * Add or remove the given category from the given list; + * This may cause parent/children categories to be selected or unselected accordingly. + */ + static categoriesSelectionTree = (allCategories: Array, currentSelection: Array, category: ProductCategory, operation: 'add'|'remove'): Array => { + let list = [...currentSelection]; + const children = allCategories + .filter(el => el.parent_id === category.id); + const siblings = allCategories + .filter(el => el.parent_id === category.parent_id && el.parent_id !== null); + + if (operation === 'add') { + list.push(category); + if (children.length) { + // if a parent category is selected, we automatically select all its children + list = [...Array.from(new Set([...list, ...children]))]; + } + if (siblings.length && siblings.every(el => list.includes(el))) { + // if a child category is selected, with every sibling of it, we automatically select its parent + list.push(allCategories.find(p => p.id === siblings[0].parent_id)); + } + } else { + list.splice(list.indexOf(category), 1); + const parent = allCategories.find(p => p.id === category.parent_id); + if (category.parent_id && list.includes(parent)) { + // if a child category is unselected, we unselect its parent + list.splice(list.indexOf(parent), 1); + } + if (children.length) { + // if a parent category is unselected, we unselect all its children + children.forEach(child => { + list.splice(list.indexOf(child), 1); + }); + } + } + return list; + }; + + /** + * Extract the IDS from the filters to pass them to the API + */ + static indexFiltersToIds = (filters: ProductIndexFilter): ProductIndexFilterIds => { + return { + ...filters, + categories: filters.categories.map(c => c.id), + machines: filters.machines.map(m => m.id) + }; + }; } diff --git a/app/frontend/src/javascript/models/api.ts b/app/frontend/src/javascript/models/api.ts index b1fbafd1d..6f531f19d 100644 --- a/app/frontend/src/javascript/models/api.ts +++ b/app/frontend/src/javascript/models/api.ts @@ -1,3 +1,10 @@ // ApiFilter should be extended by an interface listing all the filters allowed for a given API -// eslint-disable-next-line @typescript-eslint/ban-types -export type ApiFilter = {}; +export type ApiFilter = Record; + +export interface PaginatedIndex { + page: number, + total_pages: number, + page_size: number, + total_count: number, + data: Array +} diff --git a/app/frontend/src/javascript/models/order.ts b/app/frontend/src/javascript/models/order.ts index cf986e86a..e1d2fd5e0 100644 --- a/app/frontend/src/javascript/models/order.ts +++ b/app/frontend/src/javascript/models/order.ts @@ -3,6 +3,7 @@ import { PaymentConfirmation } from './payment'; import { CreateTokenResponse } from './payzen'; import { UserRole } from './user'; import { Coupon } from './coupon'; +import { ApiFilter, PaginatedIndex } from './api'; export interface Order { id: number, @@ -45,15 +46,9 @@ export interface OrderPayment { payment?: PaymentConfirmation|CreateTokenResponse } -export interface OrderIndex { - page: number, - total_pages: number, - page_size: number, - total_count: number, - data: Array -} +export type OrderIndex = PaginatedIndex; -export interface OrderIndexFilter { +export interface OrderIndexFilter extends ApiFilter { reference?: string, user_id?: number, user?: { diff --git a/app/frontend/src/javascript/models/product.ts b/app/frontend/src/javascript/models/product.ts index 757918964..62bcf6d27 100644 --- a/app/frontend/src/javascript/models/product.ts +++ b/app/frontend/src/javascript/models/product.ts @@ -1,9 +1,22 @@ import { TDateISO } from '../typings/date-iso'; -import { ApiFilter } from './api'; +import { ApiFilter, PaginatedIndex } from './api'; +import { ProductCategory } from './product-category'; +import { Machine } from './machine'; -export interface ProductIndexFilter extends ApiFilter { +export interface ProductIndexFilter { is_active?: boolean, - page?: number + page?: number, + categories?: ProductCategory[], + machines?: Machine[], + keywords?: string[], + stock_type?: 'internal' | 'external', + stock_from?: number, + stock_to?: number, +} + +export interface ProductIndexFilterIds extends Omit, 'machines'>, ApiFilter { + categories?: Array, + machines?: Array, } export type StockType = 'internal' | 'external' | 'all'; @@ -19,10 +32,7 @@ export interface Stock { external: number, } -export interface ProductsIndex { - total_pages?: number, - products: Array -} +export type ProductsIndex = PaginatedIndex; export interface ProductStockMovement { id?: number, diff --git a/app/models/machine.rb b/app/models/machine.rb index da118eddf..661f37256 100644 --- a/app/models/machine.rb +++ b/app/models/machine.rb @@ -10,12 +10,14 @@ class Machine < ApplicationRecord has_many :machine_files, as: :viewable, dependent: :destroy accepts_nested_attributes_for :machine_files, allow_destroy: true, reject_if: :all_blank - has_and_belongs_to_many :projects, join_table: 'projects_machines' + has_many :projects_machines, dependent: :destroy + has_many :projects, through: :projects_machines has_many :machines_availabilities, dependent: :destroy has_many :availabilities, through: :machines_availabilities - has_and_belongs_to_many :trainings, join_table: 'trainings_machines' + has_many :trainings_machines, dependent: :destroy + has_many :trainings, through: :trainings_machines validates :name, presence: true, length: { maximum: 50 } validates :description, presence: true @@ -27,9 +29,10 @@ class Machine < ApplicationRecord has_many :credits, as: :creditable, dependent: :destroy has_many :plans, through: :credits - has_one :payment_gateway_object, as: :item + has_one :payment_gateway_object, as: :item, dependent: :destroy - has_and_belongs_to_many :products + has_many :machines_products, dependent: :destroy + has_many :products, through: :machines_products after_create :create_statistic_subtype after_create :create_machine_prices @@ -66,11 +69,11 @@ class Machine < ApplicationRecord end def create_machine_prices - Group.all.each do |group| + Group.find_each do |group| Price.create(priceable: self, group: group, amount: 0) end - Plan.all.includes(:group).each do |plan| + Plan.includes(:group).find_each do |plan| Price.create(group: plan.group, plan: plan, priceable: self, amount: 0) end end diff --git a/app/models/machines_product.rb b/app/models/machines_product.rb new file mode 100644 index 000000000..893144834 --- /dev/null +++ b/app/models/machines_product.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +# MachinesAvailability is the relation table between a Machine and a Product. +class MachinesProduct < ApplicationRecord + belongs_to :machine + belongs_to :product +end diff --git a/app/models/product.rb b/app/models/product.rb index 37f5d1a04..6b3df19f0 100644 --- a/app/models/product.rb +++ b/app/models/product.rb @@ -8,7 +8,8 @@ class Product < ApplicationRecord belongs_to :product_category - has_and_belongs_to_many :machines + has_many :machines_products, dependent: :destroy + has_many :machines, through: :machines_products has_many :product_files, as: :viewable, dependent: :destroy accepts_nested_attributes_for :product_files, allow_destroy: true, reject_if: :all_blank diff --git a/app/models/projects_machine.rb b/app/models/projects_machine.rb new file mode 100644 index 000000000..5211d9644 --- /dev/null +++ b/app/models/projects_machine.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +# ProjectsMachine is the relation table between a Machine and a Project. +class ProjectsMachine < ApplicationRecord + belongs_to :machine + belongs_to :project +end diff --git a/app/models/trainings_machine.rb b/app/models/trainings_machine.rb new file mode 100644 index 000000000..feb4b0469 --- /dev/null +++ b/app/models/trainings_machine.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +# TrainingsMachine is the relation table between a Machine and a Training. +class TrainingsMachine < ApplicationRecord + belongs_to :machine + belongs_to :training +end diff --git a/app/services/checkout/payment_service.rb b/app/services/checkout/payment_service.rb index 429870e5f..4dcbc218d 100644 --- a/app/services/checkout/payment_service.rb +++ b/app/services/checkout/payment_service.rb @@ -1,19 +1,19 @@ # frozen_string_literal: true -# Provides methods for pay cart +# Provides methods to pay the cart class Checkout::PaymentService require 'pay_zen/helper' require 'stripe/helper' include Payments::PaymentConcern def payment(order, operator, coupon_code, payment_id = '') - raise Cart::InactiveProductError unless Orders::OrderService.new.all_products_is_active?(order) + raise Cart::InactiveProductError unless Orders::OrderService.all_products_is_active?(order) - raise Cart::OutStockError unless Orders::OrderService.new.in_stock?(order, 'external') + raise Cart::OutStockError unless Orders::OrderService.in_stock?(order, 'external') - raise Cart::QuantityMinError unless Orders::OrderService.new.greater_than_quantity_min?(order) + raise Cart::QuantityMinError unless Orders::OrderService.greater_than_quantity_min?(order) - raise Cart::ItemAmountError unless Orders::OrderService.new.item_amount_not_equal?(order) + raise Cart::ItemAmountError unless Orders::OrderService.item_amount_not_equal?(order) CouponService.new.validate(coupon_code, order.statistic_profile.user.id) diff --git a/app/services/orders/order_service.rb b/app/services/orders/order_service.rb index 4b1209131..b5576f0d0 100644 --- a/app/services/orders/order_service.rb +++ b/app/services/orders/order_service.rb @@ -2,80 +2,109 @@ # Provides methods for Order class Orders::OrderService - ORDERS_PER_PAGE = 20 + class << self + ORDERS_PER_PAGE = 20 - def self.list(filters, current_user) - orders = Order.where(nil) - if filters[:user_id] - statistic_profile_id = current_user.statistic_profile.id - if (current_user.member? && current_user.id == filters[:user_id].to_i) || current_user.privileged? - user = User.find(filters[:user_id]) - statistic_profile_id = user.statistic_profile.id + def list(filters, current_user) + orders = Order.where(nil) + orders = filter_by_user(orders, filters, current_user) + orders = filter_by_reference(orders, filters, current_user) + orders = filter_by_state(orders, filters) + orders = filter_by_period(orders, filters) + + orders = orders.where.not(state: 'cart') if current_user.member? + orders = orders.order(created_at: filters[:sort] || 'DESC') + total_count = orders.count + orders = orders.page(filters[:page] || 1).per(ORDERS_PER_PAGE) + { + data: orders, + page: filters[:page]&.to_i || 1, + total_pages: orders.page(1).per(ORDERS_PER_PAGE).total_pages, + page_size: ORDERS_PER_PAGE, + total_count: total_count + } + end + + def update_state(order, current_user, state, note = nil) + case state + when 'in_progress' + ::Orders::SetInProgressService.new.call(order, current_user) + when 'ready' + ::Orders::OrderReadyService.new.call(order, current_user, note) + when 'canceled' + ::Orders::OrderCanceledService.new.call(order, current_user) + when 'delivered' + ::Orders::OrderDeliveredService.new.call(order, current_user) + when 'refunded' + ::Orders::OrderRefundedService.new.call(order, current_user) + else + nil end - orders = orders.where(statistic_profile_id: statistic_profile_id) - elsif current_user.member? - orders = orders.where(statistic_profile_id: current_user.statistic_profile.id) - else - orders = orders.where.not(statistic_profile_id: nil) end - orders = orders.where(reference: filters[:reference]) if filters[:reference].present? && current_user.privileged? + def in_stock?(order, stock_type = 'external') + order.order_items.each do |item| + return false if item.orderable.stock[stock_type] < item.quantity + end + true + end + + def greater_than_quantity_min?(order) + order.order_items.each do |item| + return false if item.quantity < item.orderable.quantity_min + end + true + end + + def item_amount_not_equal?(order) + order.order_items.each do |item| + return false if item.amount != item.orderable.amount + end + true + end + + def all_products_is_active?(order) + order.order_items.each do |item| + return false unless item.orderable.is_active + end + true + end + + private + + def filter_by_user(orders, filters, current_user) + if filters[:user_id] + statistic_profile_id = current_user.statistic_profile.id + if (current_user.member? && current_user.id == filters[:user_id].to_i) || current_user.privileged? + user = User.find(filters[:user_id]) + statistic_profile_id = user.statistic_profile.id + end + orders = orders.where(statistic_profile_id: statistic_profile_id) + elsif current_user.member? + orders = orders.where(statistic_profile_id: current_user.statistic_profile.id) + else + orders = orders.where.not(statistic_profile_id: nil) + end + orders + end + + def filter_by_reference(orders, filters, current_user) + return orders unless filters[:reference].present? && current_user.privileged? + + orders.where(reference: filters[:reference]) + end + + def filter_by_state(orders, filters) + return orders if filters[:states].blank? - if filters[:states].present? state = filters[:states].split(',') - orders = orders.where(state: state) unless state.empty? + orders.where(state: state) unless state.empty? end - if filters[:period_from].present? && filters[:period_to].present? - orders = orders.where(created_at: DateTime.parse(filters[:period_from])..DateTime.parse(filters[:period_to]).end_of_day) + def filter_by_period(orders, filters) + return orders unless filters[:period_from].present? && filters[:period_to].present? + + orders.where(created_at: DateTime.parse(filters[:period_from])..DateTime.parse(filters[:period_to]).end_of_day) end - - orders = orders.where.not(state: 'cart') if current_user.member? - orders = orders.order(created_at: filters[:sort] || 'DESC') - total_count = orders.count - orders = orders.page(filters[:page] || 1).per(ORDERS_PER_PAGE) - { - data: orders, - page: filters[:page] || 1, - total_pages: orders.page(1).per(ORDERS_PER_PAGE).total_pages, - page_size: ORDERS_PER_PAGE, - total_count: total_count - } - end - - def self.update_state(order, current_user, state, note = nil) - return ::Orders::SetInProgressService.new.call(order, current_user) if state == 'in_progress' - return ::Orders::OrderReadyService.new.call(order, current_user, note) if state == 'ready' - return ::Orders::OrderCanceledService.new.call(order, current_user) if state == 'canceled' - return ::Orders::OrderDeliveredService.new.call(order, current_user) if state == 'delivered' - return ::Orders::OrderRefundedService.new.call(order, current_user) if state == 'refunded' - end - - def in_stock?(order, stock_type = 'external') - order.order_items.each do |item| - return false if item.orderable.stock[stock_type] < item.quantity - end - true - end - - def greater_than_quantity_min?(order) - order.order_items.each do |item| - return false if item.quantity < item.orderable.quantity_min - end - true - end - - def item_amount_not_equal?(order) - order.order_items.each do |item| - return false if item.amount != item.orderable.amount - end - true - end - - def all_products_is_active?(order) - order.order_items.each do |item| - return false unless item.orderable.is_active - end - true end end diff --git a/app/services/product_service.rb b/app/services/product_service.rb index ff7f8432e..70719fb93 100644 --- a/app/services/product_service.rb +++ b/app/services/product_service.rb @@ -7,21 +7,21 @@ class ProductService def list(filters) products = Product.includes(:product_images) - if filters[:is_active].present? - state = filters[:disabled] == 'false' ? [nil, false] : true - products = products.where(is_active: state) - end - products = products.page(filters[:page]).per(PRODUCTS_PER_PAGE) if filters[:page].present? - products - end + products = filter_by_active(products, filters) + products = filter_by_categories(products, filters) + products = filter_by_machines(products, filters) + products = filter_by_keyword_or_reference(products, filters) + products = filter_by_stock(products, filters) - def pages(filters) - products = Product.all - if filters[:is_active].present? - state = filters[:disabled] == 'false' ? [nil, false] : true - products = Product.where(is_active: state) - end - products.page(1).per(PRODUCTS_PER_PAGE).total_pages + total_count = products.count + products = products.page(filters[:page] || 1).per(PRODUCTS_PER_PAGE) + { + data: products, + page: filters[:page]&.to_i || 1, + total_pages: products.page(1).per(PRODUCTS_PER_PAGE).total_pages, + page_size: PRODUCTS_PER_PAGE, + total_count: total_count + } end # amount params multiplied by hundred @@ -80,5 +80,40 @@ class ProductService product.destroy end end + + private + + def filter_by_active(products, filters) + return products if filters[:is_active].blank? + + state = filters[:is_active] == 'false' ? [nil, false, true] : true + products.where(is_active: state) + end + + def filter_by_categories(products, filters) + return products if filters[:categories].blank? + + products.where(product_category_id: filters[:categories].split(',')) + end + + def filter_by_machines(products, filters) + return products if filters[:machines].blank? + + products.includes(:machines_products).where('machines_products.machine_id': filters[:machines].split(',')) + end + + def filter_by_keyword_or_reference(products, filters) + return products if filters[:keywords].blank? + + products.where('sku = :sku OR name ILIKE :query OR description ILIKE :query', + { sku: filters[:keywords], query: "%#{filters[:keywords]}%" }) + end + + def filter_by_stock(products, filters) + products.where("(stock ->> '#{filters[:stock_type]}')::int >= #{filters[:stock_from]}") if filters[:stock_from].to_i.positive? + products.where("(stock ->> '#{filters[:stock_type]}')::int <= #{filters[:stock_to]}") if filters[:stock_to].to_i.positive? + + products + end end end diff --git a/app/views/api/orders/index.json.jbuilder b/app/views/api/orders/index.json.jbuilder index 6bc854380..5f1912cfb 100644 --- a/app/views/api/orders/index.json.jbuilder +++ b/app/views/api/orders/index.json.jbuilder @@ -1,9 +1,6 @@ # frozen_string_literal: true -json.page @result[:page] -json.total_pages @result[:total_pages] -json.page_size @result[:page_size] -json.total_count @result[:total_count] +json.extract! @result, :page, :total_pages, :page_size, :total_count json.data @result[:data] do |order| json.extract! order, :id, :statistic_profile_id, :reference, :state, :created_at, :updated_at json.total order.total / 100.0 if order.total.present? diff --git a/app/views/api/products/index.json.jbuilder b/app/views/api/products/index.json.jbuilder index 0573e95be..33c73329e 100644 --- a/app/views/api/products/index.json.jbuilder +++ b/app/views/api/products/index.json.jbuilder @@ -1,7 +1,7 @@ # frozen_string_literal: true -json.total_pages @pages if @pages.present? -json.products @products do |product| +json.extract! @products, :page, :total_pages, :page_size, :total_count +json.data @products[:data] do |product| json.extract! product, :id, :name, :slug, :sku, :is_active, :product_category_id, :quantity_min, :stock, :machine_ids, :low_stock_threshold json.amount product.amount / 100.0 if product.amount.present? diff --git a/config/locales/app.shared.en.yml b/config/locales/app.shared.en.yml index bfa293431..7d628ae05 100644 --- a/config/locales/app.shared.en.yml +++ b/config/locales/app.shared.en.yml @@ -636,3 +636,6 @@ en: modal_title: "You have some unsaved changes" confirmation_message: "If you leave this page, your changes will be lost. Are you sure you want to continue?" confirmation_button: "Yes, don't save" + active_filters_tags: + stock_internal: "Private stock" + stock_external: "Public stock" diff --git a/db/migrate/20220920131912_add_index_on_product_slug.rb b/db/migrate/20220920131912_add_index_on_product_slug.rb new file mode 100644 index 000000000..4924b4098 --- /dev/null +++ b/db/migrate/20220920131912_add_index_on_product_slug.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# Products' slugs should validate uniqness in database +class AddIndexOnProductSlug < ActiveRecord::Migration[5.2] + def change + add_index :products, :slug, unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 6aaa272cb..62fa5125d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2022_09_15_133100) do +ActiveRecord::Schema.define(version: 2022_09_20_131912) do # These are extensions that must be enabled in order to support this database enable_extension "fuzzystrmatch" @@ -19,8 +19,8 @@ ActiveRecord::Schema.define(version: 2022_09_15_133100) do enable_extension "unaccent" create_table "abuses", id: :serial, force: :cascade do |t| - t.string "signaled_type" t.integer "signaled_id" + t.string "signaled_type" t.string "first_name" t.string "last_name" t.string "email" @@ -49,8 +49,8 @@ ActiveRecord::Schema.define(version: 2022_09_15_133100) do t.string "locality" t.string "country" t.string "postal_code" - t.string "placeable_type" t.integer "placeable_id" + t.string "placeable_type" t.datetime "created_at" t.datetime "updated_at" end @@ -64,8 +64,8 @@ ActiveRecord::Schema.define(version: 2022_09_15_133100) do end create_table "assets", id: :serial, force: :cascade do |t| - t.string "viewable_type" t.integer "viewable_id" + t.string "viewable_type" t.string "attachment" t.string "type" t.datetime "created_at" @@ -147,8 +147,8 @@ ActiveRecord::Schema.define(version: 2022_09_15_133100) do end create_table "credits", id: :serial, force: :cascade do |t| - t.string "creditable_type" t.integer "creditable_id" + t.string "creditable_type" t.integer "plan_id" t.integer "hours" t.datetime "created_at" @@ -375,15 +375,15 @@ ActiveRecord::Schema.define(version: 2022_09_15_133100) do create_table "notifications", id: :serial, force: :cascade do |t| t.integer "receiver_id" - t.string "attached_object_type" t.integer "attached_object_id" + t.string "attached_object_type" t.integer "notification_type_id" t.boolean "is_read", default: false t.datetime "created_at" t.datetime "updated_at" t.string "receiver_type" t.boolean "is_send", default: false - t.jsonb "meta_data", default: "{}" + t.jsonb "meta_data", default: {} t.index ["notification_type_id"], name: "index_notifications_on_notification_type_id" t.index ["receiver_id"], name: "index_notifications_on_receiver_id" end @@ -623,8 +623,8 @@ ActiveRecord::Schema.define(version: 2022_09_15_133100) do create_table "prices", id: :serial, force: :cascade do |t| t.integer "group_id" t.integer "plan_id" - t.string "priceable_type" t.integer "priceable_id" + t.string "priceable_type" t.integer "amount" t.datetime "created_at", null: false t.datetime "updated_at", null: false @@ -672,6 +672,7 @@ ActiveRecord::Schema.define(version: 2022_09_15_133100) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["product_category_id"], name: "index_products_on_product_category_id" + t.index ["slug"], name: "index_products_on_slug", unique: true end create_table "profile_custom_fields", force: :cascade do |t| @@ -822,8 +823,8 @@ ActiveRecord::Schema.define(version: 2022_09_15_133100) do t.text "message" t.datetime "created_at" t.datetime "updated_at" - t.string "reservable_type" t.integer "reservable_id" + t.string "reservable_type" t.integer "nb_reserve_places" t.integer "statistic_profile_id" t.index ["reservable_type", "reservable_id"], name: "index_reservations_on_reservable_type_and_reservable_id" @@ -832,8 +833,8 @@ ActiveRecord::Schema.define(version: 2022_09_15_133100) do create_table "roles", id: :serial, force: :cascade do |t| t.string "name" - t.string "resource_type" t.integer "resource_id" + t.string "resource_type" t.datetime "created_at" t.datetime "updated_at" t.index ["name", "resource_type", "resource_id"], name: "index_roles_on_name_and_resource_type_and_resource_id" @@ -1113,8 +1114,8 @@ ActiveRecord::Schema.define(version: 2022_09_15_133100) do t.boolean "is_allow_newsletter" t.inet "current_sign_in_ip" t.inet "last_sign_in_ip" - t.datetime "validated_at" t.string "mapped_from_sso" + t.datetime "validated_at" t.index ["auth_token"], name: "index_users_on_auth_token" t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true t.index ["email"], name: "index_users_on_email", unique: true From 117c9bb1ddb2ab807cbc919d0042970a9146cded Mon Sep 17 00:00:00 2001 From: Sylvain Date: Tue, 20 Sep 2022 15:47:15 +0200 Subject: [PATCH 09/10] (feat) products list ordering --- .../javascript/components/store/products.tsx | 22 ++++++++++++------- .../components/store/store-list-header.tsx | 5 +++-- app/frontend/src/javascript/models/product.ts | 3 +++ app/services/product_service.rb | 9 ++++++++ 4 files changed, 29 insertions(+), 10 deletions(-) diff --git a/app/frontend/src/javascript/components/store/products.tsx b/app/frontend/src/javascript/components/store/products.tsx index 591dde8fe..e5a233cdf 100644 --- a/app/frontend/src/javascript/components/store/products.tsx +++ b/app/frontend/src/javascript/components/store/products.tsx @@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'; import { react2angular } from 'react2angular'; import { Loader } from '../base/loader'; import { IApplication } from '../../models/application'; -import { Product, ProductIndexFilter, ProductsIndex } from '../../models/product'; +import { Product, ProductIndexFilter, ProductsIndex, ProductSortOption } from '../../models/product'; import { ProductCategory } from '../../models/product-category'; import { FabButton } from '../base/fab-button'; import { ProductItem } from './product-item'; @@ -30,7 +30,7 @@ interface ProductsProps { * Option format, expected by react-select * @see https://github.com/JedWatson/react-select */ - type selectOption = { value: number, label: string }; + type selectOption = { value: ProductSortOption, label: string }; /** This component shows the admin view of the store */ const Products: React.FC = ({ onSuccess, onError }) => { @@ -163,7 +163,12 @@ const Products: React.FC = ({ onSuccess, onError }) => { /** Display option: sorting */ const handleSorting = (option: selectOption) => { - console.log('Sort option:', option); + setFilters(draft => { + return { + ...draft, + sort: option.value + }; + }); }; /** Clear filters */ @@ -174,10 +179,10 @@ const Products: React.FC = ({ onSuccess, onError }) => { /** Creates sorting options to the react-select format */ const buildSortOptions = (): Array => { return [ - { value: 0, label: t('app.admin.store.products.sort.name_az') }, - { value: 1, label: t('app.admin.store.products.sort.name_za') }, - { value: 2, label: t('app.admin.store.products.sort.price_low') }, - { value: 3, label: t('app.admin.store.products.sort.price_high') } + { value: 'name-asc', label: t('app.admin.store.products.sort.name_az') }, + { value: 'name-desc', label: t('app.admin.store.products.sort.name_za') }, + { value: 'amount-asc', label: t('app.admin.store.products.sort.price_low') }, + { value: 'amount-desc', label: t('app.admin.store.products.sort.price_high') } ]; }; @@ -264,5 +269,6 @@ const initFilters: ProductIndexFilter = { stock_from: 0, stock_to: 0, is_active: false, - page: 1 + page: 1, + sort: '' }; diff --git a/app/frontend/src/javascript/components/store/store-list-header.tsx b/app/frontend/src/javascript/components/store/store-list-header.tsx index d69e78d5f..484306f98 100644 --- a/app/frontend/src/javascript/components/store/store-list-header.tsx +++ b/app/frontend/src/javascript/components/store/store-list-header.tsx @@ -2,12 +2,13 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import Select from 'react-select'; import Switch from 'react-switch'; +import { ProductSortOption } from '../../models/product'; interface StoreListHeaderProps { productsCount: number, selectOptions: selectOption[], onSelectOptionsChange: (option: selectOption) => void, - selectValue?: number, + selectValue?: ProductSortOption, switchLabel?: string, switchChecked?: boolean, onSwitch?: (boolean) => void @@ -16,7 +17,7 @@ interface StoreListHeaderProps { * Option format, expected by react-select * @see https://github.com/JedWatson/react-select */ - type selectOption = { value: number, label: string }; + type selectOption = { value: ProductSortOption, label: string }; /** * Renders an accordion item diff --git a/app/frontend/src/javascript/models/product.ts b/app/frontend/src/javascript/models/product.ts index 62bcf6d27..33eda5da7 100644 --- a/app/frontend/src/javascript/models/product.ts +++ b/app/frontend/src/javascript/models/product.ts @@ -3,6 +3,8 @@ import { ApiFilter, PaginatedIndex } from './api'; import { ProductCategory } from './product-category'; import { Machine } from './machine'; +export type ProductSortOption = 'name-asc' | 'name-desc' | 'amount-asc' | 'amount-desc' | ''; + export interface ProductIndexFilter { is_active?: boolean, page?: number, @@ -12,6 +14,7 @@ export interface ProductIndexFilter { stock_type?: 'internal' | 'external', stock_from?: number, stock_to?: number, + sort?: ProductSortOption } export interface ProductIndexFilterIds extends Omit, 'machines'>, ApiFilter { diff --git a/app/services/product_service.rb b/app/services/product_service.rb index 70719fb93..e40c1a16d 100644 --- a/app/services/product_service.rb +++ b/app/services/product_service.rb @@ -12,6 +12,7 @@ class ProductService products = filter_by_machines(products, filters) products = filter_by_keyword_or_reference(products, filters) products = filter_by_stock(products, filters) + products = products_ordering(products, filters) total_count = products.count products = products.page(filters[:page] || 1).per(PRODUCTS_PER_PAGE) @@ -115,5 +116,13 @@ class ProductService products end + + def products_ordering(products, filters) + key, order = filters[:sort].split('-') + key ||= 'created_at' + order ||= 'desc' + + products.order(key => order) + end end end From c23b57131bdce0bdbb318febf445afa1886fc79d Mon Sep 17 00:00:00 2001 From: Sylvain Date: Tue, 20 Sep 2022 17:12:45 +0200 Subject: [PATCH 10/10] (bug) fix admin/orders & public/store due to refactoring --- .../javascript/components/store/orders.tsx | 16 +++++------ .../javascript/components/store/products.tsx | 3 +- .../components/store/store-list-header.tsx | 6 ++-- .../src/javascript/components/store/store.tsx | 28 +++++++++---------- app/frontend/src/javascript/lib/api.ts | 6 +++- app/frontend/src/javascript/lib/product.ts | 4 +-- app/frontend/src/javascript/models/api.ts | 2 ++ app/frontend/src/javascript/models/order.ts | 4 ++- app/services/orders/order_service.rb | 10 ++++++- app/services/product_service.rb | 2 +- 10 files changed, 48 insertions(+), 33 deletions(-) diff --git a/app/frontend/src/javascript/components/store/orders.tsx b/app/frontend/src/javascript/components/store/orders.tsx index b77ca01ab..e3dedb9bc 100644 --- a/app/frontend/src/javascript/components/store/orders.tsx +++ b/app/frontend/src/javascript/components/store/orders.tsx @@ -13,7 +13,7 @@ import { MemberSelect } from '../user/member-select'; import { User } from '../../models/user'; import { FormInput } from '../form/form-input'; import OrderAPI from '../../api/order'; -import { Order, OrderIndexFilter } from '../../models/order'; +import { Order, OrderIndexFilter, OrderSortOption } from '../../models/order'; import { FabPagination } from '../base/fab-pagination'; import { X } from 'phosphor-react'; @@ -27,7 +27,7 @@ interface OrdersProps { * Option format, expected by react-select * @see https://github.com/JedWatson/react-select */ -type selectOption = { value: number, label: string }; +type selectOption = { value: OrderSortOption, label: string }; /** * Option format, expected by checklist @@ -38,7 +38,7 @@ const initFilters: OrderIndexFilter = { reference: '', states: [], page: 1, - sort: 'DESC' + sort: 'created_at-desc' }; const FablabOrdersFilters = 'FablabOrdersFilters'; @@ -126,7 +126,7 @@ const Orders: React.FC = ({ currentUser, onError }) => { return () => { setFilters(draft => { draft.page = 1; - draft.sort = 'DESC'; + draft.sort = 'created_at-desc'; switch (filterType) { case 'reference': draft.reference = ''; @@ -175,8 +175,8 @@ const Orders: React.FC = ({ currentUser, onError }) => { */ const buildOptions = (): Array => { return [ - { value: 0, label: t('app.admin.store.orders.sort.newest') }, - { value: 1, label: t('app.admin.store.orders.sort.oldest') } + { value: 'created_at-desc', label: t('app.admin.store.orders.sort.newest') }, + { value: 'created_at-asc', label: t('app.admin.store.orders.sort.oldest') } ]; }; @@ -185,7 +185,7 @@ const Orders: React.FC = ({ currentUser, onError }) => { */ const handleSorting = (option: selectOption) => { setFilters(draft => { - draft.sort = option.value ? 'ASC' : 'DESC'; + draft.sort = option.value; }); }; @@ -337,7 +337,7 @@ const Orders: React.FC = ({ currentUser, onError }) => {
diff --git a/app/frontend/src/javascript/components/store/products.tsx b/app/frontend/src/javascript/components/store/products.tsx index e5a233cdf..88c93c660 100644 --- a/app/frontend/src/javascript/components/store/products.tsx +++ b/app/frontend/src/javascript/components/store/products.tsx @@ -75,8 +75,7 @@ const Products: React.FC = ({ onSuccess, onError }) => { setProductsCount(data.total_count); return data; } catch (error) { - onError(t('app.admin.store.products.unexpected_error_occurred')); - console.error(error); + onError(t('app.admin.store.products.unexpected_error_occurred') + error); } }; diff --git a/app/frontend/src/javascript/components/store/store-list-header.tsx b/app/frontend/src/javascript/components/store/store-list-header.tsx index 484306f98..85b827761 100644 --- a/app/frontend/src/javascript/components/store/store-list-header.tsx +++ b/app/frontend/src/javascript/components/store/store-list-header.tsx @@ -2,13 +2,13 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import Select from 'react-select'; import Switch from 'react-switch'; -import { ProductSortOption } from '../../models/product'; +import { SortOption } from '../../models/api'; interface StoreListHeaderProps { productsCount: number, selectOptions: selectOption[], onSelectOptionsChange: (option: selectOption) => void, - selectValue?: ProductSortOption, + selectValue?: SortOption, switchLabel?: string, switchChecked?: boolean, onSwitch?: (boolean) => void @@ -17,7 +17,7 @@ interface StoreListHeaderProps { * Option format, expected by react-select * @see https://github.com/JedWatson/react-select */ - type selectOption = { value: ProductSortOption, label: string }; + type selectOption = { value: SortOption, label: string }; /** * Renders an accordion item diff --git a/app/frontend/src/javascript/components/store/store.tsx b/app/frontend/src/javascript/components/store/store.tsx index fa36d21f8..aaa6278cf 100644 --- a/app/frontend/src/javascript/components/store/store.tsx +++ b/app/frontend/src/javascript/components/store/store.tsx @@ -4,7 +4,7 @@ import { react2angular } from 'react2angular'; import { Loader } from '../base/loader'; import { IApplication } from '../../models/application'; import { FabButton } from '../base/fab-button'; -import { Product } from '../../models/product'; +import { Product, ProductSortOption } from '../../models/product'; import { ProductCategory } from '../../models/product-category'; import ProductAPI from '../../api/product'; import ProductCategoryAPI from '../../api/product-category'; @@ -28,7 +28,7 @@ interface StoreProps { * Option format, expected by react-select * @see https://github.com/JedWatson/react-select */ - type selectOption = { value: number, label: string }; + type selectOption = { value: ProductSortOption, label: string }; /** * This component shows public store @@ -51,21 +51,21 @@ const Store: React.FC = ({ onError, onSuccess, currentUser }) => { useEffect(() => { ProductAPI.index({ page: 1, is_active: filterVisible }).then(data => { setPageCount(data.total_pages); - setProducts(data.products); - }).catch(() => { - onError(t('app.public.store.unexpected_error_occurred')); + setProducts(data.data); + }).catch(error => { + onError(t('app.public.store.unexpected_error_occurred') + error); }); ProductCategoryAPI.index().then(data => { setProductCategories(data); formatCategories(data); - }).catch(() => { - onError(t('app.public.store.unexpected_error_occurred')); + }).catch(error => { + onError(t('app.public.store.unexpected_error_occurred') + error); }); MachineAPI.index({ disabled: false }).then(data => { setMachines(buildChecklistOptions(data)); - }).catch(() => { - onError(t('app.public.store.unexpected_error_occurred')); + }).catch(error => { + onError(t('app.public.store.unexpected_error_occurred') + error); }); }, []); @@ -117,10 +117,10 @@ const Store: React.FC = ({ onError, onSuccess, currentUser }) => { */ const buildOptions = (): Array => { return [ - { value: 0, label: t('app.public.store.products.sort.name_az') }, - { value: 1, label: t('app.public.store.products.sort.name_za') }, - { value: 2, label: t('app.public.store.products.sort.price_low') }, - { value: 3, label: t('app.public.store.products.sort.price_high') } + { value: 'name-asc', label: t('app.public.store.products.sort.name_az') }, + { value: 'name-desc', label: t('app.public.store.products.sort.name_za') }, + { value: 'amount-asc', label: t('app.public.store.products.sort.price_low') }, + { value: 'amount-desc', label: t('app.public.store.products.sort.price_high') } ]; }; /** @@ -153,7 +153,7 @@ const Store: React.FC = ({ onError, onSuccess, currentUser }) => { if (page !== currentPage) { ProductAPI.index({ page, is_active: filterVisible }).then(data => { setCurrentPage(page); - setProducts(data.products); + setProducts(data.data); setPageCount(data.total_pages); window.document.getElementById('content-main').scrollTo({ top: 100, behavior: 'smooth' }); }).catch(() => { diff --git a/app/frontend/src/javascript/lib/api.ts b/app/frontend/src/javascript/lib/api.ts index a1ea452f5..6e3abb8e0 100644 --- a/app/frontend/src/javascript/lib/api.ts +++ b/app/frontend/src/javascript/lib/api.ts @@ -1,9 +1,13 @@ +import _ from 'lodash'; import { ApiFilter } from '../models/api'; export default class ApiLib { static filtersToQuery (filters?: ApiFilter): string { if (!filters) return ''; - return '?' + Object.entries(filters).map(f => `${f[0]}=${f[1]}`).join('&'); + return '?' + Object.entries(filters) + .filter(filter => !_.isNil(filter[1])) + .map(filter => `${filter[0]}=${filter[1]}`) + .join('&'); } } diff --git a/app/frontend/src/javascript/lib/product.ts b/app/frontend/src/javascript/lib/product.ts index 56fdfce38..51acb0cef 100644 --- a/app/frontend/src/javascript/lib/product.ts +++ b/app/frontend/src/javascript/lib/product.ts @@ -98,8 +98,8 @@ export default class ProductLib { static indexFiltersToIds = (filters: ProductIndexFilter): ProductIndexFilterIds => { return { ...filters, - categories: filters.categories.map(c => c.id), - machines: filters.machines.map(m => m.id) + categories: filters.categories?.map(c => c.id), + machines: filters.machines?.map(m => m.id) }; }; } diff --git a/app/frontend/src/javascript/models/api.ts b/app/frontend/src/javascript/models/api.ts index 6f531f19d..d4ee0342b 100644 --- a/app/frontend/src/javascript/models/api.ts +++ b/app/frontend/src/javascript/models/api.ts @@ -8,3 +8,5 @@ export interface PaginatedIndex { total_count: number, data: Array } + +export type SortOption = `${string}-${'asc' | 'desc'}` | ''; diff --git a/app/frontend/src/javascript/models/order.ts b/app/frontend/src/javascript/models/order.ts index e1d2fd5e0..421bfa509 100644 --- a/app/frontend/src/javascript/models/order.ts +++ b/app/frontend/src/javascript/models/order.ts @@ -48,6 +48,8 @@ export interface OrderPayment { export type OrderIndex = PaginatedIndex; +export type OrderSortOption = 'created_at-asc' | 'created_at-desc' | ''; + export interface OrderIndexFilter extends ApiFilter { reference?: string, user_id?: number, @@ -56,7 +58,7 @@ export interface OrderIndexFilter extends ApiFilter { name?: string, }, page?: number, - sort?: 'DESC'|'ASC' + sort?: OrderSortOption states?: Array, period_from?: string, period_to?: string diff --git a/app/services/orders/order_service.rb b/app/services/orders/order_service.rb index b5576f0d0..2d724c424 100644 --- a/app/services/orders/order_service.rb +++ b/app/services/orders/order_service.rb @@ -13,7 +13,7 @@ class Orders::OrderService orders = filter_by_period(orders, filters) orders = orders.where.not(state: 'cart') if current_user.member? - orders = orders.order(created_at: filters[:sort] || 'DESC') + orders = orders_ordering(orders, filters) total_count = orders.count orders = orders.page(filters[:page] || 1).per(ORDERS_PER_PAGE) { @@ -106,5 +106,13 @@ class Orders::OrderService orders.where(created_at: DateTime.parse(filters[:period_from])..DateTime.parse(filters[:period_to]).end_of_day) end + + def orders_ordering(orders, filters) + key, order = filters[:sort]&.split('-') + key ||= 'created_at' + order ||= 'desc' + + orders.order(key => order) + end end end diff --git a/app/services/product_service.rb b/app/services/product_service.rb index e40c1a16d..645eb148a 100644 --- a/app/services/product_service.rb +++ b/app/services/product_service.rb @@ -118,7 +118,7 @@ class ProductService end def products_ordering(products, filters) - key, order = filters[:sort].split('-') + key, order = filters[:sort]&.split('-') key ||= 'created_at' order ||= 'desc'