1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2025-01-18 07:52:23 +01:00

(quality) extract filter in separate components

This commit is contained in:
Sylvain 2022-09-19 10:54:41 +02:00
parent 945428e71c
commit 586dd5f9b5
10 changed files with 414 additions and 193 deletions

View File

@ -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<AccordionItemProps> = ({ isOpen, onChange, id, label, children }) => {
const [state, setState] = useState(isOpen);
useEffect(() => {
onChange(id, state);
}, [state]);

View File

@ -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<ProductCategory>) => void,
currentFilters: Array<ProductCategory>,
openDefault?: boolean,
instantUpdate?: boolean,
}
/**
* Component to filter the products list by categories
*/
export const CategoriesFilter: React.FC<CategoriesFilterProps> = ({ onError, onApplyFilters, currentFilters, openDefault = false, instantUpdate = false }) => {
const { t } = useTranslation('admin');
const [productCategories, setProductCategories] = useState<ProductCategory[]>([]);
const [openedAccordion, setOpenedAccordion] = useState<boolean>(openDefault);
const [selectedCategories, setSelectedCategories] = useState<ProductCategory[]>(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 (
<>
<AccordionItem id={0}
isOpen={openedAccordion}
onChange={handleAccordion}
label={t('app.admin.store.categories_filter.filter_categories')}>
<div className='content'>
<div className="group u-scrollbar">
{productCategories.map(pc => (
<label key={pc.id} className={pc.parent_id ? 'offset' : ''}>
<input type="checkbox" checked={selectedCategories.includes(pc)} onChange={(event) => handleSelectCategory(pc, event.target.checked)} />
<p>{pc.name}</p>
</label>
))}
</div>
<FabButton onClick={() => onApplyFilters(selectedCategories)} className="is-info">{t('app.admin.store.categories_filter.filter_apply')}</FabButton>
</div>
</AccordionItem>
</>
);
};

View File

@ -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<KeywordFilterProps> = ({ onApplyFilters, currentFilters, openDefault = false, instantUpdate = false }) => {
const { t } = useTranslation('admin');
const [openedAccordion, setOpenedAccordion] = useState<boolean>(openDefault);
const [keyword, setKeyword] = useState<string>(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<HTMLInputElement>) => {
setKeyword(evt.target.value);
if (instantUpdate) {
onApplyFilters(evt.target.value);
}
};
return (
<>
<AccordionItem id={2}
isOpen={openedAccordion}
onChange={handleAccordion}
label={t('app.admin.store.keyword_filter.filter_keywords_reference')}
>
<div className="content">
<div className="group">
<input type="text" onChange={event => handleKeywordTyping(event)} />
<FabButton onClick={() => onApplyFilters(keyword)} className="is-info">{t('app.admin.store.keyword_filter.filter_apply')}</FabButton>
</div>
</div>
</AccordionItem>
</>
);
};

View File

@ -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<Machine>) => void,
currentFilters: Array<Machine>,
openDefault?: boolean,
instantUpdate?: boolean,
}
/**
* Component to filter the products list by associated machine
*/
export const MachinesFilter: React.FC<MachinesFilterProps> = ({ onError, onApplyFilters, currentFilters, openDefault = false, instantUpdate = false }) => {
const { t } = useTranslation('admin');
const [machines, setMachines] = useState<Machine[]>([]);
const [openedAccordion, setOpenedAccordion] = useState<boolean>(openDefault);
const [selectedMachines, setSelectedMachines] = useState<Machine[]>(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 (
<>
<AccordionItem id={1}
isOpen={openedAccordion}
onChange={handleAccordion}
label={t('app.admin.store.machines_filter.filter_machines')}
>
<div className='content'>
<div className="group u-scrollbar">
{machines.map(m => (
<label key={m.id}>
<input type="checkbox" checked={machines.includes(m)} onChange={(event) => handleSelectMachine(m, event.target.checked)} />
<p>{m.name}</p>
</label>
))}
</div>
<FabButton onClick={() => onApplyFilters(selectedMachines)} className="is-info">{t('app.admin.store.machines_filter.filter_apply')}</FabButton>
</div>
</AccordionItem>
</>
);
};

View File

@ -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<StockFilterProps> = ({ onApplyFilters, currentFilters, openDefault = false }) => {
const { t } = useTranslation('admin');
const [openedAccordion, setOpenedAccordion] = useState<boolean>(openDefault);
const { register, control, handleSubmit, getValues, reset } = useForm<StockFilterData>({ 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<selectOption> => {
return [
{ value: 'internal', label: t('app.admin.store.stock_filter.stock_internal') },
{ value: 'external', label: t('app.admin.store.stock_filter.stock_external') }
];
};
return (
<>
<AccordionItem id={3}
isOpen={openedAccordion}
onChange={handleAccordion}
label={t('app.admin.store.stock_filter.filter_stock')}>
<form className="content" onSubmit={handleSubmit(onSubmit)}>
<div className="group">
<FormSelect id="stock_type"
options={buildStockOptions()}
valueDefault="internal"
control={control}
/>
<div className='range'>
<FormInput id="stock_from"
label={t('app.admin.store.stock_filter.filter_stock_from')}
register={register}
defaultValue={0}
type="number" />
<FormInput id="stock_to"
label={t('app.admin.store.stock_filter.filter_stock_to')}
register={register}
defaultValue={0}
type="number" />
</div>
<FabButton type="submit" className="is-info">{t('app.admin.store.stock_filter.filter_apply')}</FabButton>
</div>
</form>
</AccordionItem>
</>
);
};

View File

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

View File

@ -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<ProductsProps> = ({ onSuccess, onError }) => {
const { t } = useTranslation('admin');
const { register, control, getValues } = useForm();
const [filteredProductsList, setFilteredProductList] = useImmer<Array<Product>>([]);
const [features, setFeatures] = useImmer<Filters>(initFilters);
const [filterVisible, setFilterVisible] = useState<boolean>(false);
const [filters, setFilters] = useImmer<Filters>(initFilters);
const [clearFilters, setClearFilters] = useState<boolean>(false);
const [productCategories, setProductCategories] = useState<ProductCategory[]>([]);
const [machines, setMachines] = useState<checklistOption[]>([]);
const [update, setUpdate] = useState(false);
const [accordion, setAccordion] = useState({});
const [pageCount, setPageCount] = useState<number>(0);
const [currentPage, setCurrentPage] = useState<number>(1);
@ -55,14 +48,6 @@ const Products: React.FC<ProductsProps> = ({ 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<ProductsProps> = ({ 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<ProductCategory>) => {
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<Machine>) => {
setFilters(draft => {
return { ...draft, machines: list };
return { ...draft, machines };
});
if (instantUpdate) {
setUpdate(true);
}
};
/** Filter: by keyword or ref */
const handleKeyword = (evt: React.ChangeEvent<HTMLInputElement>) => {
/**
* Update the list of applied filters with the given keywords (or reference)
*/
const handleKeywordFilterUpdate = (keywords: Array<string>) => {
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<selectOption> => {
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<ProductsProps> = ({ onSuccess, onError }) => {
];
};
/** Open/close accordion items */
const handleAccordion = (id, state) => {
setAccordion({ ...accordion, [id]: state });
};
return (
<div className='products'>
<header>
@ -259,83 +189,19 @@ const Products: React.FC<ProductsProps> = ({ onSuccess, onError }) => {
</div>
</header>
<div className='accordion'>
<AccordionItem id={0}
isOpen={accordion[0]}
onChange={handleAccordion}
label={t('app.admin.store.products.filter_categories')}
>
<div className='content'>
<div className="group u-scrollbar">
{productCategories.map(pc => (
<label key={pc.id} className={pc.parent_id ? 'offset' : ''}>
<input type="checkbox" checked={filters.categories.includes(pc)} onChange={(event) => handleSelectCategory(pc, event.target.checked)} />
<p>{pc.name}</p>
</label>
))}
</div>
<FabButton onClick={() => setUpdate(true)} className="is-info">{t('app.admin.store.products.filter_apply')}</FabButton>
</div>
</AccordionItem>
<CategoriesFilter onError={onError}
onApplyFilters={handleCategoriesFilterUpdate}
currentFilters={filters.categories} />
<AccordionItem id={1}
isOpen={accordion[1]}
onChange={handleAccordion}
label={t('app.admin.store.products.filter_machines')}
>
<div className='content'>
<div className="group u-scrollbar">
{machines.map(m => (
<label key={m.value}>
<input type="checkbox" checked={filters.machines.includes(m)} onChange={(event) => handleSelectMachine(m, event.target.checked)} />
<p>{m.label}</p>
</label>
))}
</div>
<FabButton onClick={() => setUpdate(true)} className="is-info">{t('app.admin.store.products.filter_apply')}</FabButton>
</div>
</AccordionItem>
<MachinesFilter onError={onError}
onApplyFilters={handleMachinesFilterUpdate}
currentFilters={filters.machines} />
<AccordionItem id={2}
isOpen={accordion[2]}
onChange={handleAccordion}
label={t('app.admin.store.products.filter_keywords_reference')}
>
<div className="content">
<div className="group">
<input type="text" onChange={event => handleKeyword(event)} />
<FabButton onClick={() => setUpdate(true)} className="is-info">{t('app.admin.store.products.filter_apply')}</FabButton>
</div>
</div>
</AccordionItem>
<KeywordFilter onApplyFilters={keyword => handleKeywordFilterUpdate([...filters.keywords, keyword])}
currentFilters={filters.keywords[0]} />
<AccordionItem id={3}
isOpen={accordion[3]}
onChange={handleAccordion}
label={t('app.admin.store.products.filter_stock')}
>
<div className="content">
<div className="group">
<FormSelect id="stock_type"
options={buildStockOptions()}
valueDefault={0}
control={control}
/>
<div className='range'>
<FormInput id="stock_from"
label={t('app.admin.store.products.filter_stock_from')}
register={register}
defaultValue={filters.stock_from}
type="number" />
<FormInput id="stock_to"
label={t('app.admin.store.products.filter_stock_to')}
register={register}
defaultValue={filters.stock_to}
type="number" />
</div>
<FabButton onClick={handleStockRange} className="is-info">{t('app.admin.store.products.filter_apply')}</FabButton>
</div>
</div>
</AccordionItem>
<StockFilter onApplyFilters={handleStockFilterUpdate}
currentFilters={filters} />
</div>
</div>
<div className='store-list'>
@ -350,13 +216,19 @@ const Products: React.FC<ProductsProps> = ({ onSuccess, onError }) => {
{features.categories.map(c => (
<div key={c.id} className='features-item'>
<p>{c.name}</p>
<button onClick={() => handleSelectCategory(c, false, true)}><X size={16} weight="light" /></button>
<button onClick={() => handleCategoriesFilterUpdate(filters.categories.filter(cat => cat !== c))}><X size={16} weight="light" /></button>
</div>
))}
{features.machines.map(m => (
<div key={m.value} className='features-item'>
<p>{m.label}</p>
<button onClick={() => handleSelectMachine(m, false, true)}><X size={16} weight="light" /></button>
<div key={m.id} className='features-item'>
<p>{m.name}</p>
<button onClick={() => handleMachinesFilterUpdate(filters.machines.filter(machine => machine !== m))}><X size={16} weight="light" /></button>
</div>
))}
{features.keywords.map(k => (
<div key={k} className='features-item'>
<p>{k}</p>
<button onClick={() => handleKeywordFilterUpdate(filters.keywords.filter(keyword => keyword !== k))}><X size={16} weight="light" /></button>
</div>
))}
</div>
@ -389,19 +261,9 @@ const ProductsWrapper: React.FC<ProductsProps> = ({ 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<checklistOption> => {
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,

View File

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

View File

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

View File

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