Add category and collection filter

This commit is contained in:
dominik-zeglen 2020-01-15 16:36:45 +01:00
parent 525ce272e3
commit aeb209744a
17 changed files with 490 additions and 35 deletions

View file

@ -12,11 +12,16 @@ import { fade } from "@material-ui/core/styles/colorManipulator";
import { buttonMessages } from "@saleor/intl";
import { TextField } from "@material-ui/core";
import { toggle } from "@saleor/utils/lists";
import createMultiAutocompleteSelectHandler from "@saleor/utils/handlers/multiAutocompleteSelectChangeHandler";
import useStateFromProps from "@saleor/hooks/useStateFromProps";
import Hr from "../Hr";
import Checkbox from "../Checkbox";
import SingleSelectField from "../SingleSelectField";
import { SingleAutocompleteChoiceType } from "../SingleAutocompleteSelectField";
import FormSpacer from "../FormSpacer";
import MultiAutocompleteSelectField, {
MultiAutocompleteChoiceType
} from "../MultiAutocompleteSelectField";
import { IFilter, FieldType, FilterType } from "./types";
import Arrow from "./Arrow";
import { FilterReducerAction } from "./reducer";
@ -107,6 +112,18 @@ const FilterContent: React.FC<FilterContentProps> = ({
}) => {
const intl = useIntl();
const classes = useStyles({});
const [
autocompleteDisplayValues,
setAutocompleteDisplayValues
] = useStateFromProps<Record<string, MultiAutocompleteChoiceType[]>>(
filters.reduce((acc, filterField) => {
if (filterField.type === FieldType.autocomplete) {
acc[filterField.name] = filterField.displayValues;
}
return acc;
}, {})
);
return (
<Paper>
@ -409,6 +426,54 @@ const FilterContent: React.FC<FilterContentProps> = ({
/>
</div>
))}
{filterField.type === FieldType.autocomplete &&
filterField.multiple && (
<MultiAutocompleteSelectField
displayValues={
autocompleteDisplayValues[filterField.name]
}
label={filterField.label}
choices={filterField.options}
name={filterField.name}
value={filterField.value}
// helperText={intl.formatMessage({
// defaultMessage:
// "*Optional. Adding product to collection helps users find it.",
// description: "field is optional"
// })}
onChange={createMultiAutocompleteSelectHandler(
event =>
onFilterPropertyChange({
payload: {
name: filterField.name,
update: {
value: toggle(
event.target.value,
filterField.value,
(a, b) => a === b
)
}
},
type: "set-property"
}),
value =>
setAutocompleteDisplayValues({
...autocompleteDisplayValues,
[filterField.name]: toggle(
value[0],
autocompleteDisplayValues[filterField.name],
(a, b) => a.value === b.value
)
}),
[],
filterField.options
)}
fetchChoices={filterField.onSearchChange}
loading={filterField.loading}
data-tc={filterField.name}
key={filterField.name}
/>
)}
</div>
)}
</React.Fragment>

View file

