Saleor 3087 Paginate attribute values in filters (#1152)

* Dynamic fetch attribute values in filter list

* Update filter attributes fixtures

* Change attribute values filter to autocomplete field

* Fix unchecking attribute value filter failure

* Update test snapshots

* Update changelog

* Fix cypress tests

* Add slug node mapping util
This commit is contained in:
Dawid Tarasiuk 2021-06-14 15:31:41 +02:00 committed by GitHub
parent 99aa6365be
commit c3e720a47e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 2145 additions and 941 deletions

View file

@ -48,6 +48,7 @@ All notable, unreleased changes to this project will be documented in this file.
- Choosing user shipping and billing addresses for draft order - #1082 by @orzechdev
- Fix EditorJS inline formatting - #1096 by @orzechdev
- Add pagination on attribute values - #1112 by @orzechdev
- Paginate attribute values in filters - #1152 by @orzechdev
- Fix attribute values input display - #1156 by @orzechdev
# 2.11.1

View file

@ -21,10 +21,11 @@ export const PRODUCTS_LIST = {
filterOption: '[data-test-id="filterOption"]',
productsOutOfStockOption: '[data-test-id="OUT_OF_STOCK"]',
filterBy: {
category: '[data-test-id="categories"]',
collection: '[data-test-id="collections"]',
productType: '[data-test-id="productType"]',
stock: '[data-test-id="stock"]'
category: '[data-test="filterGroupActive"][data-test-id="categories"]',
collection: '[data-test="filterGroupActive"][data-test-id="collections"]',
productType:
'[data-test="filterGroupActive"][data-test-id="productType"]',
stock: '[data-test="filterGroupActive"][data-test-id="stock"]'
},
filterBySearchInput: '[data-test*="filterField"][data-test*="Input"]'
},

View file

@ -1666,6 +1666,7 @@ input CustomerFilterInput {
numberOfOrders: IntRangeInput
placedOrders: DateRangeInput
search: String
metadata: [MetadataInput]
}
input CustomerInput {
@ -6132,10 +6133,11 @@ enum WebhookSampleEventTypeEnum {
PAGE_DELETED
PAYMENT_AUTHORIZE
PAYMENT_CAPTURE
PAYMENT_CONFIRM
PAYMENT_LIST_GATEWAYS
PAYMENT_PROCESS
PAYMENT_REFUND
PAYMENT_VOID
PAYMENT_CONFIRM
PAYMENT_PROCESS
}
type WebhookUpdate {

View file

@ -21,6 +21,7 @@ export interface FilterProps<TFilterKeys extends string = string> {
errorMessages?: FilterErrorMessages<TFilterKeys>;
menu: IFilter<TFilterKeys>;
onFilterAdd: (filter: Array<IFilterElement<string>>) => void;
onFilterAttributeFocus?: (id?: string) => void;
}
const useStyles = makeStyles(
@ -91,7 +92,13 @@ const useStyles = makeStyles(
{ name: "Filter" }
);
const Filter: React.FC<FilterProps> = props => {
const { currencySymbol, menu, onFilterAdd, errorMessages } = props;
const {
currencySymbol,
menu,
onFilterAdd,
onFilterAttributeFocus,
errorMessages
} = props;
const classes = useStyles(props);
const anchor = React.useRef<HTMLDivElement>();
@ -190,6 +197,7 @@ const Filter: React.FC<FilterProps> = props => {
filters={data}
onClear={reset}
onFilterPropertyChange={dispatch}
onFilterAttributeFocus={onFilterAttributeFocus}
onSubmit={handleSubmit}
/>
</Grow>

View file

@ -1,8 +1,15 @@
import { Paper, Typography } from "@material-ui/core";
import {
ExpansionPanel,
ExpansionPanelSummary,
makeStyles,
Paper,
Typography
} from "@material-ui/core";
import CollectionWithDividers from "@saleor/components/CollectionWithDividers";
import Hr from "@saleor/components/Hr";
import useStateFromProps from "@saleor/hooks/useStateFromProps";
import React from "react";
import IconChevronDown from "@saleor/icons/ChevronDown";
import React, { useState } from "react";
import { FilterAutocompleteDisplayValues } from "../FilterAutocompleteField";
import { FilterReducerAction } from "../reducer";
@ -18,9 +25,57 @@ import FilterContentBodyNameField from "./FilterContentBodyNameField";
import FilterContentHeader from "./FilterContentHeader";
import FilterErrorsList from "./FilterErrorsList";
const useExpanderStyles = makeStyles(
() => ({
expanded: {},
root: {
boxShadow: "none",
margin: 0,
padding: 0,
"&:before": {
content: "none"
},
"&$expanded": {
margin: 0,
border: "none"
}
}
}),
{ name: "FilterContentExpander" }
);
const useSummaryStyles = makeStyles(
theme => ({
expanded: {},
root: {
width: "100%",
border: "none",
margin: 0,
padding: 0,
minHeight: 0,
paddingRight: theme.spacing(2),
"&$expanded": {
minHeight: 0
}
},
content: {
margin: 0,
"&$expanded": {
margin: 0
}
}
}),
{ name: "FilterContentExpanderSummary" }
);
export interface FilterContentProps<T extends string = string> {
filters: IFilter<T>;
onFilterPropertyChange: React.Dispatch<FilterReducerAction<T>>;
onFilterAttributeFocus?: (id?: string) => void;
onClear: () => void;
onSubmit: () => void;
currencySymbol?: string;
@ -36,9 +91,15 @@ const FilterContent: React.FC<FilterContentProps> = ({
filters,
onClear,
onFilterPropertyChange,
onFilterAttributeFocus,
onSubmit,
dataStructure
}) => {
const expanderClasses = useExpanderStyles({});
const summaryClasses = useSummaryStyles({});
const [openedFilter, setOpenedFilter] = useState<IFilterElement<string>>();
const getAutocompleteValuesWithNewValues = (
autocompleteDisplayValues: FilterAutocompleteDisplayValues,
filterField: IFilterElement<string>
@ -84,6 +145,36 @@ const FilterContent: React.FC<FilterContentProps> = ({
initialAutocompleteDisplayValues
};
const handleFilterAttributeFocus = (filter?: IFilterElement<string>) => {
setOpenedFilter(filter);
if (onFilterAttributeFocus) {
onFilterAttributeFocus(filter?.id);
}
};
const handleFilterOpen = (filter: IFilterElement<string>) => {
if (filter.name !== openedFilter?.name) {
handleFilterAttributeFocus(filter);
} else {
handleFilterAttributeFocus(undefined);
}
};
const handleFilterPropertyGroupChange = function<T extends string>(
action: FilterReducerAction<T>,
filter: IFilterElement<string>
) {
const switchToActive = action.payload.update.active;
if (switchToActive && filter.name !== openedFilter?.name) {
handleFilterAttributeFocus(filter);
} else if (!switchToActive && filter.name === openedFilter?.name) {
handleFilterAttributeFocus(undefined);
}
onFilterPropertyChange(action);
};
const handleMultipleFieldPropertyChange = function<T extends string>(
action: FilterReducerAction<T>
) {
@ -114,11 +205,24 @@ const FilterContent: React.FC<FilterContentProps> = ({
{dataStructure
.sort((a, b) => (a.name > b.name ? 1 : -1))
.map(filter => (
<React.Fragment key={filter.name}>
<ExpansionPanel
key={filter.name}
classes={expanderClasses}
data-test="channel-availability-item"
expanded={filter.name === openedFilter?.name}
>
<ExpansionPanelSummary
expandIcon={<IconChevronDown />}
classes={summaryClasses}
onClick={() => handleFilterOpen(filter)}
>
<FilterContentBodyNameField
filter={getFilterFromCurrentData(filter)}
onFilterPropertyChange={onFilterPropertyChange}
onFilterPropertyChange={action =>
handleFilterPropertyGroupChange(action, filter)
}
/>
</ExpansionPanelSummary>
<FilterErrorsList
errors={errors}
errorMessages={errorMessages}
@ -147,7 +251,7 @@ const FilterContent: React.FC<FilterContentProps> = ({
filter={getFilterFromCurrentData(filter)}
/>
)}
</React.Fragment>
</ExpansionPanel>
))}
</form>
</Paper>

View file

@ -74,10 +74,6 @@ const FilterContentBody: React.FC<FilterContentBodyProps> = ({
const intl = useIntl();
const classes = useStyles({});
if (!filter?.active) {
return null;
}
return (
<div className={classes.filterSettings}>
{children}

View file

@ -43,6 +43,7 @@ const FilterContentBodyNameField: React.FC<FilterContentBodyNameFieldProps> = ({
/>
}
label={filter.label}
onClick={event => event.stopPropagation()}
onChange={() =>
onFilterPropertyChange({
payload: {

View file

@ -32,6 +32,7 @@ export interface IFilterElement<T extends string = string>
type?: FieldType;
required?: boolean;
multipleFields?: IFilterElement[];
id?: string;
}
export interface FilterBaseFieldProps<T extends string = string> {

View file

@ -48,6 +48,7 @@ const FilterBar: React.FC<FilterBarProps> = props => {
onAll,
onSearchChange,
onFilterChange,
onFilterAttributeFocus,
onTabChange,
onTabDelete,
onTabSave,
@ -90,6 +91,7 @@ const FilterBar: React.FC<FilterBarProps> = props => {
menu={filterStructure}
currencySymbol={currencySymbol}
onFilterAdd={onFilterChange}
onFilterAttributeFocus={onFilterAttributeFocus}
/>
<SearchInput
initialSearch={initialSearch}

View file

@ -87,6 +87,7 @@ export const ProductListPage: React.FC<ProductListPageProps> = props => {
onExport,
onFetchMore,
onFilterChange,
onFilterAttributeFocus,
onSearchChange,
onTabChange,
onTabDelete,
@ -196,6 +197,7 @@ export const ProductListPage: React.FC<ProductListPageProps> = props => {
initialSearch={initialSearch}
onAll={onAll}
onFilterChange={onFilterChange}
onFilterAttributeFocus={onFilterAttributeFocus}
onSearchChange={onSearchChange}
onTabChange={onTabChange}
onTabDelete={onTabDelete}

View file

@ -1,5 +1,4 @@
import { IFilter } from "@saleor/components/Filter";
import { MultiAutocompleteChoiceType } from "@saleor/components/MultiAutocompleteSelectField";
import { sectionNames } from "@saleor/intl";
import { AutocompleteFilterOpts, FilterOpts, MinMax } from "@saleor/types";
import { StockAvailability } from "@saleor/types/globalTypes";
@ -22,11 +21,12 @@ export enum ProductFilterKeys {
export interface ProductListFilterOpts {
attributes: Array<
FilterOpts<string[]> & {
choices: MultiAutocompleteChoiceType[];
id: string;
name: string;
slug: string;
}
>;
attributeChoices: FilterOpts<string[]> & AutocompleteFilterOpts;
categories: FilterOpts<string[]> & AutocompleteFilterOpts;
collections: FilterOpts<string[]> & AutocompleteFilterOpts;
price: FilterOpts<MinMax>;
@ -151,12 +151,21 @@ export function createFilterStructure(
active: opts.productType.active
},
...opts.attributes.map(attr => ({
...createOptionsField(
...createAutocompleteField(
attr.slug as any,
attr.name,
attr.value,
opts.attributeChoices.displayValues,
true,
attr.choices
opts.attributeChoices.choices,
{
hasMore: opts.attributeChoices.hasMore,
initialSearch: "",
loading: opts.attributeChoices.loading,
onFetchMore: opts.attributeChoices.onFetchMore,
onSearchChange: opts.attributeChoices.onSearchChange
},
attr.id
),
active: attr.active,
group: ProductFilterKeys.attributes

View file

@ -27,10 +27,7 @@ import {
GridAttributes,
GridAttributesVariables
} from "./types/GridAttributes";
import {
InitialProductFilterAttributes,
InitialProductFilterAttributesVariables
} from "./types/InitialProductFilterAttributes";
import { InitialProductFilterAttributes } from "./types/InitialProductFilterAttributes";
import {
InitialProductFilterCategories,
InitialProductFilterCategoriesVariables
@ -60,13 +57,7 @@ import {
} from "./types/ProductVariantDetails";
const initialProductFilterAttributesQuery = gql`
${pageInfoFragment}
query InitialProductFilterAttributes(
$firstValues: Int
$afterValues: String
$lastValues: Int
$beforeValues: String
) {
query InitialProductFilterAttributes {
attributes(
first: 100
filter: { filterableInDashboard: true, type: PRODUCT_TYPE }
@ -76,24 +67,6 @@ const initialProductFilterAttributesQuery = gql`
id
name
slug
choices(
first: $firstValues
after: $afterValues
last: $lastValues
before: $beforeValues
) {
pageInfo {
...PageInfoFragment
}
edges {
cursor
node {
id
name
slug
}
}
}
}
}
}
@ -101,7 +74,7 @@ const initialProductFilterAttributesQuery = gql`
`;
export const useInitialProductFilterAttributesQuery = makeQuery<
InitialProductFilterAttributes,
InitialProductFilterAttributesVariables
never
>(initialProductFilterAttributesQuery);
const initialProductFilterCategoriesQuery = gql`

View file

@ -7,39 +7,11 @@
// GraphQL query operation: InitialProductFilterAttributes
// ====================================================
export interface InitialProductFilterAttributes_attributes_edges_node_choices_pageInfo {
__typename: "PageInfo";
endCursor: string | null;
hasNextPage: boolean;
hasPreviousPage: boolean;
startCursor: string | null;
}
export interface InitialProductFilterAttributes_attributes_edges_node_choices_edges_node {
__typename: "AttributeValue";
id: string;
name: string | null;
slug: string | null;
}
export interface InitialProductFilterAttributes_attributes_edges_node_choices_edges {
__typename: "AttributeValueCountableEdge";
cursor: string;
node: InitialProductFilterAttributes_attributes_edges_node_choices_edges_node;
}
export interface InitialProductFilterAttributes_attributes_edges_node_choices {
__typename: "AttributeValueCountableConnection";
pageInfo: InitialProductFilterAttributes_attributes_edges_node_choices_pageInfo;
edges: InitialProductFilterAttributes_attributes_edges_node_choices_edges[];
}
export interface InitialProductFilterAttributes_attributes_edges_node {
__typename: "Attribute";
id: string;
name: string | null;
slug: string | null;
choices: InitialProductFilterAttributes_attributes_edges_node_choices | null;
}
export interface InitialProductFilterAttributes_attributes_edges {
@ -55,10 +27,3 @@ export interface InitialProductFilterAttributes_attributes {
export interface InitialProductFilterAttributes {
attributes: InitialProductFilterAttributes_attributes | null;
}
export interface InitialProductFilterAttributesVariables {
firstValues?: number | null;
afterValues?: string | null;
lastValues?: number | null;
beforeValues?: string | null;
}

View file

@ -48,6 +48,7 @@ import {
productUrl
} from "@saleor/products/urls";
import useAttributeSearch from "@saleor/searches/useAttributeSearch";
import useAttributeValueSearch from "@saleor/searches/useAttributeValueSearch";
import useCategorySearch from "@saleor/searches/useCategorySearch";
import useCollectionSearch from "@saleor/searches/useCollectionSearch";
import useProductTypeSearch from "@saleor/searches/useProductTypeSearch";
@ -57,7 +58,7 @@ import createFilterHandlers from "@saleor/utils/handlers/filterHandlers";
import { mapEdgesToItems } from "@saleor/utils/maps";
import { getSortUrlVariables } from "@saleor/utils/sort";
import { useWarehouseList } from "@saleor/warehouses/queries";
import React, { useEffect } from "react";
import React, { useEffect, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import ProductListPage from "../../components/ProductListPage";
@ -95,11 +96,7 @@ export const ProductList: React.FC<ProductListProps> = ({ params }) => {
const intl = useIntl();
const {
data: initialFilterAttributes
} = useInitialProductFilterAttributesQuery({
variables: {
firstValues: 100
}
});
} = useInitialProductFilterAttributesQuery({});
const {
data: initialFilterCategories
} = useInitialProductFilterCategoriesQuery({
@ -148,6 +145,15 @@ export const ProductList: React.FC<ProductListProps> = ({ params }) => {
first: 10
}
});
const [focusedAttribute, setFocusedAttribute] = useState<string>();
const searchAttributeValues = useAttributeValueSearch({
variables: {
id: focusedAttribute,
...DEFAULT_INITIAL_SEARCH_DATA,
first: 10
},
skip: !focusedAttribute
});
const warehouses = useWarehouseList({
variables: {
first: 100
@ -321,6 +327,7 @@ export const ProductList: React.FC<ProductListProps> = ({ params }) => {
const filterOpts = getFilterOpts(
params,
mapEdgesToItems(initialFilterAttributes?.attributes),
searchAttributeValues,
{
initial: mapEdgesToItems(initialFilterCategories?.categories),
search: searchCategories
@ -422,6 +429,7 @@ export const ProductList: React.FC<ProductListProps> = ({ params }) => {
toggleAll={toggleAll}
onSearchChange={handleSearchChange}
onFilterChange={changeFilters}
onFilterAttributeFocus={setFocusedAttribute}
onTabSave={() => openModal("save-search")}
onTabDelete={() => openModal("delete-search")}
onTabChange={handleTabChange}

View file

@ -8,6 +8,10 @@ import { InitialProductFilterAttributes_attributes_edges_node } from "@saleor/pr
import { InitialProductFilterCategories_categories_edges_node } from "@saleor/products/types/InitialProductFilterCategories";
import { InitialProductFilterCollections_collections_edges_node } from "@saleor/products/types/InitialProductFilterCollections";
import { InitialProductFilterProductTypes_productTypes_edges_node } from "@saleor/products/types/InitialProductFilterProductTypes";
import {
SearchAttributeValues,
SearchAttributeValuesVariables
} from "@saleor/searches/types/SearchAttributeValues";
import {
SearchCategories,
SearchCategoriesVariables
@ -20,7 +24,11 @@ import {
SearchProductTypes,
SearchProductTypesVariables
} from "@saleor/searches/types/SearchProductTypes";
import { mapEdgesToItems, mapNodeToChoice } from "@saleor/utils/maps";
import {
mapEdgesToItems,
mapNodeToChoice,
mapSlugNodeToChoice
} from "@saleor/utils/maps";
import isArray from "lodash/isArray";
import { IFilterElement } from "../../../components/Filter";
@ -50,6 +58,10 @@ export const PRODUCT_FILTERS_KEY = "productFilters";
export function getFilterOpts(
params: ProductListUrlFilters,
attributes: InitialProductFilterAttributes_attributes_edges_node[],
focusedAttributeChoices: UseSearchResult<
SearchAttributeValues,
SearchAttributeValuesVariables
>,
categories: {
initial: InitialProductFilterCategories_categories_edges_node[];
search: UseSearchResult<SearchCategories, SearchCategoriesVariables>;
@ -68,17 +80,31 @@ export function getFilterOpts(
.sort((a, b) => (a.name > b.name ? 1 : -1))
.map(attr => ({
active: maybe(() => params.attributes[attr.slug].length > 0, false),
choices: attr.choices?.edges?.map(val => ({
label: val.node.name,
value: val.node.slug
})),
id: attr.id,
name: attr.name,
slug: attr.slug,
value:
!!params.attributes && params.attributes[attr.slug]
? params.attributes[attr.slug]
? dedupeFilter(params.attributes[attr.slug])
: []
})),
attributeChoices: {
active: true,
choices: mapSlugNodeToChoice(
mapEdgesToItems(focusedAttributeChoices.result.data?.attribute?.choices)
),
displayValues: mapNodeToChoice(
mapEdgesToItems(focusedAttributeChoices.result.data?.attribute?.choices)
),
hasMore:
focusedAttributeChoices.result.data?.attribute?.choices?.pageInfo
?.hasNextPage || false,
initialSearch: "",
loading: focusedAttributeChoices.result.loading,
onFetchMore: focusedAttributeChoices.loadMore,
onSearchChange: focusedAttributeChoices.search,
value: null
},
categories: {
active: !!params.categories,
choices: mapNodeToChoice(

View file

@ -5,14 +5,12 @@ import { fetchMoreProps, searchPageProps } from "@saleor/fixtures";
import { ProductListFilterOpts } from "@saleor/products/components/ProductListPage";
import { productTypes } from "@saleor/productTypes/fixtures";
import { StockAvailability } from "@saleor/types/globalTypes";
import { mapEdgesToItems, mapSlugNodeToChoice } from "@saleor/utils/maps";
export const productListFilterOpts: ProductListFilterOpts = {
attributes: attributes.map(attr => ({
id: attr.id,
active: false,
choices: attr.choices.edges.map(val => ({
label: val.node.name,
value: val.node.slug
})),
name: attr.name,
slug: attr.slug,
value: [
@ -20,6 +18,14 @@ export const productListFilterOpts: ProductListFilterOpts = {
attr.choices.edges.length > 2 && attr.choices.edges[2].node.slug
]
})),
attributeChoices: {
...fetchMoreProps,
...searchPageProps,
active: false,
value: null,
choices: mapSlugNodeToChoice(mapEdgesToItems(attributes[0].choices)),
displayValues: mapSlugNodeToChoice(mapEdgesToItems(attributes[0].choices))
},
categories: {
...fetchMoreProps,
...searchPageProps,

File diff suppressed because it is too large Load diff

View file

@ -107,6 +107,7 @@ export interface FilterPageProps<TKeys extends string, TOpts extends {}>
export interface FilterProps<TKeys extends string> {
currencySymbol?: string;
onFilterChange: (filter: IFilter<TKeys>) => void;
onFilterAttributeFocus?: (id?: string) => void;
}
export interface TabPageProps {
@ -133,6 +134,9 @@ export interface PartialMutationProviderOutput<
export interface Node {
id: string;
}
export interface SlugNode {
slug: string;
}
export type Pagination = Partial<{
after: string;

View file

@ -72,7 +72,8 @@ export function createAutocompleteField<T extends string>(
displayValues: MultiAutocompleteChoiceType[],
multiple: boolean,
options: MultiAutocompleteChoiceType[],
opts: FetchMoreProps & SearchPageProps
opts: FetchMoreProps & SearchPageProps,
id?: string
): IFilterElement<T> {
return {
...opts,
@ -83,7 +84,8 @@ export function createAutocompleteField<T extends string>(
name,
options,
type: FieldType.autocomplete,
value: defaultValue
value: defaultValue,
id
};
}

View file

@ -3,7 +3,7 @@ import { ShopInfo_shop_countries } from "@saleor/components/Shop/types/ShopInfo"
import { SingleAutocompleteChoiceType } from "@saleor/components/SingleAutocompleteSelectField";
import { MetadataItem } from "@saleor/fragments/types/MetadataItem";
import { SearchPages_search_edges_node } from "@saleor/searches/types/SearchPages";
import { Node } from "@saleor/types";
import { Node, SlugNode } from "@saleor/types";
import { MetadataInput } from "@saleor/types/globalTypes";
interface EdgesType<T> {
@ -49,6 +49,19 @@ export function mapNodeToChoice(
}));
}
export function mapSlugNodeToChoice(
nodes: Array<SlugNode & Record<"name", string>>
): Array<SingleAutocompleteChoiceType | MultiAutocompleteChoiceType> {
if (!nodes) {
return [];
}
return nodes.map(node => ({
label: node.name,
value: node.slug
}));
}
export function mapMetadataItemToInput(item: MetadataItem): MetadataInput {
return {
key: item.key,