From 1b930d20057774169ef35e4423abda8870028da9 Mon Sep 17 00:00:00 2001 From: Sylvain Date: Mon, 26 Sep 2022 15:23:07 +0200 Subject: [PATCH] (feat) restore the filters from the URL --- .eslintrc | 3 +- app/frontend/src/javascript/api/product.ts | 7 +- .../store/filters/machines-filter.tsx | 15 +- .../javascript/components/store/products.tsx | 16 +- .../src/javascript/components/store/store.tsx | 190 ++++++++++++++---- app/frontend/src/javascript/lib/parsing.ts | 24 +++ app/frontend/src/javascript/lib/product.ts | 48 +++-- app/frontend/src/javascript/models/api.ts | 5 + 8 files changed, 230 insertions(+), 78 deletions(-) create mode 100644 app/frontend/src/javascript/lib/parsing.ts diff --git a/.eslintrc b/.eslintrc index 1dd62ba79..6b09f6097 100644 --- a/.eslintrc +++ b/.eslintrc @@ -6,7 +6,8 @@ ], "rules": { "semi": ["error", "always"], - "no-use-before-define": "off" + "no-use-before-define": "off", + "no-case-declarations": "off" }, "globals": { "Application": true, diff --git a/app/frontend/src/javascript/api/product.ts b/app/frontend/src/javascript/api/product.ts index 3df9615a7..f760e1a29 100644 --- a/app/frontend/src/javascript/api/product.ts +++ b/app/frontend/src/javascript/api/product.ts @@ -1,12 +1,13 @@ import apiClient from './clients/api-client'; import { AxiosResponse } from 'axios'; import { serialize } from 'object-to-formdata'; -import { Product, ProductIndexFilterIds, ProductsIndex, ProductStockMovement } from '../models/product'; +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?: ProductIndexFilterIds): Promise { - const res: AxiosResponse = await apiClient.get(`/api/products${ApiLib.filtersToQuery(filters)}`); + static async index (filters?: ProductIndexFilter): Promise { + 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/machines-filter.tsx b/app/frontend/src/javascript/components/store/filters/machines-filter.tsx index ab4a2bf1f..f202b71e6 100644 --- a/app/frontend/src/javascript/components/store/filters/machines-filter.tsx +++ b/app/frontend/src/javascript/components/store/filters/machines-filter.tsx @@ -7,27 +7,30 @@ import MachineAPI from '../../../api/machine'; import _ from 'lodash'; interface MachinesFilterProps { + allMachines?: Array, onError: (message: string) => void, onApplyFilters: (categories: Array) => void, currentFilters: Array, openDefault?: boolean, - instantUpdate?: boolean, + instantUpdate?: boolean } /** * Component to filter the products list by associated machine */ -export const MachinesFilter: React.FC = ({ onError, onApplyFilters, currentFilters, openDefault = false, instantUpdate = false }) => { +export const MachinesFilter: React.FC = ({ allMachines, onError, onApplyFilters, currentFilters, openDefault = false, instantUpdate = false }) => { const { t } = useTranslation('admin'); - const [machines, setMachines] = useState([]); + const [machines, setMachines] = useState(allMachines); const [openedAccordion, setOpenedAccordion] = useState(openDefault); const [selectedMachines, setSelectedMachines] = useState(currentFilters || []); useEffect(() => { - MachineAPI.index({ disabled: false }).then(data => { - setMachines(data); - }).catch(onError); + if (_.isEmpty(allMachines)) { + MachineAPI.index({ disabled: false }).then(data => { + setMachines(data); + }).catch(onError); + } }, []); useEffect(() => { diff --git a/app/frontend/src/javascript/components/store/products.tsx b/app/frontend/src/javascript/components/store/products.tsx index 7ec96c1fb..add0bf7de 100644 --- a/app/frontend/src/javascript/components/store/products.tsx +++ b/app/frontend/src/javascript/components/store/products.tsx @@ -17,7 +17,7 @@ import { MachinesFilter } from './filters/machines-filter'; import { KeywordFilter } from './filters/keyword-filter'; import { StockFilter } from './filters/stock-filter'; import ProductCategoryAPI from '../../api/product-category'; -import ProductLib from '../../lib/product'; +import ProductLib, { initFilters } from '../../lib/product'; import { ActiveFiltersTags } from './filters/active-filters-tags'; declare const Application: IApplication; @@ -70,7 +70,7 @@ const Products: React.FC = ({ onSuccess, onError }) => { */ const fetchProducts = async (): Promise => { try { - const data = await ProductAPI.index(ProductLib.indexFiltersToIds(filters)); + const data = await ProductAPI.index(filters); setCurrentPage(data.page); setProductList(data.data); setPageCount(data.total_pages); @@ -261,15 +261,3 @@ const ProductsWrapper: React.FC = ({ onSuccess, onError }) => { }; Application.Components.component('products', react2angular(ProductsWrapper, ['onSuccess', 'onError'])); - -const initFilters: ProductIndexFilter = { - categories: [], - machines: [], - keywords: [], - stock_type: 'internal', - stock_from: 0, - stock_to: 0, - is_active: false, - page: 1, - sort: '' -}; diff --git a/app/frontend/src/javascript/components/store/store.tsx b/app/frontend/src/javascript/components/store/store.tsx index 46ba018f7..cb1912b22 100644 --- a/app/frontend/src/javascript/components/store/store.tsx +++ b/app/frontend/src/javascript/components/store/store.tsx @@ -19,8 +19,11 @@ import { useImmer } from 'use-immer'; import { Machine } from '../../models/machine'; import { KeywordFilter } from './filters/keyword-filter'; import { ActiveFiltersTags } from './filters/active-filters-tags'; -import ProductLib from '../../lib/product'; +import ProductLib, { initFilters } from '../../lib/product'; import { UIRouter } from '@uirouter/angularjs'; +import MachineAPI from '../../api/machine'; +import SettingAPI from '../../api/setting'; +import { ApiResource } from '../../models/api'; declare const Application: IApplication; @@ -45,29 +48,66 @@ const Store: React.FC = ({ onError, onSuccess, currentUser, uiRouter const { cart, setCart } = useCart(currentUser); const [products, setProducts] = useState>([]); - const [productCategories, setProductCategories] = useState([]); + // this includes the resources fetch from the API (machines, categories) and from the URL (filters) + const [resources, setResources] = useImmer(initialResources); + const [machinesModule, setMachinesModule] = useState(false); const [categoriesTree, setCategoriesTree] = useState([]); const [pageCount, setPageCount] = useState(0); const [productsCount, setProductsCount] = useState(0); const [currentPage, setCurrentPage] = useState(1); - const [filters, setFilters] = useImmer(initFilters); useEffect(() => { - // TODO, set the filters in the state - console.log(ProductLib.readFiltersFromUrl(location.href)); fetchProducts().then(scrollToProducts); ProductCategoryAPI.index().then(data => { - setProductCategories(data); + setResources(draft => { + return { + ...draft, + categories: { + data, + ready: true + } + }; + }); formatCategories(data); }).catch(error => { onError(t('app.public.store.unexpected_error_occurred') + error); }); + MachineAPI.index({ disabled: false }).then(data => { + setResources(draft => { + return { + ...draft, + machines: { + data, + ready: true + } + }; + }); + }).catch(onError); + SettingAPI.get('machines_module').then(data => { + setMachinesModule(data.value === 'true'); + }).catch(onError); }, []); useEffect(() => { fetchProducts().then(scrollToProducts); - uiRouter.stateService.transitionTo(uiRouter.globals.current, ProductLib.indexFiltersToRouterParams(filters)); - }, [filters]); + if (resources.filters.ready) { + uiRouter.stateService.transitionTo(uiRouter.globals.current, ProductLib.indexFiltersToRouterParams(resources.filters.data)); + } + }, [resources.filters]); + + useEffect(() => { + if (resources.machines.ready && resources.categories.ready) { + setResources(draft => { + return { + ...draft, + filters: { + data: ProductLib.readFiltersFromUrl(uiRouter.globals.params, resources.machines.data, resources.categories.data), + ready: true + } + }; + }); + } + }, [resources.machines, resources.categories]); /** * Create categories tree (parent/children) @@ -88,12 +128,18 @@ const Store: React.FC = ({ onError, onSuccess, currentUser, uiRouter * Filter by category: the selected category will always be first */ const filterCategory = (category: ProductCategory) => { - setFilters(draft => { + setResources(draft => { return { ...draft, - categories: category - ? Array.from(new Set([category, ...ProductLib.categoriesSelectionTree(productCategories, [], category, 'add')])) - : [] + filters: { + ...draft.filters, + data: { + ...draft.filters.data, + categories: category + ? Array.from(new Set([category, ...ProductLib.categoriesSelectionTree(resources.categories.data, [], category, 'add')])) + : [] + } + } }; }); }; @@ -102,8 +148,17 @@ const Store: React.FC = ({ onError, onSuccess, currentUser, uiRouter * Update the list of applied filters with the given machines */ const applyMachineFilters = (machines: Array) => { - setFilters(draft => { - return { ...draft, machines }; + setResources(draft => { + return { + ...draft, + filters: { + ...draft.filters, + data: { + ...draft.filters.data, + machines + } + } + }; }); }; @@ -111,15 +166,32 @@ const Store: React.FC = ({ onError, onSuccess, currentUser, uiRouter * Update the list of applied filters with the given keywords (or reference) */ const applyKeywordFilter = (keywords: Array) => { - setFilters(draft => { - return { ...draft, keywords }; + setResources(draft => { + return { + ...draft, + filters: { + ...draft.filters, + data: { + ...draft.filters.data, + keywords + } + } + }; }); }; /** * Clear filters */ const clearAllFilters = () => { - setFilters(initFilters); + setResources(draft => { + return { + ...draft, + filters: { + ...draft.filters, + data: initFilters + } + }; + }); }; /** @@ -137,10 +209,16 @@ const Store: React.FC = ({ onError, onSuccess, currentUser, uiRouter * Display option: sorting */ const handleSorting = (option: selectOption) => { - setFilters(draft => { + setResources(draft => { return { ...draft, - sort: option.value + filters: { + ...draft.filters, + data: { + ...draft.filters.data, + sort: option.value + } + } }; }); }; @@ -149,8 +227,17 @@ const Store: React.FC = ({ onError, onSuccess, currentUser, uiRouter * Filter: toggle non-available products visibility */ const toggleVisible = (checked: boolean) => { - setFilters(draft => { - return { ...draft, is_active: checked }; + setResources(draft => { + return { + ...draft, + filters: { + ...draft.filters, + data: { + ...draft.filters.data, + is_active: checked + } + } + }; }); }; @@ -167,8 +254,17 @@ const Store: React.FC = ({ onError, onSuccess, currentUser, uiRouter */ const handlePagination = (page: number) => { if (page !== currentPage) { - setFilters(draft => { - return { ...draft, page }; + setResources(draft => { + return { + ...draft, + filters: { + ...draft.filters, + data: { + ...draft.filters.data, + page + } + } + }; }); } }; @@ -178,7 +274,7 @@ const Store: React.FC = ({ onError, onSuccess, currentUser, uiRouter */ const fetchProducts = async (): Promise => { try { - const data = await ProductAPI.index(ProductLib.indexFiltersToIds(filters)); + const data = await ProductAPI.index(resources.filters.data); setCurrentPage(data.page); setProducts(data.data); setPageCount(data.total_pages); @@ -196,8 +292,8 @@ const Store: React.FC = ({ onError, onSuccess, currentUser, uiRouter window.document.getElementById('content-main').scrollTo({ top: 100, behavior: 'smooth' }); }; - const selectedCategory = filters.categories[0]; - const parent = productCategories.find(c => c.id === selectedCategory?.parent_id); + const selectedCategory = resources.filters.data.categories[0]; + const parent = resources.categories.data.find(c => c.id === selectedCategory?.parent_id); return (
    @@ -252,8 +348,13 @@ const Store: React.FC = ({ onError, onSuccess, currentUser, uiRouter {t('app.public.store.products.filter_clear')}
- - applyKeywordFilter([keyword])} currentFilters={filters.keywords[0]} /> + {machinesModule && resources.machines.ready && + + } + applyKeywordFilter([keyword])} currentFilters={resources.filters.data.keywords[0]} />
@@ -262,13 +363,14 @@ const Store: React.FC = ({ onError, onSuccess, currentUser, uiRouter selectOptions={buildOptions()} onSelectOptionsChange={handleSorting} switchLabel={t('app.public.store.products.in_stock_only')} - switchChecked={filters.is_active} + switchChecked={resources.filters.data.is_active} + selectValue={resources.filters.data.sort} onSwitch={toggleVisible} />
- applyMachineFilters(filters.machines.filter(machine => machine !== m))} + onRemoveMachine={(m) => applyMachineFilters(resources.filters.data.machines.filter(machine => machine !== m))} onRemoveKeyword={() => applyKeywordFilter([])} />
@@ -299,11 +401,23 @@ interface CategoryTree { children: ProductCategory[] } -const initFilters: ProductIndexFilter = { - categories: [], - keywords: [], - machines: [], - is_active: false, - page: 1, - sort: '' +interface FetchResources { + machines: ApiResource>, + categories: ApiResource>, + filters: ApiResource +} + +const initialResources: FetchResources = { + machines: { + data: [], + ready: false + }, + categories: { + data: [], + ready: false + }, + filters: { + data: initFilters, + ready: false + } }; diff --git a/app/frontend/src/javascript/lib/parsing.ts b/app/frontend/src/javascript/lib/parsing.ts new file mode 100644 index 000000000..2aa8583a0 --- /dev/null +++ b/app/frontend/src/javascript/lib/parsing.ts @@ -0,0 +1,24 @@ +type baseType = string|number|boolean; +type ValueOrArray = T | ValueOrArray[]; +type NestedBaseArray = ValueOrArray; + +export default class ParsingLib { + /** + * Try to parse the given value to get the value with the matching type. + * It supports parsing arrays. + */ + static parse = (value: string|string[]): NestedBaseArray => { + let parsedValue: NestedBaseArray = value; + if (Array.isArray(value)) { + parsedValue = []; + for (const item of value) { + parsedValue.push(ParsingLib.parse(item)); + } + } else if (['true', 'false'].includes(value)) { + parsedValue = (value === 'true'); + } else if (parseInt(value, 10).toString() === value) { + parsedValue = parseInt(value, 10); + } + return parsedValue; + }; +} diff --git a/app/frontend/src/javascript/lib/product.ts b/app/frontend/src/javascript/lib/product.ts index d32e8dfb6..d365f2888 100644 --- a/app/frontend/src/javascript/lib/product.ts +++ b/app/frontend/src/javascript/lib/product.ts @@ -6,6 +6,9 @@ import { stockMovementOutReasons, StockMovementReason } from '../models/product'; +import { Machine } from '../models/machine'; +import { StateParams } from '@uirouter/angularjs'; +import ParsingLib from './parsing'; export default class ProductLib { /** @@ -124,25 +127,38 @@ export default class ProductLib { /** * Parse the provided URL and return a ready-to-use filter object - * FIXME */ - static readFiltersFromUrl = (url: string): ProductIndexFilterIds => { - const res: ProductIndexFilterIds = {}; - for (const [key, value] of new URLSearchParams(url.split('?')[1])) { - let parsedValue: string|number|boolean = value; - if (['true', 'false'].includes(value)) { - parsedValue = (value === 'true'); - } else if (parseInt(value, 10).toString() === value) { - parsedValue = parseInt(value, 10); - } - if (res[key] === undefined) { - res[key] = parsedValue; - } else if (Array.isArray(res[key])) { - res[key] = [...res[key] as Array, parsedValue]; - } else { - res[key] = [res[key], parsedValue]; + static readFiltersFromUrl = (params: StateParams, machines: Array, categories: Array): ProductIndexFilter => { + const res: ProductIndexFilter = { ...initFilters }; + for (const key in params) { + if (['#', 'categoryTypeUrl'].includes(key) || !Object.prototype.hasOwnProperty.call(params, key)) continue; + + const value = ParsingLib.parse(params[key]) || initFilters[key]; + switch (key) { + case 'category': + const parents = categories?.filter(c => (value as Array)?.includes(c.slug)); + // we may also add to the selection children categories + res.categories = [...parents, ...categories?.filter(c => parents.map(c => c.id).includes(c.parent_id))]; + break; + case 'machines': + res.machines = machines?.filter(m => (value as Array)?.includes(m.slug)); + break; + default: + res[key] = value; } } return res; }; } + +export const initFilters: ProductIndexFilter = { + categories: [], + keywords: [], + machines: [], + is_active: false, + stock_type: 'internal', + stock_from: 0, + stock_to: 0, + page: 1, + sort: '' +}; diff --git a/app/frontend/src/javascript/models/api.ts b/app/frontend/src/javascript/models/api.ts index d4ee0342b..ef11decc3 100644 --- a/app/frontend/src/javascript/models/api.ts +++ b/app/frontend/src/javascript/models/api.ts @@ -10,3 +10,8 @@ export interface PaginatedIndex { } export type SortOption = `${string}-${'asc' | 'desc'}` | ''; + +export interface ApiResource { + data: T, + ready: boolean +}