@ -1,16 +1,39 @@
import { update } from "@saleor/utils/lists";
import { IFilter, IFilterElementMutableData } from "./types";
export type FilterReducerActionType = "clear" | "reset" | "set-property";
export type FilterReducerActionType =
| "clear"
| "merge"
| "reset"
| "set-property";
export interface FilterReducerAction<T extends string> {
type: FilterReducerActionType;
payload: Partial<{
name: T;
update: Partial<IFilterElementMutableData>;
reset: IFilter<T>;
new: IFilter<T>;
}>;
}
function merge<T extends string>(
prevState: IFilter<T>,
newState: IFilter<T>
): IFilter<T> {
return newState.map(newFilter => {
const prevFilter = prevState.find(
prevFilter => prevFilter.name === newFilter.name
);
if (!!prevFilter) {
return {
...newFilter,
active: prevFilter.active
};
}
return newFilter;
});
}
function setProperty<T extends string>(
prevState: IFilter<T>,
filter: T,
@ -32,8 +55,10 @@ function reduceFilter<T extends string>(
switch (action.type) {
case "set-property":
return setProperty(prevState, action.payload.name, action.payload.update);
case "merge":
return merge(prevState, action.payload.new);
case "reset":
return action.payload.reset;
return action.payload.new;
default:
return prevState;

View file

@ -1,7 +1,8 @@
import { FetchMoreProps } from "@saleor/types";
import { FetchMoreProps, SearchPageProps } from "@saleor/types";
import { MultiAutocompleteChoiceType } from "../MultiAutocompleteSelectField";
export enum FieldType {
autocomplete,
boolean,
date,
dateTime,
@ -18,9 +19,10 @@ export interface IFilterElementMutableData<T extends string = string> {
value: T[];
}
export interface IFilterElement<T extends string = string>
extends Partial<FetchMoreProps>,
IFilterElementMutableData {
extends IFilterElementMutableData,
Partial<FetchMoreProps & SearchPageProps> {
autocomplete?: boolean;
displayValues?: MultiAutocompleteChoiceType[];
label: string;
name: T;
type: FieldType;

View file

@ -17,12 +17,20 @@ function useFilter<T extends string>(initialFilter: IFilter<T>): UseFilter<T> {
const reset = () =>
dispatchFilterAction({
payload: {
reset: initialFilter
new: initialFilter
},
type: "reset"
});
useEffect(reset, [initialFilter]);
const refresh = () =>
dispatchFilterAction({
payload: {
new: initialFilter
},
type: "merge"
});
useEffect(refresh, [initialFilter]);
return [data, dispatchFilterAction, reset];
}

View file

@ -41,17 +41,14 @@ export interface SingleAutocompleteSelectFieldProps
onChange: (event: React.ChangeEvent<any>) => void;
}
const DebounceAutocomplete: React.ComponentType<
DebounceProps<string>
> = Debounce;
const DebounceAutocomplete: React.ComponentType<DebounceProps<
string
>> = Debounce;
const SingleAutocompleteSelectFieldComponent: React.FC<
SingleAutocompleteSelectFieldProps
> = props => {
const SingleAutocompleteSelectFieldComponent: React.FC<SingleAutocompleteSelectFieldProps> = props => {
const {
choices,
allowCustomValues,
choices,
disabled,
displayValue,
emptyOption,
@ -169,9 +166,11 @@ const SingleAutocompleteSelectFieldComponent: React.FC<
);
};
const SingleAutocompleteSelectField: React.FC<
SingleAutocompleteSelectFieldProps
> = ({ choices, fetchChoices, ...rest }) => {
const SingleAutocompleteSelectField: React.FC<SingleAutocompleteSelectFieldProps> = ({
choices,
fetchChoices,
...rest
}) => {
const [query, setQuery] = React.useState("");
if (fetchChoices) {
return (

View file

@ -7,8 +7,7 @@ import {
} from "@saleor/utils/filters/fields";
import {
VoucherDiscountType,
DiscountStatusEnum,
DiscountValueTypeEnum
DiscountStatusEnum
} from "@saleor/types/globalTypes";
import { MinMax, FilterOpts } from "@saleor/types";
import { IFilter } from "@saleor/components/Filter";
@ -118,11 +117,15 @@ export function createFilterStructure(
[
{
label: intl.formatMessage(messages.fixed),
value: DiscountValueTypeEnum.FIXED
value: VoucherDiscountType.FIXED
},
{
label: intl.formatMessage(messages.percentage),
value: DiscountValueTypeEnum.PERCENTAGE
value: VoucherDiscountType.PERCENTAGE
},
{
label: intl.formatMessage(messages.percentage),
value: VoucherDiscountType.SHIPPING
}
]
),

View file

@ -1,20 +1,26 @@
import { defineMessages, IntlShape } from "react-intl";
import { FilterOpts, MinMax } from "@saleor/types";
import { FilterOpts, MinMax, AutocompleteFilterOpts } from "@saleor/types";
import { StockAvailability } from "@saleor/types/globalTypes";
import {
createOptionsField,
createPriceField
createPriceField,
createAutocompleteField
} from "@saleor/utils/filters/fields";
import { IFilter } from "@saleor/components/Filter";
import { sectionNames } from "@saleor/intl";
export enum ProductFilterKeys {
categories = "categories",
collections = "collections",
status = "status",
price = "price",
stock = "stock"
}
export interface ProductListFilterOpts {
categories: FilterOpts<string[]> & AutocompleteFilterOpts;
collections: FilterOpts<string[]> & AutocompleteFilterOpts;
price: FilterOpts<MinMax>;
status: FilterOpts<ProductStatus>;
stockStatus: FilterOpts<StockAvailability>;
@ -105,6 +111,42 @@ export function createFilterStructure(
opts.price.value
),
active: opts.price.active
},
{
...createAutocompleteField(
ProductFilterKeys.categories,
intl.formatMessage(sectionNames.categories),
opts.categories.value,
opts.categories.displayValues,
true,
opts.categories.choices,
{
hasMore: opts.categories.hasMore,
initialSearch: "",
loading: opts.categories.loading,
onFetchMore: opts.categories.onFetchMore,
onSearchChange: opts.categories.onSearchChange
}
),
active: opts.categories.active
},
{
...createAutocompleteField(
ProductFilterKeys.collections,
intl.formatMessage(sectionNames.collections),
opts.collections.value,
opts.collections.displayValues,
true,
opts.collections.choices,
{
hasMore: opts.collections.hasMore,
initialSearch: "",
loading: opts.collections.loading,
onFetchMore: opts.collections.onFetchMore,
onSearchChange: opts.collections.onSearchChange
}
),
active: opts.collections.active
}
];
}

View file

@ -1,5 +1,6 @@
import gql from "graphql-tag";
import makeQuery from "@saleor/hooks/makeQuery";
import { pageInfoFragment, TypedQuery } from "../queries";
import {
AvailableInGridAttributes,
@ -22,6 +23,10 @@ import {
ProductVariantDetails,
ProductVariantDetailsVariables
} from "./types/ProductVariantDetails";
import {
InitialProductFilterData,
InitialProductFilterDataVariables
} from "./types/InitialProductFilterData";
export const fragmentMoney = gql`
fragment Money on Money {
@ -209,6 +214,31 @@ export const fragmentVariant = gql`
}
`;
const initialProductFilterDataQuery = gql`
query InitialProductFilterData($categories: [ID!], $collections: [ID!]) {
categories(first: 20, filter: { ids: $categories }) {
edges {
node {
id
name
}
}
}
collections(first: 20, filter: { ids: $collections }) {
edges {
node {
id
name
}
}
}
}
`;
export const useInitialProductFilterDataQuery = makeQuery<
InitialProductFilterData,
InitialProductFilterDataVariables
>(initialProductFilterDataQuery);
const productListQuery = gql`
${productFragment}
query ProductList(

View file

@ -0,0 +1,49 @@
/* tslint:disable */
/* eslint-disable */
// This file was automatically generated and should not be edited.
// ====================================================
// GraphQL query operation: InitialProductFilterData
// ====================================================
export interface InitialProductFilterData_categories_edges_node {
__typename: "Category";
id: string;
name: string;
}
export interface InitialProductFilterData_categories_edges {
__typename: "CategoryCountableEdge";
node: InitialProductFilterData_categories_edges_node;
}
export interface InitialProductFilterData_categories {
__typename: "CategoryCountableConnection";
edges: InitialProductFilterData_categories_edges[];
}
export interface InitialProductFilterData_collections_edges_node {
__typename: "Collection";
id: string;
name: string;
}
export interface InitialProductFilterData_collections_edges {
__typename: "CollectionCountableEdge";
node: InitialProductFilterData_collections_edges_node;
}
export interface InitialProductFilterData_collections {
__typename: "CollectionCountableConnection";
edges: InitialProductFilterData_collections_edges[];
}
export interface InitialProductFilterData {
categories: InitialProductFilterData_categories | null;
collections: InitialProductFilterData_collections | null;
}
export interface InitialProductFilterDataVariables {
categories?: string[] | null;
collections?: string[] | null;
}

View file

@ -9,7 +9,8 @@ import {
Filters,
Pagination,
Sort,
TabActionDialog
TabActionDialog,
FiltersWithMultipleValues
} from "../types";
const productSection = "/products/";
@ -30,7 +31,12 @@ export enum ProductListUrlFiltersEnum {
stockStatus = "stockStatus",
query = "query"
}
export type ProductListUrlFilters = Filters<ProductListUrlFiltersEnum>;
export enum ProductListUrlFiltersWithMultipleValues {
categories = "categories",
collections = "collections"
}
export type ProductListUrlFilters = Filters<ProductListUrlFiltersEnum> &
FiltersWithMultipleValues<ProductListUrlFiltersWithMultipleValues>;
export enum ProductListUrlSortField {
attribute = "attribute",
name = "name",

View file

@ -10,7 +10,11 @@ import DeleteFilterTabDialog from "@saleor/components/DeleteFilterTabDialog";
import SaveFilterTabDialog, {
SaveFilterTabDialogFormData
} from "@saleor/components/SaveFilterTabDialog";
import { defaultListSettings, ProductListColumns } from "@saleor/config";
import {
defaultListSettings,
ProductListColumns,
DEFAULT_INITIAL_SEARCH_DATA
} from "@saleor/config";
import useBulkActions from "@saleor/hooks/useBulkActions";
import useListSettings from "@saleor/hooks/useListSettings";
import useNavigator from "@saleor/hooks/useNavigator";
@ -26,6 +30,8 @@ import { ListViews } from "@saleor/types";
import { getSortUrlVariables } from "@saleor/utils/sort";
import createDialogActionHandlers from "@saleor/utils/handlers/dialogActionHandlers";
import createFilterHandlers from "@saleor/utils/handlers/filterHandlers";
import useCategorySearch from "@saleor/searches/useCategorySearch";
import useCollectionSearch from "@saleor/searches/useCollectionSearch";
import ProductListPage from "../../components/ProductListPage";
import {
TypedProductBulkDeleteMutation,
@ -33,7 +39,8 @@ import {
} from "../../mutations";
import {
AvailableInGridAttributesQuery,
TypedProductListQuery
TypedProductListQuery,
useInitialProductFilterDataQuery
} from "../../queries";
import { productBulkDelete } from "../../types/productBulkDelete";
import { productBulkPublish } from "../../types/productBulkPublish";
@ -73,6 +80,19 @@ export const ProductList: React.FC<ProductListProps> = ({ params }) => {
ListViews.PRODUCT_LIST
);
const intl = useIntl();
const { data: initialFilterData } = useInitialProductFilterDataQuery({
skip: !(!!params.categories || !!params.collections),
variables: {
categories: params.categories,
collections: params.collections
}
});
const searchCategories = useCategorySearch({
variables: DEFAULT_INITIAL_SEARCH_DATA
});
const searchCollections = useCollectionSearch({
variables: DEFAULT_INITIAL_SEARCH_DATA
});
React.useEffect(
() =>
@ -156,6 +176,24 @@ export const ProductList: React.FC<ProductListProps> = ({ params }) => {
[params, settings.rowNumber]
);
const filterOpts = getFilterOpts(
params,
{
initial: maybe(
() => initialFilterData.categories.edges.map(edge => edge.node),
[]
),
search: searchCategories
},
{
initial: maybe(
() => initialFilterData.collections.edges.map(edge => edge.node),
[]
),
search: searchCollections
}
);
return (
<AvailableInGridAttributesQuery
variables={{ first: 6, ids: settings.columns }}
@ -218,7 +256,7 @@ export const ProductList: React.FC<ProductListProps> = ({ params }) => {
defaultSettings={
defaultListSettings[ListViews.PRODUCT_LIST]
}
filterOpts={getFilterOpts(params)}
filterOpts={filterOpts}
gridAttributes={maybe(
() =>
attributes.data.grid.edges.map(edge => edge.node),

View file

@ -2,6 +2,12 @@
exports[`Filtering URL params should not be empty if active filters are present 1`] = `
Object {
"categories": Array [
"878752",
],
"collections": Array [
"Q29sbGVjdGlvbjoc",
],
"priceFrom": "10",
"priceTo": "20",
"status": "published",
@ -9,4 +15,4 @@ Object {
}
`;
exports[`Filtering URL params should not be empty if active filters are present 2`] = `"status=published&stockStatus=IN_STOCK&priceFrom=10&priceTo=20"`;
exports[`Filtering URL params should not be empty if active filters are present 2`] = `"status=published&stockStatus=IN_STOCK&priceFrom=10&priceTo=20&categories%5B0%5D=878752&collections%5B0%5D=Q29sbGVjdGlvbjoc"`;

View file

@ -10,6 +10,9 @@ import { getFilterQueryParams } from "@saleor/utils/filters";
import { getExistingKeys, setFilterOptsStatus } from "@test/filters";
import { config } from "@test/intl";
import { StockAvailability } from "@saleor/types/globalTypes";
import { categories } from "@saleor/categories/fixtures";
import { fetchMoreProps, searchPageProps } from "@saleor/fixtures";
import { collections } from "@saleor/collections/fixtures";
import { getFilterVariables, getFilterQueryParam } from "./filters";
describe("Filtering query params", () => {
@ -37,6 +40,38 @@ describe("Filtering URL params", () => {
const intl = createIntl(config);
const filters = createFilterStructure(intl, {
categories: {
...fetchMoreProps,
...searchPageProps,
active: false,
choices: categories.slice(5).map(category => ({
label: category.name,
value: category.id
})),
displayValues: [
{
label: categories[5].name,
value: categories[5].id
}
],
value: [categories[5].id]
},
collections: {
...fetchMoreProps,
...searchPageProps,
active: false,
choices: collections.slice(5).map(category => ({
label: category.name,
value: category.id
})),
displayValues: [
{
label: collections[5].name,
value: collections[5].id
}
],
value: [collections[5].id]
},
price: {
active: false,
value: {

View file

@ -4,6 +4,19 @@ import {
ProductListFilterOpts,
ProductStatus
} from "@saleor/products/components/ProductListPage";
import { UseSearchResult } from "@saleor/hooks/makeSearch";
import {
SearchCategories,
SearchCategoriesVariables
} from "@saleor/searches/types/SearchCategories";
import {
InitialProductFilterData_categories_edges_node,
InitialProductFilterData_collections_edges_node
} from "@saleor/products/types/InitialProductFilterData";
import {
SearchCollections,
SearchCollectionsVariables
} from "@saleor/searches/types/SearchCollections";
import { IFilterElement } from "../../../components/Filter";
import {
ProductFilterInput,
@ -14,20 +27,87 @@ import {
createFilterUtils,
getGteLteVariables,
getMinMaxQueryParam,
getSingleEnumValueQueryParam
getSingleEnumValueQueryParam,
dedupeFilter,
getMultipleValueQueryParam
} from "../../../utils/filters";
import {
ProductListUrlFilters,
ProductListUrlFiltersEnum,
ProductListUrlQueryParams
ProductListUrlQueryParams,
ProductListUrlFiltersWithMultipleValues
} from "../../urls";
export const PRODUCT_FILTERS_KEY = "productFilters";
export function getFilterOpts(
params: ProductListUrlFilters
params: ProductListUrlFilters,
categories: {
initial: InitialProductFilterData_categories_edges_node[];
search: UseSearchResult<SearchCategories, SearchCategoriesVariables>;
},
collections: {
initial: InitialProductFilterData_collections_edges_node[];
search: UseSearchResult<SearchCollections, SearchCollectionsVariables>;
}
): ProductListFilterOpts {
return {
categories: {
active: !!params.categories,
choices: maybe(
() =>
categories.search.result.data.search.edges.map(edge => ({
label: edge.node.name,
value: edge.node.id
})),
[]
),
displayValues: maybe(
() =>
categories.initial.map(category => ({
label: category.name,
value: category.id
})),
[]
),
hasMore: maybe(
() => categories.search.result.data.search.pageInfo.hasNextPage,
false
),
initialSearch: "",
loading: categories.search.result.loading,
onFetchMore: categories.search.loadMore,
onSearchChange: categories.search.search,
value: maybe(() => dedupeFilter(params.categories), [])
},
collections: {
active: !!params.collections,
choices: maybe(
() =>
collections.search.result.data.search.edges.map(edge => ({
label: edge.node.name,
value: edge.node.id
})),
[]
),
displayValues: maybe(
() =>
collections.initial.map(category => ({
label: category.name,
value: category.id
})),
[]
),
hasMore: maybe(
() => collections.search.result.data.search.pageInfo.hasNextPage,
false
),
initialSearch: "",
loading: collections.search.result.loading,
onFetchMore: collections.search.loadMore,
onSearchChange: collections.search.search,
value: maybe(() => dedupeFilter(params.collections), [])
},
price: {
active: maybe(
() =>
@ -54,6 +134,8 @@ export function getFilterVariables(
params: ProductListUrlFilters
): ProductFilterInput {
return {
categories: params.categories !== undefined ? params.categories : null,
collections: params.collections !== undefined ? params.collections : null,
isPublished:
params.status !== undefined
? params.status === ProductStatus.PUBLISHED
@ -76,6 +158,18 @@ export function getFilterQueryParam(
const { name } = filter;
switch (name) {
case ProductFilterKeys.categories:
return getMultipleValueQueryParam(
filter,
ProductListUrlFiltersWithMultipleValues.categories
);
case ProductFilterKeys.collections:
return getMultipleValueQueryParam(
filter,
ProductListUrlFiltersWithMultipleValues.collections
);
case ProductFilterKeys.price:
return getMinMaxQueryParam(
filter,

View file

@ -3,6 +3,7 @@ import { MutationResult } from "react-apollo";
import { User_permissions } from "./auth/types/User";
import { ConfirmButtonTransitionState } from "./components/ConfirmButton";
import { IFilter } from "./components/Filter";
import { MultiAutocompleteChoiceType } from "./components/MultiAutocompleteSelectField";
export interface UserError {
field: string;
@ -176,3 +177,10 @@ export interface FilterOpts<T> {
active: boolean;
value: T;
}
export interface AutocompleteFilterOpts
extends FetchMoreProps,
SearchPageProps {
choices: MultiAutocompleteChoiceType[];
displayValues: MultiAutocompleteChoiceType[];
}

View file

@ -1,6 +1,6 @@
import { IFilterElement, FieldType } from "@saleor/components/Filter";
import { MultiAutocompleteChoiceType } from "@saleor/components/MultiAutocompleteSelectField";
import { MinMax } from "@saleor/types";
import { MinMax, FetchMoreProps, SearchPageProps } from "@saleor/types";
export function createPriceField<T extends string>(
name: T,
@ -65,6 +65,28 @@ export function createOptionsField<T extends string>(
};
}
export function createAutocompleteField<T extends string>(
name: T,
label: string,
defaultValue: string[],
displayValues: MultiAutocompleteChoiceType[],
multiple: boolean,
options: MultiAutocompleteChoiceType[],
opts: FetchMoreProps & SearchPageProps
): IFilterElement<T> {
return {
...opts,
active: false,
displayValues,
label,
multiple,
name,
options,
type: FieldType.autocomplete,
value: defaultValue
};
}
export function createTextField<T extends string>(
name: T,
label: string,

View file

@ -1,3 +1,5 @@
import isArray from "lodash-es/isArray";
import { IFilterElement, IFilter } from "@saleor/components/Filter";
import { findValueInEnum } from "@saleor/misc";
@ -25,6 +27,10 @@ function createFilterUtils<
}
export function dedupeFilter<T>(array: T[]): T[] {
if (!isArray(array)) {
return [array];
}
return Array.from(new Set(array));
}
@ -110,6 +116,23 @@ export function getMultipleEnumValueQueryParam<
};
}
export function getMultipleValueQueryParam<
TKey extends string,
TUrlKey extends string
>(param: IFilterElement<TKey>, key: TUrlKey) {
const { active, value } = param;
if (!active) {
return {
[key]: undefined
};
}
return {
[key]: value
};
}
export function getMinMaxQueryParam<
TKey extends string,
TUrlKey extends string