Refactor filter components

This commit is contained in:
dominik-zeglen 2019-12-19 16:54:52 +01:00
parent 87b94f47e1
commit aafa6b62dc
22 changed files with 408 additions and 1088 deletions

View file

@ -9,14 +9,14 @@ import classNames from "classnames";
import React from "react";
import { FormattedMessage } from "react-intl";
import { FilterContentSubmitData } from "./FilterContent";
import { IFilter } from "./types";
import { IFilter, IFilterElement } from "./types";
import useFilter from "./useFilter";
import { FilterContent } from ".";
export interface FilterProps<TFilterKeys = string> {
export interface FilterProps<TFilterKeys extends string = string> {
currencySymbol: string;
menu: IFilter<TFilterKeys>;
onFilterAdd: (filter: FilterContentSubmitData) => void;
onFilterAdd: (filter: Array<IFilterElement<string>>) => void;
}
const useStyles = makeStyles(
@ -71,7 +71,8 @@ const useStyles = makeStyles(
width: 240
},
popover: {
zIndex: 1
width: 376,
zIndex: 3
},
rotate: {
transform: "rotate(180deg)"
@ -85,6 +86,7 @@ const Filter: React.FC<FilterProps> = props => {
const anchor = React.useRef<HTMLDivElement>();
const [isFilterMenuOpened, setFilterMenuOpened] = React.useState(false);
const [data, dispatch, reset] = useFilter(menu);
return (
<div ref={anchor}>
@ -122,8 +124,10 @@ const Filter: React.FC<FilterProps> = props => {
>
<FilterContent
currencySymbol={currencySymbol}
filters={menu}
onSubmit={data => {
filters={data}
onClear={reset}
onFilterPropertyChange={dispatch}
onSubmit={() => {
onFilterAdd(data);
setFilterMenuOpened(false);
}}

View file

@ -1,109 +0,0 @@
import { makeStyles } from "@material-ui/core/styles";
import TextField, { TextFieldProps } from "@material-ui/core/TextField";
import classNames from "classnames";
import React from "react";
import { FilterContentSubmitData, IFilter } from "../Filter";
import Filter from "./Filter";
const useInputStyles = makeStyles(
{
input: {
padding: "10.5px 12px"
},
root: {
flex: 1
}
},
{ name: "FilterActions" }
);
const Search: React.FC<TextFieldProps> = props => {
const classes = useInputStyles({});
return (
<TextField
{...props}
className={classes.root}
inputProps={{
className: classes.input
}}
/>
);
};
const useStyles = makeStyles(
theme => ({
actionContainer: {
display: "flex",
flexWrap: "wrap",
padding: theme.spacing(1, 3)
},
searchOnly: {
paddingBottom: theme.spacing(1.5)
}
}),
{
name: "FilterActions"
}
);
export interface FilterActionsPropsSearch {
placeholder: string;
search: string;
onSearchChange: (event: React.ChangeEvent<any>) => void;
}
export interface FilterActionsPropsFilters<TKeys = string> {
currencySymbol: string;
menu: IFilter<TKeys>;
onFilterAdd: (filter: FilterContentSubmitData<TKeys>) => void;
}
export const FilterActionsOnlySearch: React.FC<FilterActionsPropsSearch> = props => {
const { onSearchChange, placeholder, search } = props;
const classes = useStyles(props);
return (
<div className={classNames(classes.actionContainer, classes.searchOnly)}>
<Search
fullWidth
placeholder={placeholder}
value={search}
onChange={onSearchChange}
/>
</div>
);
};
export type FilterActionsProps = FilterActionsPropsSearch &
FilterActionsPropsFilters;
const FilterActions: React.FC<FilterActionsProps> = props => {
const {
currencySymbol,
menu,
onFilterAdd,
onSearchChange,
placeholder,
search
} = props;
const classes = useStyles(props);
return (
<div className={classes.actionContainer}>
<Filter
currencySymbol={currencySymbol}
menu={menu}
onFilterAdd={onFilterAdd}
/>
<Search
fullWidth
placeholder={placeholder}
value={search}
onChange={onSearchChange}
/>
</div>
);
};
FilterActions.displayName = "FilterActions";
export default FilterActions;

View file

@ -19,10 +19,6 @@ import { IFilter, FieldType, FilterType } from "./types";
import Arrow from "./Arrow";
import { FilterReducerAction } from "./reducer";
export interface FilterContentSubmitData<TKeys = string> {
name: TKeys;
value: string[];
}
export interface FilterContentProps<T extends string = string> {
currencySymbol: string;
filters: IFilter<T>;
@ -31,17 +27,6 @@ export interface FilterContentProps<T extends string = string> {
onSubmit: () => void;
}
function checkFilterValue(value: string[]): boolean {
return value.some(v => !!v);
}
function getFilterChoices(items: IFilter<string>) {
return items.map(filterItem => ({
label: filterItem.label,
value: filterItem.value.toString()
}));
}
const useStyles = makeStyles(
theme => ({
actionBar: {

View file

@ -1,229 +0,0 @@
import { makeStyles } from "@material-ui/core/styles";
import TextField from "@material-ui/core/TextField";
import Typography from "@material-ui/core/Typography";
import React from "react";
import { FormattedMessage, useIntl } from "react-intl";
import Calendar from "../../icons/Calendar";
import FormSpacer from "../FormSpacer";
import PriceField from "../PriceField";
import SingleSelectField from "../SingleSelectField";
import { FieldType, IFilterItem } from "./types";
export interface FilterElementProps<TFilterKeys = string> {
className?: string;
filter: IFilterItem<TFilterKeys>;
value: string | string[];
onChange: (value: string | string[]) => void;
}
const useStyles = makeStyles(
{
calendar: {
margin: 8
},
input: {
padding: "20px 12px 17px"
}
},
{ name: "FilterElement" }
);
export interface FilterElementProps<TFilterKeys = string> {
className?: string;
currencySymbol: string;
filter: IFilterItem<TFilterKeys>;
value: string | string[];
onChange: (value: string | string[]) => void;
}
const FilterElement: React.FC<FilterElementProps> = ({
currencySymbol,
className,
filter,
onChange,
value
}) => {
const intl = useIntl();
const classes = useStyles({});
if (filter.data.type === FieldType.date) {
return (
<TextField
className={className}
fullWidth
type="date"
onChange={event => onChange(event.target.value)}
value={value}
InputProps={{
classes: {
input: classes.input
},
startAdornment: <Calendar className={classes.calendar} />
}}
/>
);
} else if (filter.data.type === FieldType.rangeDate) {
return (
<>
<Typography>
<FormattedMessage defaultMessage="from" />
</Typography>
<TextField
className={className}
fullWidth
type="date"
value={value[0]}
onChange={event => onChange([event.target.value, value[1]])}
InputProps={{
classes: {
input: classes.input
},
startAdornment: <Calendar className={classes.calendar} />
}}
/>
<FormSpacer />
<Typography>
<FormattedMessage defaultMessage="to" />
</Typography>
<TextField
className={className}
fullWidth
type="date"
value={value[1]}
onChange={event => onChange([value[0], event.target.value])}
InputProps={{
classes: {
input: classes.input
},
startAdornment: <Calendar className={classes.calendar} />
}}
/>
</>
);
} else if (filter.data.type === FieldType.range) {
return (
<>
<Typography>
<FormattedMessage defaultMessage="from" />
</Typography>
<TextField
className={className}
fullWidth
value={value[0]}
onChange={event => onChange([event.target.value, value[1]])}
type="number"
InputProps={{
classes: {
input: classes.input
}
}}
/>
<FormSpacer />
<Typography>
<FormattedMessage defaultMessage="to" />
</Typography>
<TextField
className={className}
fullWidth
value={value[1]}
onChange={event => onChange([value[0], event.target.value])}
type="number"
InputProps={{
classes: {
input: classes.input
}
}}
/>
</>
);
} else if (filter.data.type === FieldType.rangePrice) {
return (
<>
<Typography>
<FormattedMessage defaultMessage="from" />
</Typography>
<PriceField
currencySymbol={currencySymbol}
className={className}
value={value[0]}
onChange={event => onChange([event.target.value, value[1]])}
InputProps={{
classes: {
input: classes.input
}
}}
/>
<FormSpacer />
<Typography>
<FormattedMessage defaultMessage="to" />
</Typography>
<PriceField
currencySymbol={currencySymbol}
className={className}
value={value[1]}
onChange={event => onChange([value[0], event.target.value])}
InputProps={{
classes: {
input: classes.input
}
}}
/>
</>
);
} else if (filter.data.type === FieldType.select) {
return (
<SingleSelectField
choices={filter.data.options.map(option => ({
...option,
value: option.value.toString()
}))}
selectProps={{
className,
inputProps: {
className: classes.input
}
}}
value={value as string}
placeholder={intl.formatMessage({
defaultMessage: "Select Filter..."
})}
onChange={event => onChange(event.target.value)}
/>
);
} else if (filter.data.type === FieldType.price) {
return (
<PriceField
currencySymbol={currencySymbol}
className={className}
label={filter.data.fieldLabel}
onChange={event => onChange(event.target.value)}
InputProps={{
classes: {
input: !filter.data.fieldLabel && classes.input
}
}}
value={value as string}
/>
);
} else if (filter.data.type === FieldType.hidden) {
onChange(filter.data.value);
return <input type="hidden" value={value} />;
}
return (
<TextField
className={className}
fullWidth
label={filter.data.fieldLabel}
InputProps={{
classes: {
input: !filter.data.fieldLabel && classes.input
}
}}
onChange={event => onChange(event.target.value)}
value={value as string}
/>
);
};
FilterElement.displayName = "FilterElement";
export default FilterElement;

View file

@ -30,14 +30,14 @@ function reduceFilter<T extends string>(
action: FilterReducerAction<T>
): IFilter<T> {
switch (action.type) {
case "clear":
return prevState;
case "set-property":
return setProperty(prevState, action.payload.name, action.payload.update);
case "reset":
return action.payload.reset;
default:
return prevState;
}
return prevState;
}
export default reduceFilter;

View file

@ -19,7 +19,6 @@ export interface IFilterElement<T extends string = string>
extends Partial<FetchMoreProps>,
IFilterElementMutableData {
autocomplete?: boolean;
currencySymbol?: string;
label: string;
name: T;
type: FieldType;

View file

@ -1,37 +1,66 @@
import React from "react";
import { useIntl } from "react-intl";
import { FormattedMessage, useIntl } from "react-intl";
import makeStyles from "@material-ui/core/styles/makeStyles";
import { FilterProps } from "../../types";
import Debounce from "../Debounce";
import { IFilter } from "../Filter/types";
import FilterTabs, { FilterChips, FilterTab } from "../TableFilter";
import FilterTabs, { FilterTab } from "../TableFilter";
import { SearchBarProps } from "../SearchBar";
import SearchInput from "../SearchBar/SearchInput";
import Filter from "../Filter";
import Link from "../Link";
import Hr from "../Hr";
export interface FilterBarProps<TKeys = string> extends FilterProps {
filterMenu: IFilter<TKeys>;
export interface FilterBarProps<TKeys extends string = string>
extends FilterProps<TKeys>,
SearchBarProps {
filterStructure: IFilter<TKeys>;
}
const FilterBar: React.FC<FilterBarProps> = ({
allTabLabel,
currencySymbol,
filterLabel,
filtersList,
filterMenu,
currentTab,
initialSearch,
searchPlaceholder,
tabs,
onAll,
onSearchChange,
onFilterAdd,
onTabChange,
onTabDelete,
onTabSave
}) => {
const useStyles = makeStyles(
theme => ({
root: {
display: "flex",
flexWrap: "wrap",
padding: theme.spacing(1, 3)
},
tabActions: {
borderBottom: `1px solid ${theme.palette.divider}`,
padding: theme.spacing(1, 3, 2),
textAlign: "right"
}
}),
{
name: "FilterBar"
}
);
const FilterBar: React.FC<FilterBarProps> = props => {
const {
allTabLabel,
currencySymbol,
filterStructure,
currentTab,
initialSearch,
searchPlaceholder,
tabs,
onAll,
onSearchChange,
onFilterChange,
onTabChange,
onTabDelete,
onTabSave
} = props;
const classes = useStyles(props);
const intl = useIntl();
const [search, setSearch] = React.useState(initialSearch);
React.useEffect(() => setSearch(initialSearch), [currentTab, initialSearch]);
const isCustom = currentTab === tabs.length + 1;
const displayTabAction = isCustom
? "save"
: currentTab === 0
? null
: "delete";
return (
<>
@ -53,34 +82,41 @@ const FilterBar: React.FC<FilterBarProps> = ({
/>
)}
</FilterTabs>
<Debounce debounceFn={onSearchChange}>
{debounceSearchChange => {
const handleSearchChange = (event: React.ChangeEvent<any>) => {
const value = event.target.value;
setSearch(value);
debounceSearchChange(value);
};
return (
<FilterChips
currencySymbol={currencySymbol}
displayTabAction={
!!initialSearch ? (isCustom ? "save" : "delete") : null
}
menu={filterMenu}
filtersList={filtersList}
filterLabel={filterLabel}
placeholder={searchPlaceholder}
search={search}
onSearchChange={handleSearchChange}
onFilterAdd={onFilterAdd}
onFilterSave={onTabSave}
isCustomSearch={isCustom}
onFilterDelete={onTabDelete}
/>
);
}}
</Debounce>
<div className={classes.root}>
<Filter
currencySymbol={currencySymbol}
menu={filterStructure}
onFilterAdd={onFilterChange}
/>
<SearchInput
initialSearch={initialSearch}
placeholder={searchPlaceholder}
onSearchChange={onSearchChange}
/>
</div>
{displayTabAction === null ? (
<Hr />
) : (
<div className={classes.tabActions}>
{displayTabAction === "save" ? (
<Link onClick={onTabSave}>
<FormattedMessage
defaultMessage="Save Custom Search"
description="button"
/>
</Link>
) : (
displayTabAction === "delete" && (
<Link onClick={onTabDelete}>
<FormattedMessage
defaultMessage="Delete Search"
description="button"
/>
</Link>
)
)}
</div>
)}
</>
);
};

View file

@ -1,8 +1,11 @@
import React from "react";
import { useIntl } from "react-intl";
import { FormattedMessage, useIntl } from "react-intl";
import makeStyles from "@material-ui/core/styles/makeStyles";
import { SearchPageProps, TabPageProps } from "@saleor/types";
import FilterTabs, { FilterTab } from "../TableFilter";
import Link from "../Link";
import Hr from "../Hr";
import SearchInput from "./SearchInput";
export interface SearchBarProps extends SearchPageProps, TabPageProps {
@ -10,6 +13,24 @@ export interface SearchBarProps extends SearchPageProps, TabPageProps {
searchPlaceholder: string;
}
const useStyles = makeStyles(
theme => ({
root: {
display: "flex",
flexWrap: "wrap",
padding: theme.spacing(1, 3)
},
tabActions: {
borderBottom: `1px solid ${theme.palette.divider}`,
padding: theme.spacing(1, 3, 2),
textAlign: "right"
}
}),
{
name: "SearchBar"
}
);
const SearchBar: React.FC<SearchBarProps> = props => {
const {
allTabLabel,
@ -23,9 +44,16 @@ const SearchBar: React.FC<SearchBarProps> = props => {
onTabDelete,
onTabSave
} = props;
const classes = useStyles(props);
const intl = useIntl();
const isCustom = currentTab === tabs.length + 1;
const displayTabAction = isCustom
? "save"
: currentTab === 0
? null
: "delete";
return (
<>
@ -47,16 +75,36 @@ const SearchBar: React.FC<SearchBarProps> = props => {
/>
)}
</FilterTabs>
<SearchInput
displaySearchAction={
!!initialSearch ? (isCustom ? "save" : "delete") : null
}
initialSearch={initialSearch}
searchPlaceholder={searchPlaceholder}
onSearchChange={onSearchChange}
onSearchDelete={onTabDelete}
onSearchSave={onTabSave}
/>
<div className={classes.root}>
<SearchInput
initialSearch={initialSearch}
placeholder={searchPlaceholder}
onSearchChange={onSearchChange}
/>
</div>
{displayTabAction === null ? (
<Hr />
) : (
<div className={classes.tabActions}>
{displayTabAction === "save" ? (
<Link onClick={onTabSave}>
<FormattedMessage
defaultMessage="Save Custom Search"
description="button"
/>
</Link>
) : (
displayTabAction === "delete" && (
<Link onClick={onTabDelete}>
<FormattedMessage
defaultMessage="Delete Search"
description="button"
/>
</Link>
)
)}
</div>
)}
</>
);
};

View file

@ -1,47 +1,31 @@
import { makeStyles } from "@material-ui/core/styles";
import React from "react";
import { FormattedMessage } from "react-intl";
import TextField from "@material-ui/core/TextField";
import { SearchPageProps } from "../../types";
import Debounce from "../Debounce";
import { FilterActionsOnlySearch } from "../Filter/FilterActions";
import Hr from "../Hr";
import Link from "../Link";
export interface SearchInputProps extends SearchPageProps {
displaySearchAction: "save" | "delete" | null;
searchPlaceholder: string;
onSearchDelete?: () => void;
onSearchSave?: () => void;
placeholder: string;
}
const useStyles = makeStyles(
theme => ({
tabAction: {
display: "inline-block"
{
input: {
padding: "10.5px 12px"
},
tabActionContainer: {
borderBottom: `1px solid ${theme.palette.divider}`,
display: "flex",
justifyContent: "flex-end",
marginTop: theme.spacing(),
padding: theme.spacing(0, 1, 3, 1)
root: {
flex: 1
}
}),
},
{
name: "SearchInput"
}
);
const SearchInput: React.FC<SearchInputProps> = props => {
const {
displaySearchAction,
initialSearch,
onSearchChange,
onSearchDelete,
onSearchSave,
searchPlaceholder
} = props;
const { initialSearch, onSearchChange, placeholder } = props;
const classes = useStyles(props);
const [search, setSearch] = React.useState(initialSearch);
React.useEffect(() => setSearch(initialSearch), [initialSearch]);
@ -56,37 +40,15 @@ const SearchInput: React.FC<SearchInputProps> = props => {
};
return (
<>
<FilterActionsOnlySearch
{...props}
placeholder={searchPlaceholder}
search={search}
onSearchChange={handleSearchChange}
/>
{!!displaySearchAction ? (
<div className={classes.tabActionContainer}>
<div className={classes.tabAction}>
{displaySearchAction === "save" ? (
<Link onClick={onSearchSave}>
<FormattedMessage
defaultMessage="Save Custom Search"
description="button"
/>
</Link>
) : (
<Link onClick={onSearchDelete}>
<FormattedMessage
defaultMessage="Delete Search"
description="button"
/>
</Link>
)}
</div>
</div>
) : (
<Hr />
)}
</>
<TextField
className={classes.root}
inputProps={{
className: classes.input,
placeholder
}}
value={search}
onChange={handleSearchChange}
/>
);
}}
</Debounce>

View file

@ -1,156 +0,0 @@
import ButtonBase from "@material-ui/core/ButtonBase";
import { makeStyles, useTheme } from "@material-ui/core/styles";
import { fade } from "@material-ui/core/styles/colorManipulator";
import Typography from "@material-ui/core/Typography";
import ClearIcon from "@material-ui/icons/Clear";
import React from "react";
import { FormattedMessage } from "react-intl";
import FilterActions, { FilterActionsProps } from "../Filter/FilterActions";
import Hr from "../Hr";
import Link from "../Link";
export interface Filter {
label: string;
onClick: () => void;
}
const useStyles = makeStyles(
theme => ({
filterButton: {
alignItems: "center",
backgroundColor: fade(theme.palette.primary.main, 0.8),
borderRadius: "19px",
display: "flex",
height: "38px",
justifyContent: "space-around",
margin: theme.spacing(0, 1, 2),
marginLeft: 0,
padding: theme.spacing(0, 2)
},
filterChipContainer: {
display: "flex",
flex: 1,
flexWrap: "wrap",
paddingTop: theme.spacing(2)
},
filterContainer: {
"& a": {
paddingBottom: 10,
paddingTop: theme.spacing(1)
},
borderBottom: `1px solid ${theme.palette.divider}`,
display: "flex",
marginTop: -theme.spacing(1),
padding: theme.spacing(0, 2)
},
filterIcon: {
color: theme.palette.common.white,
height: 16,
width: 16
},
filterIconContainer: {
WebkitAppearance: "none",
background: "transparent",
border: "none",
borderRadius: "100%",
cursor: "pointer",
height: 32,
marginRight: -13,
padding: 8,
width: 32
},
filterLabel: {
marginBottom: theme.spacing(1)
},
filterText: {
color: theme.palette.common.white,
fontSize: 14,
fontWeight: 400 as 400,
lineHeight: "38px"
}
}),
{
name: "FilterChips"
}
);
interface FilterChipProps extends FilterActionsProps {
displayTabAction: "save" | "delete" | null;
filtersList: Filter[];
search: string;
isCustomSearch: boolean;
onFilterDelete: () => void;
onFilterSave: () => void;
}
export const FilterChips: React.FC<FilterChipProps> = ({
currencySymbol,
displayTabAction,
filtersList,
menu,
filterLabel,
placeholder,
onSearchChange,
search,
onFilterAdd,
onFilterSave,
onFilterDelete
}) => {
const theme = useTheme();
const classes = useStyles({ theme });
return (
<>
<FilterActions
currencySymbol={currencySymbol}
menu={menu}
filterLabel={filterLabel}
placeholder={placeholder}
search={search}
onSearchChange={onSearchChange}
onFilterAdd={onFilterAdd}
/>
{search || (filtersList && filtersList.length > 0) ? (
<div className={classes.filterContainer}>
<div className={classes.filterChipContainer}>
{filtersList.map(filter => (
<div className={classes.filterButton} key={filter.label}>
<Typography className={classes.filterText}>
{filter.label}
</Typography>
<ButtonBase
className={classes.filterIconContainer}
onClick={filter.onClick}
>
<ClearIcon className={classes.filterIcon} />
</ButtonBase>
</div>
))}
</div>
{displayTabAction === "save" ? (
<Link onClick={onFilterSave}>
<FormattedMessage
defaultMessage="Save Custom Search"
description="button"
/>
</Link>
) : (
displayTabAction === "delete" && (
<Link onClick={onFilterDelete}>
<FormattedMessage
defaultMessage="Delete Search"
description="button"
/>
</Link>
)
)}
</div>
) : (
<Hr />
)}
</>
);
};
export default FilterChips;

View file

@ -1,4 +1,3 @@
export { default } from "./FilterTabs";
export * from "./FilterTabs";
export * from "./FilterTab";
export * from "./FilterChips";

View file

@ -1,153 +0,0 @@
import React from "react";
import { useIntl } from "react-intl";
import { FieldType, IFilter } from "@saleor/components/Filter";
import FilterBar from "@saleor/components/FilterBar";
import { FilterProps } from "@saleor/types";
import { StockAvailability } from "@saleor/types/globalTypes";
type ProductListFilterProps = Omit<
FilterProps,
"allTabLabel" | "filterLabel" | "searchPlaceholder"
>;
export enum ProductFilterKeys {
published = "published",
price = "price",
priceEqual = "priceEqual",
priceRange = "priceRange",
stock = "stock"
}
const ProductListFilter: React.FC<ProductListFilterProps> = props => {
const intl = useIntl();
const filterMenu: IFilter<ProductFilterKeys> = [
{
children: [],
data: {
additionalText: intl.formatMessage({
defaultMessage: "is set as",
description: "product status is set as"
}),
fieldLabel: intl.formatMessage({
defaultMessage: "Status",
description: "product status"
}),
options: [
{
label: intl.formatMessage({
defaultMessage: "Visible",
description: "product is visible"
}),
value: true
},
{
label: intl.formatMessage({
defaultMessage: "Hidden",
description: "product is hidden"
}),
value: false
}
],
type: FieldType.select
},
label: intl.formatMessage({
defaultMessage: "Visibility",
description: "product visibility"
}),
value: ProductFilterKeys.published
},
{
children: [],
data: {
fieldLabel: intl.formatMessage({
defaultMessage: "Stock quantity"
}),
options: [
{
label: intl.formatMessage({
defaultMessage: "Available",
description: "product status"
}),
value: StockAvailability.IN_STOCK
},
{
label: intl.formatMessage({
defaultMessage: "Out Of Stock",
description: "product status"
}),
value: StockAvailability.OUT_OF_STOCK
}
],
type: FieldType.select
},
label: intl.formatMessage({
defaultMessage: "Stock",
description: "product stock"
}),
value: ProductFilterKeys.stock
},
{
children: [
{
children: [],
data: {
additionalText: intl.formatMessage({
defaultMessage: "equals",
description: "product price"
}),
fieldLabel: null,
type: FieldType.price
},
label: intl.formatMessage({
defaultMessage: "Specific Price"
}),
value: ProductFilterKeys.priceEqual
},
{
children: [],
data: {
fieldLabel: intl.formatMessage({
defaultMessage: "Range"
}),
type: FieldType.rangePrice
},
label: intl.formatMessage({
defaultMessage: "Range"
}),
value: ProductFilterKeys.priceRange
}
],
data: {
fieldLabel: intl.formatMessage({
defaultMessage: "Price"
}),
type: FieldType.range
},
label: intl.formatMessage({
defaultMessage: "Price"
}),
value: ProductFilterKeys.price
}
];
return (
<FilterBar
{...props}
allTabLabel={intl.formatMessage({
defaultMessage: "All Products",
description: "tab name"
})}
filterMenu={filterMenu}
filterLabel={intl.formatMessage({
defaultMessage: "Select all products where:"
})}
searchPlaceholder={intl.formatMessage({
defaultMessage: "Search Products..."
})}
/>
);
};
ProductListFilter.displayName = "ProductListFilter";
export default ProductListFilter;

View file

@ -1,2 +0,0 @@
export { default } from "./ProductListFilter";
export * from "./ProductListFilter";

View file

@ -23,9 +23,13 @@ import {
PageListProps,
SortPage
} from "@saleor/types";
import FilterBar from "@saleor/components/FilterBar";
import {
createFilterStructure,
ProductFilterKeys
} from "@saleor/products/views/ProductList/filters";
import { ProductListUrlSortField } from "../../urls";
import ProductList from "../ProductList";
import ProductListFilter, { ProductFilterKeys } from "../ProductListFilter";
export interface ProductListPageProps
extends PageListProps<ProductListColumns>,
@ -55,7 +59,6 @@ export const ProductListPage: React.FC<ProductListPageProps> = props => {
currencySymbol,
currentTab,
defaultSettings,
filtersList,
gridAttributes,
availableInGridAttributes,
hasMore,
@ -67,8 +70,8 @@ export const ProductListPage: React.FC<ProductListPageProps> = props => {
onAdd,
onAll,
onFetchMore,
onFilterChange,
onSearchChange,
onFilterAdd,
onTabChange,
onTabDelete,
onTabSave,
@ -81,6 +84,8 @@ export const ProductListPage: React.FC<ProductListPageProps> = props => {
const handleSave = (columns: ProductListColumns[]) =>
onUpdateListSettings("columns", columns);
const filterStructure = createFilterStructure(intl);
const columns: ColumnPickerChoice[] = [
{
label: intl.formatMessage({
@ -140,18 +145,28 @@ export const ProductListPage: React.FC<ProductListPageProps> = props => {
</Button>
</PageHeader>
<Card>
<ProductListFilter
<FilterBar
currencySymbol={currencySymbol}
currentTab={currentTab}
filtersList={filtersList}
initialSearch={initialSearch}
onAll={onAll}
onFilterAdd={onFilterAdd}
onFilterChange={onFilterChange}
onSearchChange={onSearchChange}
onTabChange={onTabChange}
onTabDelete={onTabDelete}
onTabSave={onTabSave}
tabs={tabs}
allTabLabel={intl.formatMessage({
defaultMessage: "All Products",
description: "tab name"
})}
filterStructure={filterStructure}
filterLabel={intl.formatMessage({
defaultMessage: "Select all products where:"
})}
searchPlaceholder={intl.formatMessage({
defaultMessage: "Search Products..."
})}
/>
<ProductList
{...listProps}

View file

@ -24,10 +24,10 @@ export type ProductListUrlDialog =
| "delete"
| TabActionDialog;
export enum ProductListUrlFiltersEnum {
isPublished = "isPublished",
priceFrom = "priceFrom",
priceTo = "priceTo",
status = "status",
stockStatus = "stockStatus",
query = "query"
}
export type ProductListUrlFilters = Filters<ProductListUrlFiltersEnum>;

View file

@ -13,7 +13,6 @@ import SaveFilterTabDialog, {
import { defaultListSettings, ProductListColumns } from "@saleor/config";
import useBulkActions from "@saleor/hooks/useBulkActions";
import useListSettings from "@saleor/hooks/useListSettings";
import useLocale from "@saleor/hooks/useLocale";
import useNavigator from "@saleor/hooks/useNavigator";
import useNotifier from "@saleor/hooks/useNotifier";
import usePaginator, {
@ -26,6 +25,7 @@ import { ProductListVariables } from "@saleor/products/types/ProductList";
import { ListViews } from "@saleor/types";
import { getSortUrlVariables } from "@saleor/utils/sort";
import createDialogActionHandlers from "@saleor/utils/handlers/dialogActionHandlers";
import { IFilter } from "@saleor/components/Filter";
import ProductListPage from "../../components/ProductListPage";
import {
TypedProductBulkDeleteMutation,
@ -48,13 +48,13 @@ import {
} from "../../urls";
import {
areFiltersApplied,
createFilter,
createFilterChips,
deleteFilterTab,
getActiveFilters,
getFilterTabs,
getFilterVariables,
saveFilterTab
saveFilterTab,
ProductFilterKeys,
createFilterQueryParams
} from "./filters";
import { getSortQueryVariables } from "./sort";
@ -63,7 +63,6 @@ interface ProductListProps {
}
export const ProductList: React.FC<ProductListProps> = ({ params }) => {
const { locale } = useLocale();
const navigate = useNavigator();
const notify = useNotifier();
const paginate = usePaginator();
@ -108,17 +107,28 @@ export const ProductList: React.FC<ProductListProps> = ({ params }) => {
navigate(productListUrl(filters));
};
const changeFilterField = (filter: ProductListUrlFilters) => {
const changeFilterField = (filter: IFilter<ProductFilterKeys>) => {
reset();
navigate(
productListUrl({
...getActiveFilters(params),
...filter,
...params,
...createFilterQueryParams(filter),
activeTab: undefined
})
);
};
const handleSearchChange = (query: string) => {
reset();
navigate(
productListUrl({
...params,
activeTab: undefined,
query
})
);
};
const handleTabChange = (tab: number) => {
reset();
navigate(
@ -241,15 +251,6 @@ export const ProductList: React.FC<ProductListProps> = ({ params }) => {
.hasNextPage,
false
)}
filtersList={createFilterChips(
params,
{
currencySymbol,
locale
},
changeFilterField,
intl
)}
onAdd={() => navigate(productAddUrl)}
disabled={loading}
products={maybe(() =>
@ -337,10 +338,8 @@ export const ProductList: React.FC<ProductListProps> = ({ params }) => {
selected={listElements.length}
toggle={toggle}
toggleAll={toggleAll}
onSearchChange={query => changeFilterField({ query })}
onFilterAdd={filter =>
changeFilterField(createFilter(filter))
}
onSearchChange={handleSearchChange}
onFilterChange={filter => changeFilterField(filter)}
onTabSave={() => openModal("save-search")}
onTabDelete={() => openModal("delete-search")}
onTabChange={handleTabChange}

View file

@ -1,76 +1,12 @@
import { createIntl } from "react-intl";
import { ProductFilterKeys } from "@saleor/products/components/ProductListFilter";
import { StockAvailability } from "@saleor/types/globalTypes";
import { createFilter, createFilterChips, getFilterVariables } from "./filters";
const mockIntl = createIntl({
locale: "en"
});
describe("Create filter object", () => {
it("with price", () => {
const filter = createFilter({
name: ProductFilterKeys.priceEqual,
value: "10"
});
expect(filter).toMatchSnapshot();
});
it("with price range", () => {
const filter = createFilter({
name: ProductFilterKeys.priceEqual,
value: ["10", "20"]
});
expect(filter).toMatchSnapshot();
});
it("with publication status", () => {
const filter = createFilter({
name: ProductFilterKeys.published,
value: "false"
});
expect(filter).toMatchSnapshot();
});
it("with stock status", () => {
const filter = createFilter({
name: ProductFilterKeys.stock,
value: StockAvailability.OUT_OF_STOCK
});
expect(filter).toMatchSnapshot();
});
});
test("Crate filter chips", () => {
const chips = createFilterChips(
{
isPublished: "true",
priceFrom: "10",
priceTo: "20",
status: StockAvailability.IN_STOCK
},
{
currencySymbol: "USD",
locale: "en"
},
jest.fn(),
mockIntl as any
);
expect(chips).toMatchSnapshot();
});
import { getFilterVariables } from "./filters";
test("Get filter variables", () => {
const filter = getFilterVariables({
isPublished: "true",
priceFrom: "10",
priceTo: "20",
status: StockAvailability.IN_STOCK
status: "true",
stockStatus: StockAvailability.IN_STOCK
});
expect(filter).toMatchSnapshot();

View file

@ -1,7 +1,11 @@
import { defineMessages, IntlShape } from "react-intl";
import { IntlShape } from "react-intl";
import { FilterContentSubmitData } from "../../../components/Filter";
import { Filter } from "../../../components/TableFilter";
import { findInEnum, maybe } from "@saleor/misc";
import {
createOptionsField,
createPriceField
} from "@saleor/utils/filters/fields";
import { IFilterElement, IFilter } from "../../../components/Filter";
import {
ProductFilterInput,
StockAvailability
@ -10,204 +14,141 @@ import {
createFilterTabUtils,
createFilterUtils
} from "../../../utils/filters";
import { ProductFilterKeys } from "../../components/ProductListFilter";
import {
ProductListUrlFilters,
ProductListUrlFiltersEnum,
ProductListUrlQueryParams
} from "../../urls";
import messages from "./messages";
export const PRODUCT_FILTERS_KEY = "productFilters";
export enum ProductFilterKeys {
status = "status",
price = "price",
stock = "stock"
}
export enum ProductStatus {
PUBLISHED = "published",
HIDDEN = "hidden"
}
export function createFilterStructure(
intl: IntlShape,
params: ProductListUrlFilters
): IFilter<ProductFilterKeys> {
return [
{
...createOptionsField(
ProductFilterKeys.status,
intl.formatMessage(messages.visibility),
[ProductStatus.PUBLISHED],
false,
[
{
label: intl.formatMessage(messages.visible),
value: ProductStatus.PUBLISHED
},
{
label: intl.formatMessage(messages.hidden),
value: ProductStatus.HIDDEN
}
]
),
active: maybe(() => params.status !== undefined, false)
},
{
...createOptionsField(
ProductFilterKeys.stock,
intl.formatMessage(messages.quantity),
[StockAvailability.IN_STOCK],
false,
[
{
label: intl.formatMessage(messages.available),
value: StockAvailability.IN_STOCK
},
{
label: intl.formatMessage(messages.outOfStock),
value: StockAvailability.OUT_OF_STOCK
}
]
),
active: maybe(() => params.stockStatus !== undefined, false)
},
{
...createPriceField(
ProductFilterKeys.price,
intl.formatMessage(messages.price),
{
max: maybe(() => params.priceTo, "0"),
min: maybe(() => params.priceFrom, "0")
}
),
active: maybe(
() =>
[params.priceFrom, params.priceTo].some(field => field !== undefined),
false
)
}
];
}
export function getFilterVariables(
params: ProductListUrlFilters
): ProductFilterInput {
return {
isPublished:
params.isPublished !== undefined ? params.isPublished === "true" : null,
params.status !== undefined
? params.status === ProductStatus.PUBLISHED
: null,
price: {
gte: parseFloat(params.priceFrom),
lte: parseFloat(params.priceTo)
},
search: params.query,
stockAvailability: StockAvailability[params.status]
stockAvailability: StockAvailability[params.stockStatus]
};
}
export function createFilter(
filter: FilterContentSubmitData<ProductFilterKeys>
export function getFilterQueryParam(
filter: IFilterElement<ProductFilterKeys>
): ProductListUrlFilters {
const filterName = filter.name;
if (filterName === ProductFilterKeys.priceEqual) {
const value = filter.value as string;
return {
priceFrom: value,
priceTo: value
};
} else if (filterName === ProductFilterKeys.priceRange) {
const { value } = filter;
return {
priceFrom: value[0],
priceTo: value[1]
};
} else if (filterName === ProductFilterKeys.published) {
return {
isPublished: filter.value as string
};
} else if (filterName === ProductFilterKeys.stock) {
const value = filter.value as string;
return {
status: StockAvailability[value]
};
}
}
const { active, name, value } = filter;
const filterMessages = defineMessages({
available: {
defaultMessage: "Available",
description: "filter products by stock"
},
hidden: {
defaultMessage: "Hidden",
description: "filter products by visibility"
},
outOfStock: {
defaultMessage: "Out of stock",
description: "filter products by stock"
},
priceFrom: {
defaultMessage: "Price from {price}",
description: "filter by price"
},
priceIs: {
defaultMessage: "Price is {price}",
description: "filter by price"
},
priceTo: {
defaultMessage: "Price to {price}",
description: "filter by price"
},
published: {
defaultMessage: "Published",
description: "filter products by visibility"
}
});
if (active) {
switch (name) {
case ProductFilterKeys.price:
return {
priceFrom: value[0],
priceTo: value[1]
};
interface ProductListChipFormatData {
currencySymbol: string;
locale: string;
}
export function createFilterChips(
filters: ProductListUrlFilters,
formatData: ProductListChipFormatData,
onFilterDelete: (filters: ProductListUrlFilters) => void,
intl: IntlShape
): Filter[] {
let filterChips: Filter[] = [];
case ProductFilterKeys.status:
return {
status: (
findInEnum(value[0], ProductStatus) === ProductStatus.PUBLISHED
).toString()
};
if (!!filters.priceFrom || !!filters.priceTo) {
if (filters.priceFrom === filters.priceTo) {
filterChips = [
...filterChips,
{
label: intl.formatMessage(filterMessages.priceIs, {
price: parseFloat(filters.priceFrom).toLocaleString(
formatData.locale,
{
currency: formatData.currencySymbol,
style: "currency"
}
)
}),
onClick: () =>
onFilterDelete({
...filters,
priceFrom: undefined,
priceTo: undefined
})
}
];
} else {
if (!!filters.priceFrom) {
filterChips = [
...filterChips,
{
label: intl.formatMessage(filterMessages.priceFrom, {
price: parseFloat(filters.priceFrom).toLocaleString(
formatData.locale,
{
currency: formatData.currencySymbol,
style: "currency"
}
)
}),
onClick: () =>
onFilterDelete({
...filters,
priceFrom: undefined
})
}
];
}
if (!!filters.priceTo) {
filterChips = [
...filterChips,
{
label: intl.formatMessage(filterMessages.priceTo, {
price: parseFloat(filters.priceTo).toLocaleString(
formatData.locale,
{
currency: formatData.currencySymbol,
style: "currency"
}
)
}),
onClick: () =>
onFilterDelete({
...filters,
priceTo: undefined
})
}
];
}
case ProductFilterKeys.stock:
return {
status: findInEnum(value[0], StockAvailability)
};
}
}
if (!!filters.status) {
filterChips = [
...filterChips,
{
label:
filters.status === StockAvailability.IN_STOCK.toString()
? intl.formatMessage(filterMessages.available)
: intl.formatMessage(filterMessages.outOfStock),
onClick: () =>
onFilterDelete({
...filters,
status: undefined
})
}
];
}
if (!!filters.isPublished) {
filterChips = [
...filterChips,
{
label: !!filters.isPublished
? intl.formatMessage(filterMessages.published)
: intl.formatMessage(filterMessages.hidden),
onClick: () =>
onFilterDelete({
...filters,
isPublished: undefined
})
}
];
}
return filterChips;
}
export function createFilterQueryParams(
filter: IFilter<ProductFilterKeys>
): ProductListUrlFilters {
return filter.reduce(
(acc, filterField) => ({
...acc,
...getFilterQueryParam(filterField)
}),
{}
);
}
export const {

View file

@ -0,0 +1,33 @@
import { defineMessages } from "react-intl";
const messages = defineMessages({
available: {
defaultMessage: "Available",
description: "product status"
},
hidden: {
defaultMessage: "Hidden",
description: "product is hidden"
},
outOfStock: {
defaultMessage: "Out Of Stock",
description: "product status"
},
price: {
defaultMessage: "Price"
},
quantity: {
defaultMessage: "Stock quantity",
description: "product"
},
visibility: {
defaultMessage: "Visibility",
description: "product visibility"
},
visible: {
defaultMessage: "Visible",
description: "product is visible"
}
});
export default messages;

View file

@ -1,6 +1,7 @@
import Card from "@material-ui/core/Card";
import React from "react";
import { IntlShape, useIntl } from "react-intl";
import makeStyles from "@material-ui/core/styles/makeStyles";
import AppHeader from "@saleor/components/AppHeader";
import Container from "@saleor/components/Container";
@ -30,6 +31,24 @@ export interface TranslationsEntitiesFilters {
onProductTypesTabClick: () => void;
}
const useStyles = makeStyles(
theme => ({
root: {
display: "flex",
flexWrap: "wrap",
padding: theme.spacing(1, 3)
},
tabActions: {
borderBottom: `1px solid ${theme.palette.divider}`,
padding: theme.spacing(1, 3, 2),
textAlign: "right"
}
}),
{
name: "FilterActions"
}
);
export type TranslationsEntitiesListFilterTab = keyof typeof TranslatableEntities;
function getSearchPlaceholder(
@ -87,13 +106,10 @@ const tabs: TranslationsEntitiesListFilterTab[] = [
"productTypes"
];
const TranslationsEntitiesListPage: React.FC<TranslationsEntitiesListPageProps> = ({
filters,
language,
onBack,
children,
...searchProps
}) => {
const TranslationsEntitiesListPage: React.FC<TranslationsEntitiesListPageProps> = props => {
const { filters, language, onBack, children, ...searchProps } = props;
const classes = useStyles(props);
const intl = useIntl();
const currentTab = tabs.indexOf(filters.current);
@ -160,11 +176,12 @@ const TranslationsEntitiesListPage: React.FC<TranslationsEntitiesListPageProps>
onClick={filters.onProductTypesTabClick}
/>
</FilterTabs>
<SearchInput
displaySearchAction={null}
searchPlaceholder={getSearchPlaceholder(filters.current, intl)}
{...searchProps}
/>
<div className={classes.root}>
<SearchInput
placeholder={getSearchPlaceholder(filters.current, intl)}
{...searchProps}
/>
</div>
{children}
</Card>
</Container>

View file

@ -1,9 +1,8 @@
import { MutationResult } from "react-apollo";
import { User_permissions } from "./auth/types/User";
import { FilterContentSubmitData } from "./components/Filter";
import { Filter } from "./components/TableFilter";
import { ConfirmButtonTransitionState } from "./components/ConfirmButton";
import { IFilter } from "./components/Filter";
export interface UserError {
field: string;
@ -83,22 +82,20 @@ export interface SearchPageProps {
initialSearch: string;
onSearchChange: (value: string) => void;
}
export interface FilterPageProps<TKeys = string>
export interface FilterPageProps<TKeys extends string>
extends SearchPageProps,
TabPageProps {
currencySymbol: string;
filtersList: Filter[];
onFilterAdd: (filter: FilterContentSubmitData<TKeys>) => void;
onFilterChange: (filter: IFilter<TKeys>) => void;
}
export interface SearchProps {
searchPlaceholder: string;
}
export interface FilterProps<TKeys = string>
export interface FilterProps<TKeys extends string>
extends FilterPageProps<TKeys>,
SearchProps {
allTabLabel: string;
filterLabel: string;
currencySymbol: string;
}
export interface TabPageProps {

View file

@ -6,14 +6,12 @@ type MinMax = Record<"min" | "max", string>;
export function createPriceField<T extends string>(
name: T,
label: string,
currencySymbol: string,
defaultValue: MinMax
): IFilterElement<T> {
return {
active: false,
currencySymbol,
label,
multiple: true,
multiple: defaultValue.min !== defaultValue.max,
name,
type: FieldType.price,
value: [defaultValue.min, defaultValue.max]