1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2025-01-17 06:52:27 +01:00

(feat) save/restore filters in admin/store/products

This commit is contained in:
Sylvain 2022-09-26 17:18:52 +02:00
parent af81f10a4e
commit 75a4038a60
9 changed files with 204 additions and 239 deletions

View File

@ -4,7 +4,14 @@ import { useTranslation } from 'react-i18next';
import { react2angular } from 'react2angular';
import { Loader } from '../base/loader';
import { IApplication } from '../../models/application';
import { Product, ProductIndexFilter, ProductsIndex, ProductSortOption } from '../../models/product';
import {
initialFilters, initialResources,
Product,
ProductIndexFilter,
ProductResourcesFetching,
ProductsIndex,
ProductSortOption
} from '../../models/product';
import { ProductCategory } from '../../models/product-category';
import { FabButton } from '../base/fab-button';
import { ProductItem } from './product-item';
@ -16,15 +23,17 @@ import { Machine } from '../../models/machine';
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, { initFilters } from '../../lib/product';
import ProductLib from '../../lib/product';
import { ActiveFiltersTags } from './filters/active-filters-tags';
import SettingAPI from '../../api/setting';
import { UIRouter } from '@uirouter/angularjs';
declare const Application: IApplication;
interface ProductsProps {
onSuccess: (message: string) => void,
onError: (message: string) => void,
uiRouter: UIRouter,
}
/**
* Option format, expected by react-select
@ -33,35 +42,52 @@ interface ProductsProps {
type selectOption = { value: ProductSortOption, label: string };
/** This component shows the admin view of the store */
const Products: React.FC<ProductsProps> = ({ onSuccess, onError }) => {
const Products: React.FC<ProductsProps> = ({ onSuccess, onError, uiRouter }) => {
const { t } = useTranslation('admin');
const [productCategories, setProductCategories] = useState<Array<ProductCategory>>([]);
const [productsList, setProductList] = useState<Array<Product>>([]);
const [filters, setFilters] = useImmer<ProductIndexFilter>(initFilters);
// this includes the resources fetch from the API (machines, categories) and from the URL (filters)
const [resources, setResources] = useImmer<ProductResourcesFetching>(initialResources);
const [machinesModule, setMachinesModule] = useState<boolean>(false);
const [pageCount, setPageCount] = useState<number>(0);
const [currentPage, setCurrentPage] = useState<number>(1);
const [productsCount, setProductsCount] = useState<number>(0);
useEffect(() => {
fetchProducts().then(scrollToProducts);
ProductCategoryAPI.index().then(data => {
setProductCategories(ProductLib.sortCategories(data));
ProductLib.fetchInitialResources(setResources, onError);
SettingAPI.get('machines_module').then(data => {
setMachinesModule(data.value === 'true');
}).catch(onError);
}, []);
useEffect(() => {
fetchProducts().then(scrollToProducts);
}, [filters]);
if (resources.filters.ready) {
fetchProducts().then(scrollToProducts);
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]);
/**
* Handle products pagination
*/
const handlePagination = (page: number) => {
if (page !== currentPage) {
setFilters(draft => {
return { ...draft, page };
});
ProductLib.updateFilter(setResources, 'page', page);
}
};
@ -70,7 +96,7 @@ const Products: React.FC<ProductsProps> = ({ onSuccess, onError }) => {
*/
const fetchProducts = async (): Promise<ProductsIndex> => {
try {
const data = await ProductAPI.index(filters);
const data = await ProductAPI.index(resources.filters.data);
setCurrentPage(data.page);
setProductList(data.data);
setPageCount(data.total_pages);
@ -112,25 +138,21 @@ const Products: React.FC<ProductsProps> = ({ onSuccess, onError }) => {
/** Filter: toggle non-available products visibility */
const toggleVisible = (checked: boolean) => {
setFilters(draft => {
return { ...draft, is_active: checked };
});
ProductLib.updateFilter(setResources, 'is_active', checked);
};
/**
* Update the list of applied filters with the given categories
*/
const handleCategoriesFilterUpdate = (categories: Array<ProductCategory>) => {
setFilters(draft => {
return { ...draft, categories };
});
ProductLib.updateFilter(setResources, 'categories', categories);
};
/**
* Remove the provided category from the filters selection
*/
const handleRemoveCategory = (category: ProductCategory) => {
const list = ProductLib.categoriesSelectionTree(productCategories, filters.categories, category, 'remove');
const list = ProductLib.categoriesSelectionTree(resources.categories.data, resources.filters.data.categories, category, 'remove');
handleCategoriesFilterUpdate(list);
};
@ -138,43 +160,33 @@ const Products: React.FC<ProductsProps> = ({ onSuccess, onError }) => {
* Update the list of applied filters with the given machines
*/
const handleMachinesFilterUpdate = (machines: Array<Machine>) => {
setFilters(draft => {
return { ...draft, machines };
});
ProductLib.updateFilter(setResources, 'machines', machines);
};
/**
* Update the list of applied filters with the given keywords (or reference)
*/
const handleKeywordFilterUpdate = (keywords: Array<string>) => {
setFilters(draft => {
return { ...draft, keywords };
});
ProductLib.updateFilter(setResources, 'keywords', keywords);
};
/** Filter: by stock range */
const handleStockFilterUpdate = (filters: ProductIndexFilter) => {
setFilters(draft => {
return {
...draft,
...filters
};
setResources(draft => {
return { ...draft, filters: { ...draft.filters, data: { ...draft.filters.data, ...filters } } };
});
};
/** Display option: sorting */
const handleSorting = (option: selectOption) => {
setFilters(draft => {
return {
...draft,
sort: option.value
};
});
ProductLib.updateFilter(setResources, 'sort', option.value);
};
/** Clear filters */
const clearAllFilters = () => {
setFilters(initFilters);
setResources(draft => {
return { ...draft, filters: { ...draft.filters, data: initialFilters } };
});
};
/** Creates sorting options to the react-select format */
@ -203,19 +215,20 @@ const Products: React.FC<ProductsProps> = ({ onSuccess, onError }) => {
</div>
</header>
<div className='accordion'>
<CategoriesFilter productCategories={productCategories}
<CategoriesFilter productCategories={resources.categories.data}
onApplyFilters={handleCategoriesFilterUpdate}
currentFilters={filters.categories} />
currentFilters={resources.filters.data.categories} />
<MachinesFilter onError={onError}
onApplyFilters={handleMachinesFilterUpdate}
currentFilters={filters.machines} />
{machinesModule && <MachinesFilter onError={onError}
allMachines={resources.machines.data}
onApplyFilters={handleMachinesFilterUpdate}
currentFilters={resources.filters.data.machines} />}
<KeywordFilter onApplyFilters={keyword => handleKeywordFilterUpdate([keyword])}
currentFilters={filters.keywords[0]} />
currentFilters={resources.filters.data.keywords[0]} />
<StockFilter onApplyFilters={handleStockFilterUpdate}
currentFilters={filters} />
currentFilters={resources.filters.data} />
</div>
</div>
<div className='store-list'>
@ -223,13 +236,14 @@ const Products: React.FC<ProductsProps> = ({ onSuccess, onError }) => {
productsCount={productsCount}
selectOptions={buildSortOptions()}
onSelectOptionsChange={handleSorting}
switchChecked={filters.is_active}
selectValue={resources.filters.data.sort}
switchChecked={resources.filters.data.is_active}
onSwitch={toggleVisible}
/>
<div className='features'>
<ActiveFiltersTags filters={filters}
<ActiveFiltersTags filters={resources.filters.data}
onRemoveCategory={handleRemoveCategory}
onRemoveMachine={(m) => handleMachinesFilterUpdate(filters.machines.filter(machine => machine !== m))}
onRemoveMachine={(m) => handleMachinesFilterUpdate(resources.filters.data.machines.filter(machine => machine !== m))}
onRemoveKeyword={() => handleKeywordFilterUpdate([])}
onRemoveStock={() => handleStockFilterUpdate({ stock_type: 'internal', stock_to: 0, stock_from: 0 })} />
</div>
@ -252,12 +266,12 @@ const Products: React.FC<ProductsProps> = ({ onSuccess, onError }) => {
);
};
const ProductsWrapper: React.FC<ProductsProps> = ({ onSuccess, onError }) => {
const ProductsWrapper: React.FC<ProductsProps> = (props) => {
return (
<Loader>
<Products onSuccess={onSuccess} onError={onError} />
<Products {...props} />
</Loader>
);
};
Application.Components.component('products', react2angular(ProductsWrapper, ['onSuccess', 'onError']));
Application.Components.component('products', react2angular(ProductsWrapper, ['onSuccess', 'onError', 'uiRouter']));

View File

@ -4,10 +4,16 @@ import { react2angular } from 'react2angular';
import { Loader } from '../base/loader';
import { IApplication } from '../../models/application';
import { FabButton } from '../base/fab-button';
import { Product, ProductIndexFilter, ProductsIndex, ProductSortOption } from '../../models/product';
import {
initialFilters,
initialResources,
Product,
ProductResourcesFetching,
ProductsIndex,
ProductSortOption
} from '../../models/product';
import { ProductCategory } from '../../models/product-category';
import ProductAPI from '../../api/product';
import ProductCategoryAPI from '../../api/product-category';
import { StoreProductItem } from './store-product-item';
import useCart from '../../hooks/use-cart';
import { User } from '../../models/user';
@ -19,11 +25,9 @@ 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, { initFilters } from '../../lib/product';
import ProductLib 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;
@ -49,7 +53,7 @@ const Store: React.FC<StoreProps> = ({ onError, onSuccess, currentUser, uiRouter
const [products, setProducts] = useState<Array<Product>>([]);
// this includes the resources fetch from the API (machines, categories) and from the URL (filters)
const [resources, setResources] = useImmer<FetchResources>(initialResources);
const [resources, setResources] = useImmer<ProductResourcesFetching>(initialResources);
const [machinesModule, setMachinesModule] = useState<boolean>(false);
const [categoriesTree, setCategoriesTree] = useState<CategoryTree[]>([]);
const [pageCount, setPageCount] = useState<number>(0);
@ -58,31 +62,7 @@ const Store: React.FC<StoreProps> = ({ onError, onSuccess, currentUser, uiRouter
useEffect(() => {
fetchProducts().then(scrollToProducts);
ProductCategoryAPI.index().then(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);
ProductLib.fetchInitialResources(setResources, onError, formatCategories);
SettingAPI.get('machines_module').then(data => {
setMachinesModule(data.value === 'true');
}).catch(onError);
@ -128,56 +108,27 @@ const Store: React.FC<StoreProps> = ({ onError, onSuccess, currentUser, uiRouter
* Filter by category: the selected category will always be first
*/
const filterCategory = (category: ProductCategory) => {
setResources(draft => {
return {
...draft,
filters: {
...draft.filters,
data: {
...draft.filters.data,
categories: category
? Array.from(new Set([category, ...ProductLib.categoriesSelectionTree(resources.categories.data, [], category, 'add')]))
: []
}
}
};
});
ProductLib.updateFilter(
setResources,
'categories',
category
? Array.from(new Set([category, ...ProductLib.categoriesSelectionTree(resources.categories.data, [], category, 'add')]))
: []
);
};
/**
* Update the list of applied filters with the given machines
*/
const applyMachineFilters = (machines: Array<Machine>) => {
setResources(draft => {
return {
...draft,
filters: {
...draft.filters,
data: {
...draft.filters.data,
machines
}
}
};
});
ProductLib.updateFilter(setResources, 'machines', machines);
};
/**
* Update the list of applied filters with the given keywords (or reference)
*/
const applyKeywordFilter = (keywords: Array<string>) => {
setResources(draft => {
return {
...draft,
filters: {
...draft.filters,
data: {
...draft.filters.data,
keywords
}
}
};
});
ProductLib.updateFilter(setResources, 'keywords', keywords);
};
/**
* Clear filters
@ -189,7 +140,7 @@ const Store: React.FC<StoreProps> = ({ onError, onSuccess, currentUser, uiRouter
filters: {
...draft.filters,
data: {
...initFilters,
...initialFilters,
categories: draft.filters.data.categories
}
}
@ -212,36 +163,14 @@ const Store: React.FC<StoreProps> = ({ onError, onSuccess, currentUser, uiRouter
* Display option: sorting
*/
const handleSorting = (option: selectOption) => {
setResources(draft => {
return {
...draft,
filters: {
...draft.filters,
data: {
...draft.filters.data,
sort: option.value
}
}
};
});
ProductLib.updateFilter(setResources, 'sort', option.value);
};
/**
* Filter: toggle non-available products visibility
*/
const toggleVisible = (checked: boolean) => {
setResources(draft => {
return {
...draft,
filters: {
...draft.filters,
data: {
...draft.filters.data,
is_active: checked
}
}
};
});
ProductLib.updateFilter(setResources, 'is_active', checked);
};
/**
@ -257,18 +186,7 @@ const Store: React.FC<StoreProps> = ({ onError, onSuccess, currentUser, uiRouter
*/
const handlePagination = (page: number) => {
if (page !== currentPage) {
setResources(draft => {
return {
...draft,
filters: {
...draft.filters,
data: {
...draft.filters.data,
page
}
}
};
});
ProductLib.updateFilter(setResources, 'page', page);
}
};
@ -403,24 +321,3 @@ interface CategoryTree {
parent: ProductCategory,
children: ProductCategory[]
}
interface FetchResources {
machines: ApiResource<Array<Machine>>,
categories: ApiResource<Array<ProductCategory>>,
filters: ApiResource<ProductIndexFilter>
}
const initialResources: FetchResources = {
machines: {
data: [],
ready: false
},
categories: {
data: [],
ready: false
},
filters: {
data: initFilters,
ready: false
}
};

View File

@ -4,8 +4,8 @@
*/
'use strict';
Application.Controllers.controller('AdminStoreController', ['$scope', 'CSRF', 'growl', '$state',
function ($scope, CSRF, growl, $state) {
Application.Controllers.controller('AdminStoreController', ['$scope', 'CSRF', 'growl', '$state', '$uiRouter',
function ($scope, CSRF, growl, $state, $uiRouter) {
/* PRIVATE SCOPE */
// Map of tab state and index
const TABS = {
@ -21,6 +21,9 @@ Application.Controllers.controller('AdminStoreController', ['$scope', 'CSRF', 'g
active: TABS[$state.current.name]
};
// the following item is used by the Products component to save/restore filters in the URL
$scope.uiRouter = $uiRouter;
/**
* Callback triggered in click tab
*/

View File

@ -4,7 +4,7 @@ Application.Controllers.controller('StoreController', ['$scope', 'CSRF', 'growl'
function ($scope, CSRF, growl, $uiRouter) {
/* PUBLIC SCOPE */
// the following item is used by the Store component to store the filters in te URL
// the following item is used by the Store component to store the filters in the URL
$scope.uiRouter = $uiRouter;
/**

View File

@ -1,7 +1,8 @@
import { ProductCategory } from '../models/product-category';
import {
initialFilters,
ProductIndexFilter,
ProductIndexFilterIds, ProductIndexFilterUrl,
ProductIndexFilterIds, ProductIndexFilterUrl, ProductResourcesFetching,
stockMovementInReasons,
stockMovementOutReasons,
StockMovementReason
@ -9,6 +10,9 @@ import {
import { Machine } from '../models/machine';
import { StateParams } from '@uirouter/angularjs';
import ParsingLib from './parsing';
import ProductCategoryAPI from '../api/product-category';
import MachineAPI from '../api/machine';
import { Updater } from 'use-immer';
export default class ProductLib {
/**
@ -120,6 +124,7 @@ export default class ProductLib {
return {
...filters,
machines: filters.machines?.map(m => m.slug),
categories: filters.categories?.map(c => c.slug),
category,
categoryTypeUrl
};
@ -129,17 +134,20 @@ export default class ProductLib {
* Parse the provided URL and return a ready-to-use filter object
*/
static readFiltersFromUrl = (params: StateParams, machines: Array<Machine>, categories: Array<ProductCategory>): ProductIndexFilter => {
const res: ProductIndexFilter = { ...initFilters };
const res: ProductIndexFilter = { ...initialFilters };
for (const key in params) {
if (['#', 'categoryTypeUrl'].includes(key) || !Object.prototype.hasOwnProperty.call(params, key)) continue;
const value = ParsingLib.parse(params[key]) || initFilters[key];
const value = ParsingLib.parse(params[key]) || initialFilters[key];
switch (key) {
case 'category':
const parents = categories?.filter(c => (value as Array<string>)?.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 'categories':
res.categories = [...categories?.filter(c => (value as Array<string>)?.includes(c.slug))];
break;
case 'machines':
res.machines = machines?.filter(m => (value as Array<string>)?.includes(m.slug));
break;
@ -149,16 +157,41 @@ export default class ProductLib {
}
return res;
};
}
export const initFilters: ProductIndexFilter = {
categories: [],
keywords: [],
machines: [],
is_active: false,
stock_type: 'internal',
stock_from: 0,
stock_to: 0,
page: 1,
sort: ''
};
/**
* Fetch the initial ressources needed to initialise the store and its filters (categories and machines)
*/
static fetchInitialResources = (setResources: Updater<ProductResourcesFetching>, onError: (message: string) => void, onProductCategoryFetched?: (data: Array<ProductCategory>) => void) => {
ProductCategoryAPI.index().then(data => {
setResources(draft => {
return { ...draft, categories: { data: ProductLib.sortCategories(data), ready: true } };
});
if (typeof onProductCategoryFetched === 'function') onProductCategoryFetched(data);
}).catch(error => {
onError(error);
});
MachineAPI.index({ disabled: false }).then(data => {
setResources(draft => {
return { ...draft, machines: { data, ready: true } };
});
}).catch(onError);
};
/**
* Update the given filter in memory with the new provided value
*/
static updateFilter = (setResources: Updater<ProductResourcesFetching>, key: keyof ProductIndexFilter, value: unknown): void => {
setResources(draft => {
return {
...draft,
filters: {
...draft.filters,
data: {
...draft.filters.data,
[key]: value
}
}
};
});
};
}

View File

@ -1,5 +1,5 @@
import { TDateISO } from '../typings/date-iso';
import { ApiFilter, PaginatedIndex } from './api';
import { ApiFilter, ApiResource, PaginatedIndex } from './api';
import { ProductCategory } from './product-category';
import { Machine } from './machine';
@ -26,8 +26,42 @@ export interface ProductIndexFilterUrl extends Omit<Omit<ProductIndexFilter, 'ca
categoryTypeUrl?: 'c' | 'sc',
category?: string,
machines?: Array<string>,
categories?: Array<string>,
}
export interface ProductResourcesFetching {
machines: ApiResource<Array<Machine>>,
categories: ApiResource<Array<ProductCategory>>,
filters: ApiResource<ProductIndexFilter>
}
export const initialFilters: ProductIndexFilter = {
categories: [],
keywords: [],
machines: [],
is_active: false,
stock_type: 'internal',
stock_from: 0,
stock_to: 0,
page: 1,
sort: ''
};
export const initialResources: ProductResourcesFetching = {
machines: {
data: [],
ready: false
},
categories: {
data: [],
ready: false
},
filters: {
data: initialFilters,
ready: false
}
};
export type StockType = 'internal' | 'external' | 'all';
export const stockMovementInReasons = ['inward_stock', 'returned', 'cancelled', 'inventory_fix', 'other_in'] as const;

View File

@ -628,42 +628,13 @@ angular.module('application.router', ['ui.router'])
}
},
params: {
categoryTypeUrl: {
dynamic: true,
raw: true,
type: 'path',
value: null,
squash: true
},
category: {
dynamic: true,
type: 'path',
raw: true,
value: null,
squash: true
},
machines: {
array: true,
dynamic: true,
type: 'query',
raw: true
},
keywords: {
dynamic: true,
type: 'query'
},
is_active: {
dynamic: true,
type: 'query'
},
page: {
dynamic: true,
type: 'query'
},
sort: {
dynamic: true,
type: 'query'
}
categoryTypeUrl: { dynamic: true, raw: true, type: 'path', value: null, squash: true },
category: { dynamic: true, type: 'path', raw: true, value: null, squash: true },
machines: { array: true, dynamic: true, type: 'query', raw: true },
keywords: { dynamic: true, type: 'query' },
is_active: { dynamic: true, type: 'query', value: 'false', squash: true },
page: { dynamic: true, type: 'query', value: '1', squash: true },
sort: { dynamic: true, type: 'query' }
}
})
@ -1220,12 +1191,23 @@ angular.module('application.router', ['ui.router'])
})
.state('app.admin.store.products', {
url: '/products',
url: '/products?{categories:string}{machines:string}{keywords:string}{stock_type:string}{stock_from:string}{stock_to:string}{is_active:string}{page:string}{sort:string}',
views: {
'main@': {
templateUrl: '/admin/store/index.html',
controller: 'AdminStoreController'
}
},
params: {
categories: { array: true, dynamic: true, type: 'query', raw: true },
machines: { array: true, dynamic: true, type: 'query', raw: true },
keywords: { dynamic: true, type: 'query' },
stock_type: { dynamic: true, type: 'query', value: 'internal', squash: true },
stock_from: { dynamic: true, type: 'query', value: '0', squash: true },
stock_to: { dynamic: true, type: 'query', value: '0', squash: true },
is_active: { dynamic: true, type: 'query', value: 'false', squash: true },
page: { dynamic: true, type: 'query', value: '1', squash: true },
sort: { dynamic: true, type: 'query' }
}
})

View File

@ -1 +1 @@
<products on-success="onSuccess" on-error="onError"/>
<products on-success="onSuccess" on-error="onError" ui-router="uiRouter"/>

View File

@ -111,10 +111,12 @@ class ProductService
end
def filter_by_stock(products, filters, operator)
filters[:stock_type] = 'external' unless operator.privileged?
return products if filters[:stock_type] == 'internal' && !operator.privileged?
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?
if filters[:stock_from].to_i.positive?
products = products.where('(stock ->> ?)::int >= ?', filters[:stock_type], filters[:stock_from])
end
products = products.where('(stock ->> ?)::int <= ?', filters[:stock_type], filters[:stock_to]) if filters[:stock_to].to_i.positive?
products
end