mirror of
https://github.com/LaCasemate/fab-manager.git
synced 2025-01-29 18:52:22 +01:00
(feat) products filtering in public store
This commit is contained in:
parent
c23b57131b
commit
57e3dda2cd
@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import _ from 'lodash';
|
||||
import { ProductIndexFilter } from '../../../models/product';
|
||||
import { X } from 'phosphor-react';
|
||||
import { ProductCategory } from '../../../models/product-category';
|
||||
@ -7,20 +8,21 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ActiveFiltersTagsProps {
|
||||
filters: ProductIndexFilter,
|
||||
onRemoveCategory: (category: ProductCategory) => void,
|
||||
displayCategories?: boolean,
|
||||
onRemoveCategory?: (category: ProductCategory) => void,
|
||||
onRemoveMachine: (machine: Machine) => void,
|
||||
onRemoveKeyword: () => void,
|
||||
onRemoveStock: () => void,
|
||||
onRemoveStock?: () => void,
|
||||
}
|
||||
|
||||
/**
|
||||
* Some tags listing the currently actives filters for a product list
|
||||
*/
|
||||
export const ActiveFiltersTags: React.FC<ActiveFiltersTagsProps> = ({ filters, onRemoveCategory, onRemoveMachine, onRemoveKeyword, onRemoveStock }) => {
|
||||
export const ActiveFiltersTags: React.FC<ActiveFiltersTagsProps> = ({ filters, displayCategories = true, onRemoveCategory, onRemoveMachine, onRemoveKeyword, onRemoveStock }) => {
|
||||
const { t } = useTranslation('shared');
|
||||
return (
|
||||
<>
|
||||
{filters.categories.map(c => (
|
||||
{displayCategories && filters.categories.map(c => (
|
||||
<div key={c.id} className='features-item'>
|
||||
<p>{c.name}</p>
|
||||
<button onClick={() => onRemoveCategory(c)}><X size={16} weight="light" /></button>
|
||||
@ -33,10 +35,10 @@ export const ActiveFiltersTags: React.FC<ActiveFiltersTagsProps> = ({ filters, o
|
||||
</div>
|
||||
))}
|
||||
{filters.keywords[0] && <div className='features-item'>
|
||||
<p>{filters.keywords[0]}</p>
|
||||
<p>{t('app.shared.active_filters_tags.keyword', { KEYWORD: filters.keywords[0] })}</p>
|
||||
<button onClick={onRemoveKeyword}><X size={16} weight="light" /></button>
|
||||
</div>}
|
||||
{(filters.stock_to !== 0 || filters.stock_from !== 0) && <div className='features-item'>
|
||||
{(!_.isNil(filters.stock_to) && (filters.stock_to !== 0 || filters.stock_from !== 0)) && <div className='features-item'>
|
||||
<p>{t(`app.shared.active_filters_tags.stock_${filters.stock_type}`)} [{filters.stock_from || '…'} ⟶ {filters.stock_to || '…'}]</p>
|
||||
<button onClick={onRemoveStock}><X size={16} weight="light" /></button>
|
||||
</div>}
|
||||
|
@ -54,7 +54,9 @@ const Products: React.FC<ProductsProps> = ({ onSuccess, onError }) => {
|
||||
fetchProducts().then(scrollToProducts);
|
||||
}, [filters]);
|
||||
|
||||
/** Handle products pagination */
|
||||
/**
|
||||
* Handle products pagination
|
||||
*/
|
||||
const handlePagination = (page: number) => {
|
||||
if (page !== currentPage) {
|
||||
setFilters(draft => {
|
||||
|
@ -4,18 +4,22 @@ import { react2angular } from 'react2angular';
|
||||
import { Loader } from '../base/loader';
|
||||
import { IApplication } from '../../models/application';
|
||||
import { FabButton } from '../base/fab-button';
|
||||
import { Product, ProductSortOption } from '../../models/product';
|
||||
import { Product, ProductIndexFilter, ProductsIndex, ProductSortOption } from '../../models/product';
|
||||
import { ProductCategory } from '../../models/product-category';
|
||||
import ProductAPI from '../../api/product';
|
||||
import ProductCategoryAPI from '../../api/product-category';
|
||||
import MachineAPI from '../../api/machine';
|
||||
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 '../base/accordion-item';
|
||||
import { StoreListHeader } from './store-list-header';
|
||||
import { FabPagination } from '../base/fab-pagination';
|
||||
import { MachinesFilter } from './filters/machines-filter';
|
||||
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';
|
||||
|
||||
declare const Application: IApplication;
|
||||
|
||||
@ -40,76 +44,77 @@ const Store: React.FC<StoreProps> = ({ onError, onSuccess, currentUser }) => {
|
||||
|
||||
const [products, setProducts] = useState<Array<Product>>([]);
|
||||
const [productCategories, setProductCategories] = useState<ProductCategory[]>([]);
|
||||
const [categoriesTree, setCategoriesTree] = useState<ParentCategory[]>([]);
|
||||
const [activeCategory, setActiveCategory] = useState<ActiveCategory>();
|
||||
const [filterVisible, setFilterVisible] = useState<boolean>(false);
|
||||
const [machines, setMachines] = useState<checklistOption[]>([]);
|
||||
const [accordion, setAccordion] = useState({});
|
||||
const [categoriesTree, setCategoriesTree] = useState<CategoryTree[]>([]);
|
||||
const [pageCount, setPageCount] = useState<number>(0);
|
||||
const [productsCount, setProductsCount] = useState<number>(0);
|
||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||
const [filters, setFilters] = useImmer<ProductIndexFilter>(initFilters);
|
||||
|
||||
useEffect(() => {
|
||||
ProductAPI.index({ page: 1, is_active: filterVisible }).then(data => {
|
||||
setPageCount(data.total_pages);
|
||||
setProducts(data.data);
|
||||
}).catch(error => {
|
||||
onError(t('app.public.store.unexpected_error_occurred') + error);
|
||||
});
|
||||
fetchProducts().then(scrollToProducts);
|
||||
ProductCategoryAPI.index().then(data => {
|
||||
setProductCategories(data);
|
||||
formatCategories(data);
|
||||
}).catch(error => {
|
||||
onError(t('app.public.store.unexpected_error_occurred') + error);
|
||||
});
|
||||
|
||||
MachineAPI.index({ disabled: false }).then(data => {
|
||||
setMachines(buildChecklistOptions(data));
|
||||
}).catch(error => {
|
||||
onError(t('app.public.store.unexpected_error_occurred') + error);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchProducts().then(scrollToProducts);
|
||||
}, [filters]);
|
||||
|
||||
/**
|
||||
* Create categories tree (parent/children)
|
||||
*/
|
||||
const formatCategories = (list: ProductCategory[]) => {
|
||||
const tree = [];
|
||||
const tree: Array<CategoryTree> = [];
|
||||
const parents = list.filter(c => !c.parent_id);
|
||||
const getChildren = (id) => {
|
||||
return list.filter(c => c.parent_id === id);
|
||||
};
|
||||
parents.forEach(p => {
|
||||
tree.push({ parent: p, children: getChildren(p.id) });
|
||||
tree.push({
|
||||
parent: p,
|
||||
children: list.filter(c => c.parent_id === p.id)
|
||||
});
|
||||
});
|
||||
setCategoriesTree(tree);
|
||||
};
|
||||
|
||||
/**
|
||||
* Filter by category
|
||||
* Filter by category: the selected category will always be first
|
||||
*/
|
||||
const filterCategory = (id: number, parent?: number) => {
|
||||
setActiveCategory({ id, parent });
|
||||
console.log('Filter by category:', productCategories.find(c => c.id === id).name);
|
||||
const filterCategory = (category: ProductCategory) => {
|
||||
setFilters(draft => {
|
||||
return {
|
||||
...draft,
|
||||
categories: category
|
||||
? Array.from(new Set([category, ...ProductLib.categoriesSelectionTree(productCategories, [], category, 'add')]))
|
||||
: []
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Apply filters
|
||||
* Update the list of applied filters with the given machines
|
||||
*/
|
||||
const applyFilters = () => {
|
||||
console.log('Filter products');
|
||||
const applyMachineFilters = (machines: Array<Machine>) => {
|
||||
setFilters(draft => {
|
||||
return { ...draft, machines };
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Update the list of applied filters with the given keywords (or reference)
|
||||
*/
|
||||
const applyKeywordFilter = (keywords: Array<string>) => {
|
||||
setFilters(draft => {
|
||||
return { ...draft, keywords };
|
||||
});
|
||||
};
|
||||
/**
|
||||
* Clear filters
|
||||
*/
|
||||
const clearAllFilters = () => {
|
||||
console.log('Clear filters');
|
||||
};
|
||||
|
||||
/**
|
||||
* Open/close accordion items
|
||||
*/
|
||||
const handleAccordion = (id, state) => {
|
||||
setAccordion({ ...accordion, [id]: state });
|
||||
setFilters(initFilters);
|
||||
};
|
||||
|
||||
/**
|
||||
@ -127,15 +132,21 @@ const Store: React.FC<StoreProps> = ({ onError, onSuccess, currentUser }) => {
|
||||
* Display option: sorting
|
||||
*/
|
||||
const handleSorting = (option: selectOption) => {
|
||||
console.log('Sort option:', option);
|
||||
setFilters(draft => {
|
||||
return {
|
||||
...draft,
|
||||
sort: option.value
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Filter: toggle non-available products visibility
|
||||
*/
|
||||
const toggleVisible = (checked: boolean) => {
|
||||
setFilterVisible(!filterVisible);
|
||||
console.log('Display in stock only:', checked);
|
||||
setFilters(draft => {
|
||||
return { ...draft, is_active: checked };
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
@ -151,34 +162,54 @@ const Store: React.FC<StoreProps> = ({ onError, onSuccess, currentUser }) => {
|
||||
*/
|
||||
const handlePagination = (page: number) => {
|
||||
if (page !== currentPage) {
|
||||
ProductAPI.index({ page, is_active: filterVisible }).then(data => {
|
||||
setCurrentPage(page);
|
||||
setProducts(data.data);
|
||||
setPageCount(data.total_pages);
|
||||
window.document.getElementById('content-main').scrollTo({ top: 100, behavior: 'smooth' });
|
||||
}).catch(() => {
|
||||
onError(t('app.public.store.unexpected_error_occurred'));
|
||||
setFilters(draft => {
|
||||
return { ...draft, page };
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch the products from the API, according to the current filters
|
||||
*/
|
||||
const fetchProducts = async (): Promise<ProductsIndex> => {
|
||||
try {
|
||||
const data = await ProductAPI.index(filters);
|
||||
setCurrentPage(data.page);
|
||||
setProducts(data.data);
|
||||
setPageCount(data.total_pages);
|
||||
setProductsCount(data.total_count);
|
||||
return data;
|
||||
} catch (error) {
|
||||
onError(t('app.public.store.unexpected_error_occurred') + error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Scroll the view to the product list
|
||||
*/
|
||||
const scrollToProducts = () => {
|
||||
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);
|
||||
return (
|
||||
<div className="store">
|
||||
<ul className="breadcrumbs">
|
||||
<li>
|
||||
<span onClick={() => setActiveCategory(null)}>{t('app.public.store.products.all_products')}</span>
|
||||
<span onClick={() => filterCategory(null)}>{t('app.public.store.products.all_products')}</span>
|
||||
</li>
|
||||
{activeCategory?.parent &&
|
||||
{parent &&
|
||||
<li>
|
||||
<span onClick={() => filterCategory(activeCategory?.parent)}>
|
||||
{productCategories.find(c => c.id === activeCategory.parent).name}
|
||||
<span onClick={() => filterCategory(parent)}>
|
||||
{parent.name}
|
||||
</span>
|
||||
</li>
|
||||
}
|
||||
{activeCategory?.id &&
|
||||
}
|
||||
{selectedCategory &&
|
||||
<li>
|
||||
<span onClick={() => filterCategory(activeCategory?.id, activeCategory?.parent)}>
|
||||
{productCategories.find(c => c.id === activeCategory.id).name}
|
||||
<span onClick={() => filterCategory(selectedCategory)}>
|
||||
{selectedCategory.name}
|
||||
</span>
|
||||
</li>
|
||||
}
|
||||
@ -190,16 +221,16 @@ const Store: React.FC<StoreProps> = ({ onError, onSuccess, currentUser }) => {
|
||||
</header>
|
||||
<div className="group u-scrollbar">
|
||||
{categoriesTree.map(c =>
|
||||
<div key={c.parent.id} className={`parent ${activeCategory?.id === c.parent.id || activeCategory?.parent === c.parent.id ? 'is-active' : ''}`}>
|
||||
<p onClick={() => filterCategory(c.parent.id)}>
|
||||
<div key={c.parent.id} className={`parent ${selectedCategory?.id === c.parent.id || selectedCategory?.parent_id === c.parent.id ? 'is-active' : ''}`}>
|
||||
<p onClick={() => filterCategory(c.parent)}>
|
||||
{c.parent.name}<span>(count)</span>
|
||||
</p>
|
||||
{c.children.length > 0 &&
|
||||
<div className='children'>
|
||||
{c.children.map(ch =>
|
||||
<p key={ch.id}
|
||||
className={activeCategory?.id === ch.id ? 'is-active' : ''}
|
||||
onClick={() => filterCategory(ch.id, c.parent.id)}>
|
||||
className={selectedCategory?.id === ch.id ? 'is-active' : ''}
|
||||
onClick={() => filterCategory(ch)}>
|
||||
{ch.name}<span>(count)</span>
|
||||
</p>
|
||||
)}
|
||||
@ -216,36 +247,25 @@ const Store: React.FC<StoreProps> = ({ onError, onSuccess, currentUser }) => {
|
||||
<FabButton onClick={clearAllFilters} className="is-black">{t('app.public.store.products.filter_clear')}</FabButton>
|
||||
</div>
|
||||
</header>
|
||||
<div className="accordion">
|
||||
<AccordionItem id={1}
|
||||
isOpen={accordion[1]}
|
||||
onChange={handleAccordion}
|
||||
label={t('app.public.store.products.filter_machines')}
|
||||
>
|
||||
<div className='content'>
|
||||
<div className="group u-scrollbar">
|
||||
{machines.map(m => (
|
||||
<label key={m.value}>
|
||||
<input type="checkbox" />
|
||||
<p>{m.label}</p>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<FabButton onClick={applyFilters} className="is-info">{t('app.public.store.products.filter_apply')}</FabButton>
|
||||
</div>
|
||||
</AccordionItem>
|
||||
</div>
|
||||
<MachinesFilter onError={onError} onApplyFilters={applyMachineFilters} currentFilters={filters.machines} />
|
||||
<KeywordFilter onApplyFilters={keyword => applyKeywordFilter([keyword])} currentFilters={filters.keywords[0]} />
|
||||
</div>
|
||||
</aside>
|
||||
<div className='store-list'>
|
||||
<StoreListHeader
|
||||
productsCount={products.length}
|
||||
productsCount={productsCount}
|
||||
selectOptions={buildOptions()}
|
||||
onSelectOptionsChange={handleSorting}
|
||||
switchLabel={t('app.public.store.products.in_stock_only')}
|
||||
switchChecked={filterVisible}
|
||||
switchChecked={filters.is_active}
|
||||
onSwitch={toggleVisible}
|
||||
/>
|
||||
<div className='features'>
|
||||
<ActiveFiltersTags filters={filters}
|
||||
displayCategories={false}
|
||||
onRemoveMachine={(m) => applyMachineFilters(filters.machines.filter(machine => machine !== m))}
|
||||
onRemoveKeyword={() => applyKeywordFilter([])} />
|
||||
</div>
|
||||
<div className="products-grid">
|
||||
{products.map((product) => (
|
||||
<StoreProductItem key={product.id} product={product} cart={cart} onSuccessAddProductToCart={addToCart} onError={onError} />
|
||||
@ -259,19 +279,6 @@ const Store: React.FC<StoreProps> = ({ onError, onSuccess, currentUser }) => {
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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<checklistOption> => {
|
||||
return items.map(t => {
|
||||
return { value: t.id, label: t.name };
|
||||
});
|
||||
};
|
||||
|
||||
const StoreWrapper: React.FC<StoreProps> = (props) => {
|
||||
return (
|
||||
<Loader>
|
||||
@ -282,11 +289,16 @@ const StoreWrapper: React.FC<StoreProps> = (props) => {
|
||||
|
||||
Application.Components.component('store', react2angular(StoreWrapper, ['onError', 'onSuccess', 'currentUser']));
|
||||
|
||||
interface ActiveCategory {
|
||||
id: number,
|
||||
parent: number
|
||||
}
|
||||
interface ParentCategory {
|
||||
interface CategoryTree {
|
||||
parent: ProductCategory,
|
||||
children: ProductCategory[]
|
||||
}
|
||||
|
||||
const initFilters: ProductIndexFilter = {
|
||||
categories: [],
|
||||
keywords: [],
|
||||
machines: [],
|
||||
is_active: false,
|
||||
page: 1,
|
||||
sort: ''
|
||||
};
|
||||
|
@ -637,5 +637,6 @@ en:
|
||||
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:
|
||||
keyword: "Keyword: {KEYWORD}"
|
||||
stock_internal: "Private stock"
|
||||
stock_external: "Public stock"
|
||||
|
Loading…
x
Reference in New Issue
Block a user