Select filter presets (#3412)

This commit is contained in:
Paweł Chyła 2023-04-12 09:11:11 +02:00 committed by GitHub
parent 42584cdfc2
commit 63b98a08bf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
49 changed files with 27090 additions and 276 deletions

View file

@ -764,6 +764,9 @@
"3DGvA/": { "3DGvA/": {
"string": "Remember this will also unpin all products assigned to this category, making them unavailable in storefront." "string": "Remember this will also unpin all products assigned to this category, making them unavailable in storefront."
}, },
"3PVGWj": {
"string": "Filter preset"
},
"3Sz1/t": { "3Sz1/t": {
"context": "dialog header", "context": "dialog header",
"string": "Delete Pages" "string": "Delete Pages"
@ -1283,10 +1286,6 @@
"7NFfmz": { "7NFfmz": {
"string": "Products" "string": "Products"
}, },
"7NfoiJ": {
"context": "custom search delete, dialog header",
"string": "Delete Search"
},
"7Oorx5": { "7Oorx5": {
"context": "navigator section header", "context": "navigator section header",
"string": "Search in Catalog" "string": "Search in Catalog"
@ -1595,6 +1594,9 @@
"context": "used staff users counter", "context": "used staff users counter",
"string": "{count}/{max} members" "string": "{count}/{max} members"
}, },
"A+g/VP": {
"string": "Unsaved preset"
},
"A0Wlg7": { "A0Wlg7": {
"context": "product discount removed title", "context": "product discount removed title",
"string": "{productName} discount was removed by" "string": "{productName} discount was removed by"
@ -1786,6 +1788,9 @@
"BUKMzM": { "BUKMzM": {
"string": "Variant removed" "string": "Variant removed"
}, },
"BWpuKl": {
"string": "Update"
},
"BXMSl4": { "BXMSl4": {
"context": "currency channel", "context": "currency channel",
"string": "There is no available channel to move order information to. Please create a channel with same currency so that information can be moved to it." "string": "There is no available channel to move order information to. Please create a channel with same currency so that information can be moved to it."
@ -3688,6 +3693,10 @@
"P7PLVj": { "P7PLVj": {
"string": "Date" "string": "Date"
}, },
"P9YktI": {
"context": "save preset, header",
"string": "Save view preset"
},
"PAqicb": { "PAqicb": {
"context": "button", "context": "button",
"string": "Cancel order" "string": "Cancel order"
@ -3923,10 +3932,6 @@
"context": "dialog header", "context": "dialog header",
"string": "Delete Channel" "string": "Delete Channel"
}, },
"QcIFCs": {
"context": "save search tab",
"string": "Search Name"
},
"QclvqG": { "QclvqG": {
"context": "input label", "context": "input label",
"string": "Checkout line limit" "string": "Checkout line limit"
@ -4378,6 +4383,9 @@
"U2mOqA": { "U2mOqA": {
"string": "No vouchers found" "string": "No vouchers found"
}, },
"U5CH0u": {
"string": "Are you sure you want to delete {name} preset?"
},
"U5Da30": { "U5Da30": {
"string": "Warehouses" "string": "Warehouses"
}, },
@ -4442,9 +4450,6 @@
"context": "filters error messages unknown error", "context": "filters error messages unknown error",
"string": "Unknown error occurred" "string": "Unknown error occurred"
}, },
"UaYJJ8": {
"string": "Are you sure you want to delete {name} search tab?"
},
"Uf3oHA": { "Uf3oHA": {
"context": "add new menu item", "context": "add new menu item",
"string": "Create new item" "string": "Create new item"
@ -5838,6 +5843,9 @@
"eUjFjW": { "eUjFjW": {
"string": "Permission group created" "string": "Permission group created"
}, },
"eW36Jx": {
"string": "Saved search queries will appear here"
},
"eWV760": { "eWV760": {
"string": "Attribute with this slug already exists" "string": "Attribute with this slug already exists"
}, },
@ -6751,10 +6759,6 @@
"context": "export items as csv file", "context": "export items as csv file",
"string": "Plain CSV file" "string": "Plain CSV file"
}, },
"liLrVs": {
"context": "save filter tab, header",
"string": "Save Custom Search"
},
"lit2zF": { "lit2zF": {
"string": "Search Discounts" "string": "Search Discounts"
}, },
@ -7638,6 +7642,9 @@
"context": "button", "context": "button",
"string": "Assign product" "string": "Assign product"
}, },
"scTuDZ": {
"string": "Save search as preset"
},
"sdA14A": { "sdA14A": {
"context": "Status label when object is published in a channel", "context": "Status label when object is published in a channel",
"string": "Published" "string": "Published"
@ -7718,6 +7725,10 @@
"context": "webhook input help text", "context": "webhook input help text",
"string": "secret key is used to create a hash signature with each payload. *optional field" "string": "secret key is used to create a hash signature with each payload. *optional field"
}, },
"tCLTCb": {
"context": "tab name",
"string": "All products"
},
"tEdFmZ": { "tEdFmZ": {
"context": "notification title", "context": "notification title",
"string": "System update required" "string": "System update required"
@ -8351,6 +8362,10 @@
"xxQxLE": { "xxQxLE": {
"string": "Email Address" "string": "Email Address"
}, },
"xy66ru": {
"context": "custom preset delete, dialog header",
"string": "Delete preset"
},
"y/UWBR": { "y/UWBR": {
"string": "There is no address to show for this customer" "string": "There is no address to show for this customer"
}, },
@ -8572,6 +8587,10 @@
"context": "attribute list", "context": "attribute list",
"string": "Attribute {name}" "string": "Attribute {name}"
}, },
"zhnwl6": {
"context": "save preset name",
"string": "Preset name"
},
"zjHH6b": { "zjHH6b": {
"context": "sale start date", "context": "sale start date",
"string": "Started" "string": "Started"

26282
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -33,7 +33,7 @@
"@material-ui/lab": "^4.0.0-alpha.61", "@material-ui/lab": "^4.0.0-alpha.61",
"@material-ui/styles": "^4.11.4", "@material-ui/styles": "^4.11.4",
"@reach/auto-id": "^0.16.0", "@reach/auto-id": "^0.16.0",
"@saleor/macaw-ui": "^0.8.0-pre.50", "@saleor/macaw-ui": "^0.8.0-pre.64",
"@saleor/sdk": "^0.4.6", "@saleor/sdk": "^0.4.6",
"@sentry/react": "^6.0.0", "@sentry/react": "^6.0.0",
"@types/faker": "^5.1.6", "@types/faker": "^5.1.6",

View file

@ -14,6 +14,7 @@ export interface ActionDialogProps extends DialogProps {
maxWidth?: Size | false; maxWidth?: Size | false;
title: string; title: string;
variant?: ActionDialogVariant; variant?: ActionDialogVariant;
backButtonText?: string;
onConfirm(); onConfirm();
} }

View file

@ -16,6 +16,7 @@ interface DialogButtonsProps {
variant?: ActionDialogVariant; variant?: ActionDialogVariant;
children?: React.ReactNode; children?: React.ReactNode;
showBackButton?: boolean; showBackButton?: boolean;
backButtonText?: string;
onConfirm(); onConfirm();
} }
@ -29,6 +30,7 @@ const DialogButtons: React.FC<DialogButtonsProps> = props => {
onClose, onClose,
children, children,
showBackButton = true, showBackButton = true,
backButtonText,
} = props; } = props;
const intl = useIntl(); const intl = useIntl();
@ -36,7 +38,9 @@ const DialogButtons: React.FC<DialogButtonsProps> = props => {
return ( return (
<DialogActions> <DialogActions>
{children} {children}
{showBackButton && <BackButton onClick={onClose} />} {showBackButton && (
<BackButton onClick={onClose}>{backButtonText}</BackButton>
)}
{variant !== "info" && ( {variant !== "info" && (
<ConfirmButton <ConfirmButton
disabled={disabled} disabled={disabled}

View file

@ -98,7 +98,7 @@ const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
borderColor="neutralPlain" borderColor="neutralPlain"
__maxWidth={contentMaxWidth} __maxWidth={contentMaxWidth}
margin="auto" margin="auto"
zIndex="3" zIndex="2"
/> />
</Box> </Box>
</Box> </Box>

View file

@ -1,35 +0,0 @@
import { Box, DropdownButton } from "@saleor/macaw-ui/next";
import React, { MouseEventHandler } from "react";
import { FormattedMessage } from "react-intl";
interface FilterButtonProps {
isFilterActive: boolean;
onClick: MouseEventHandler<HTMLButtonElement>;
selectedFilterAmount: number;
}
export const FilterButton = ({
isFilterActive,
onClick,
selectedFilterAmount,
}: FilterButtonProps) => (
<DropdownButton data-test-id="show-filters-button" onClick={onClick}>
<FormattedMessage
id="FNpv6K"
defaultMessage="Filters"
description="button"
/>
{isFilterActive && (
<>
<Box
as="span"
backgroundColor="interactiveNeutralDefault"
height={6}
width={1}
marginX={3}
/>
{selectedFilterAmount}
</>
)}
</DropdownButton>
);

View file

@ -1 +0,0 @@
export * from "./FilterBar";

View file

@ -3,10 +3,10 @@ import { FilterProps, SearchPageProps } from "@dashboard/types";
import { Box } from "@saleor/macaw-ui/next"; import { Box } from "@saleor/macaw-ui/next";
import React, { ReactNode } from "react"; import React, { ReactNode } from "react";
import { Filter } from "./Filter"; import { FiltersSelect } from "./components/FiltersSelect";
import SearchInput from "./SearchInput"; import SearchInput from "./components/SearchInput";
export interface FilterBarProps<TKeys extends string = string> export interface ListFiltersProps<TKeys extends string = string>
extends FilterProps<TKeys>, extends FilterProps<TKeys>,
SearchPageProps { SearchPageProps {
searchPlaceholder: string; searchPlaceholder: string;
@ -15,7 +15,7 @@ export interface FilterBarProps<TKeys extends string = string>
actions?: ReactNode; actions?: ReactNode;
} }
export const FilterBar: React.FC<FilterBarProps> = ({ export const ListFilters = ({
currencySymbol, currencySymbol,
filterStructure, filterStructure,
initialSearch, initialSearch,
@ -25,11 +25,11 @@ export const FilterBar: React.FC<FilterBarProps> = ({
onFilterAttributeFocus, onFilterAttributeFocus,
errorMessages, errorMessages,
actions, actions,
}: FilterBarProps) => ( }: ListFiltersProps) => (
<> <>
<Box <Box
display="grid" display="grid"
__gridTemplateColumns="1fr 1fr" gridTemplateColumns={2}
gap={7} gap={7}
paddingBottom={5} paddingBottom={5}
paddingX={9} paddingX={9}
@ -38,13 +38,14 @@ export const FilterBar: React.FC<FilterBarProps> = ({
borderBottomWidth={1} borderBottomWidth={1}
> >
<Box display="flex" alignItems="center" gap={7}> <Box display="flex" alignItems="center" gap={7}>
<Filter <FiltersSelect
errorMessages={errorMessages} errorMessages={errorMessages}
menu={filterStructure} menu={filterStructure}
currencySymbol={currencySymbol} currencySymbol={currencySymbol}
onFilterAdd={onFilterChange} onFilterAdd={onFilterChange}
onFilterAttributeFocus={onFilterAttributeFocus} onFilterAttributeFocus={onFilterAttributeFocus}
/> />
<Box __width="320px"> <Box __width="320px">
<SearchInput <SearchInput
initialSearch={initialSearch} initialSearch={initialSearch}
@ -59,4 +60,4 @@ export const FilterBar: React.FC<FilterBarProps> = ({
</Box> </Box>
</> </>
); );
FilterBar.displayName = "FilterBar"; ListFilters.displayName = "FilterBar";

View file

@ -8,11 +8,11 @@ import {
import useFilter from "@dashboard/components/Filter/useFilter"; import useFilter from "@dashboard/components/Filter/useFilter";
import { extractInvalidFilters } from "@dashboard/components/Filter/utils"; import { extractInvalidFilters } from "@dashboard/components/Filter/utils";
import { ClickAwayListener, Grow, Popper } from "@material-ui/core"; import { ClickAwayListener, Grow, Popper } from "@material-ui/core";
import { sprinkles } from "@saleor/macaw-ui/next"; import { DropdownButton, sprinkles } from "@saleor/macaw-ui/next";
import React, { useMemo, useState } from "react"; import React, { useMemo, useState } from "react";
import { FormattedMessage } from "react-intl";
import { FilterButton } from "./FilterButton"; import { getSelectedFilterAmount } from "../utils";
import { getSelectedFilterAmount } from "./utils";
export interface FilterProps<TFilterKeys extends string = string> { export interface FilterProps<TFilterKeys extends string = string> {
currencySymbol?: string; currencySymbol?: string;
@ -22,7 +22,7 @@ export interface FilterProps<TFilterKeys extends string = string> {
onFilterAttributeFocus?: (id?: string) => void; onFilterAttributeFocus?: (id?: string) => void;
} }
export const Filter = ({ export const FiltersSelect = ({
currencySymbol, currencySymbol,
menu, menu,
onFilterAdd, onFilterAdd,
@ -68,11 +68,19 @@ export const Filter = ({
mouseEvent="onMouseUp" mouseEvent="onMouseUp"
> >
<div ref={anchor}> <div ref={anchor}>
<FilterButton <DropdownButton
isFilterActive={isFilterActive} data-test-id="show-filters-button"
onClick={() => setFilterMenuOpened(!isFilterMenuOpened)} onClick={() => setFilterMenuOpened(!isFilterMenuOpened)}
selectedFilterAmount={selectedFilterAmount} >
/> <FormattedMessage
id="FNpv6K"
defaultMessage="Filters"
description="button"
/>
{isFilterActive && selectedFilterAmount > 0 && (
<>({selectedFilterAmount})</>
)}
</DropdownButton>
<Popper <Popper
className={sprinkles({ className={sprinkles({
backgroundColor: "surfaceNeutralPlain", backgroundColor: "surfaceNeutralPlain",
@ -123,4 +131,4 @@ export const Filter = ({
); );
}; };
Filter.displayName = "Filter"; FiltersSelect.displayName = "Filter";

View file

@ -0,0 +1 @@
export * from "./ListFilters";

View file

@ -10,12 +10,14 @@ interface TopNavProps {
title: string | React.ReactNode; title: string | React.ReactNode;
href?: string; href?: string;
withoutBorder?: boolean; withoutBorder?: boolean;
isAlignToRight?: boolean;
} }
export const TopNav: React.FC<PropsWithChildren<TopNavProps>> = ({ export const TopNav: React.FC<PropsWithChildren<TopNavProps>> = ({
title, title,
href, href,
withoutBorder = false, withoutBorder = false,
isAlignToRight = true,
children, children,
}) => { }) => {
const { availableChannels, channel, isPickerActive, setChannel } = const { availableChannels, channel, isPickerActive, setChannel } =
@ -24,10 +26,16 @@ export const TopNav: React.FC<PropsWithChildren<TopNavProps>> = ({
return ( return (
<TopNavWrapper withoutBorder={withoutBorder}> <TopNavWrapper withoutBorder={withoutBorder}>
{href && <TopNavLink to={href} />} {href && <TopNavLink to={href} />}
<Box __flex={1}> <Box __flex={isAlignToRight ? 1 : 0}>
<Text variant="title">{title}</Text> <Text variant="title" size="small">
{title}
</Text>
</Box> </Box>
<Box display="flex" flexWrap="nowrap"> <Box
display="flex"
flexWrap="nowrap"
__flex={isAlignToRight ? "initial" : 1}
>
{isPickerActive && ( {isPickerActive && (
<AppChannelSelect <AppChannelSelect
channels={availableChannels} channels={availableChannels}

View file

@ -1,12 +1,16 @@
import { Button } from "@dashboard/components/Button"; import { Button } from "@dashboard/components/Button";
import { buttonMessages } from "@dashboard/intl"; import { buttonMessages } from "@dashboard/intl";
import { ButtonProps } from "@saleor/macaw-ui"; import { ButtonProps } from "@saleor/macaw-ui";
import React from "react"; import React, { ReactNode } from "react";
import { FormattedMessage } from "react-intl"; import { FormattedMessage } from "react-intl";
const BackButton: React.FC<ButtonProps> = props => ( interface BackButtonProps extends ButtonProps {
children?: ReactNode;
}
const BackButton: React.FC<BackButtonProps> = ({ children, ...props }) => (
<Button data-test-id="back" variant="secondary" color="text" {...props}> <Button data-test-id="back" variant="secondary" color="text" {...props}>
<FormattedMessage {...buttonMessages.back} /> {children ?? <FormattedMessage {...buttonMessages.back} />}
</Button> </Button>
); );

View file

@ -88,6 +88,7 @@ export interface DatagridProps {
onColumnMoved?: (startIndex: number, endIndex: number) => void; onColumnMoved?: (startIndex: number, endIndex: number) => void;
onColumnResize?: (column: GridColumn, newSize: number) => void; onColumnResize?: (column: GridColumn, newSize: number) => void;
readonly?: boolean; readonly?: boolean;
hasRowHover?: boolean;
rowMarkers?: DataEditorProps["rowMarkers"]; rowMarkers?: DataEditorProps["rowMarkers"];
freezeColumns?: DataEditorProps["freezeColumns"]; freezeColumns?: DataEditorProps["freezeColumns"];
verticalBorder?: DataEditorProps["verticalBorder"]; verticalBorder?: DataEditorProps["verticalBorder"];
@ -117,6 +118,7 @@ export const Datagrid: React.FC<DatagridProps> = ({
columnSelect = "none", columnSelect = "none",
onColumnMoved, onColumnMoved,
onColumnResize, onColumnResize,
hasRowHover = false,
...datagridProps ...datagridProps
}): ReactElement => { }): ReactElement => {
const classes = useStyles(); const classes = useStyles();
@ -219,11 +221,11 @@ export const Datagrid: React.FC<DatagridProps> = ({
const handleRowHover = useCallback( const handleRowHover = useCallback(
(args: GridMouseEventArgs) => { (args: GridMouseEventArgs) => {
if (readonly) { if (hasRowHover) {
setHoverRow(args.kind !== "cell" ? undefined : args.location[1]); setHoverRow(args.kind !== "cell" ? undefined : args.location[1]);
} }
}, },
[readonly], [hasRowHover],
); );
const handleGridSelectionChange = (gridSelection: GridSelection) => { const handleGridSelectionChange = (gridSelection: GridSelection) => {

View file

@ -1,3 +1,4 @@
import { buttonMessages } from "@dashboard/intl";
import { DialogContentText } from "@material-ui/core"; import { DialogContentText } from "@material-ui/core";
import { ConfirmButtonTransitionState } from "@saleor/macaw-ui"; import { ConfirmButtonTransitionState } from "@saleor/macaw-ui";
import React from "react"; import React from "react";
@ -26,19 +27,20 @@ const DeleteFilterTabDialog: React.FC<DeleteFilterTabDialogProps> = ({
<ActionDialog <ActionDialog
open={open} open={open}
confirmButtonState={confirmButtonState} confirmButtonState={confirmButtonState}
backButtonText={intl.formatMessage(buttonMessages.cancel)}
onClose={onClose} onClose={onClose}
onConfirm={onSubmit} onConfirm={onSubmit}
title={intl.formatMessage({ title={intl.formatMessage({
id: "7NfoiJ", id: "xy66ru",
defaultMessage: "Delete Search", defaultMessage: "Delete preset",
description: "custom search delete, dialog header", description: "custom preset delete, dialog header",
})} })}
variant="delete" variant="delete"
> >
<DialogContentText> <DialogContentText>
<FormattedMessage <FormattedMessage
id="UaYJJ8" id="U5CH0u"
defaultMessage="Are you sure you want to delete {name} search tab?" defaultMessage="Are you sure you want to delete {name} preset?"
values={{ values={{
name: <strong>{tabName}</strong>, name: <strong>{tabName}</strong>,
}} }}

View file

@ -3,7 +3,7 @@ import { alpha } from "@material-ui/core/styles";
import { Button, makeStyles } from "@saleor/macaw-ui"; import { Button, makeStyles } from "@saleor/macaw-ui";
import { vars } from "@saleor/macaw-ui/next"; import { vars } from "@saleor/macaw-ui/next";
import clsx from "clsx"; import clsx from "clsx";
import React, { useState } from "react"; import React, { useMemo, useState } from "react";
import { FormattedMessage } from "react-intl"; import { FormattedMessage } from "react-intl";
import { FilterContent } from "."; import { FilterContent } from ".";
@ -14,7 +14,7 @@ import {
InvalidFilters, InvalidFilters,
} from "./types"; } from "./types";
import useFilter from "./useFilter"; import useFilter from "./useFilter";
import { extractInvalidFilters } from "./utils"; import { extractInvalidFilters, getSelectedFiltersAmount } from "./utils";
export interface FilterProps<TFilterKeys extends string = string> { export interface FilterProps<TFilterKeys extends string = string> {
currencySymbol?: string; currencySymbol?: string;
@ -103,6 +103,11 @@ const Filter: React.FC<FilterProps> = props => {
const isFilterActive = menu.some(filterElement => filterElement.active); const isFilterActive = menu.some(filterElement => filterElement.active);
const selectedFilterAmount = useMemo(
() => getSelectedFiltersAmount(menu, data),
[data, menu],
);
const handleSubmit = () => { const handleSubmit = () => {
const invalidFilters = extractInvalidFilters(data, menu); const invalidFilters = extractInvalidFilters(data, menu);
@ -147,23 +152,8 @@ const Filter: React.FC<FilterProps> = props => {
description="button" description="button"
/> />
</Typography> </Typography>
{isFilterActive && ( {isFilterActive && selectedFilterAmount > 0 && (
<> <>({selectedFilterAmount})</>
<span className={classes.separator} />
<Typography className={classes.addFilterText}>
{menu.reduce((acc, filterElement) => {
const dataFilterElement = data.find(
({ name }) => name === filterElement.name,
);
if (!dataFilterElement) {
return acc;
}
return acc + (dataFilterElement.active ? 1 : 0);
}, 0)}
</Typography>
</>
)} )}
</Button> </Button>
<Popper <Popper

View file

@ -16,31 +16,11 @@ export interface FilterReducerAction<K extends string, T extends FieldType> {
}>; }>;
} }
export type UpdateStateFunction<K extends string = string> = < export type UpdateStateFunction<K extends string = string> = <
T extends FieldType T extends FieldType,
>( >(
value: FilterReducerAction<K, T>, value: FilterReducerAction<K, T>,
) => void; ) => void;
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,
value: prevFilter.value,
};
}
return newFilter;
});
}
function setProperty<K extends string, T extends FieldType>( function setProperty<K extends string, T extends FieldType>(
prevState: IFilter<K, T>, prevState: IFilter<K, T>,
filter: K, filter: K,
@ -66,8 +46,6 @@ function reduceFilter<K extends string, T extends FieldType>(
action.payload.name, action.payload.name,
action.payload.update, action.payload.update,
); );
case "merge":
return merge(prevState, action.payload.new);
case "reset": case "reset":
return action.payload.new; return action.payload.new;

View file

@ -4,7 +4,7 @@ import reduceFilter, { FilterReducerAction } from "./reducer";
import { FieldType, FilterElement, IFilter } from "./types"; import { FieldType, FilterElement, IFilter } from "./types";
export type FilterDispatchFunction<K extends string = string> = < export type FilterDispatchFunction<K extends string = string> = <
T extends FieldType T extends FieldType,
>( >(
value: FilterReducerAction<K, T>, value: FilterReducerAction<K, T>,
) => void; ) => void;
@ -53,7 +53,7 @@ function useFilter<K extends string>(initialFilter: IFilter<K>): UseFilter<K> {
payload: { payload: {
new: parsedInitialFilter, new: parsedInitialFilter,
}, },
type: "merge", type: "reset",
}); });
useEffect(refresh, [initialFilter]); useEffect(refresh, [initialFilter]);

View file

@ -3,6 +3,7 @@ import compact from "lodash/compact";
import { import {
FieldType, FieldType,
FilterElement, FilterElement,
IFilter,
InvalidFilters, InvalidFilters,
ValidationErrorCode, ValidationErrorCode,
} from "./types"; } from "./types";
@ -10,13 +11,13 @@ import {
export const getByName = (nameToCompare: string) => (obj: { name: string }) => export const getByName = (nameToCompare: string) => (obj: { name: string }) =>
obj.name === nameToCompare; obj.name === nameToCompare;
export const isAutocompleteFilterFieldValid = function<T extends string>({ export const isAutocompleteFilterFieldValid = function <T extends string>({
value, value,
}: FilterElement<T>) { }: FilterElement<T>) {
return !!compact(value).length; return !!compact(value).length;
}; };
export const isNumberFilterFieldValid = function<T extends string>({ export const isNumberFilterFieldValid = function <T extends string>({
value, value,
}: FilterElement<T>) { }: FilterElement<T>) {
const [min, max] = value; const [min, max] = value;
@ -28,7 +29,7 @@ export const isNumberFilterFieldValid = function<T extends string>({
return true; return true;
}; };
export const isFilterFieldValid = function<T extends string>( export const isFilterFieldValid = function <T extends string>(
filter: FilterElement<T>, filter: FilterElement<T>,
) { ) {
const { type } = filter; const { type } = filter;
@ -47,7 +48,7 @@ export const isFilterFieldValid = function<T extends string>(
} }
}; };
export const isFilterValid = function<T extends string>( export const isFilterValid = function <T extends string>(
filter: FilterElement<T>, filter: FilterElement<T>,
) { ) {
const { required, active } = filter; const { required, active } = filter;
@ -59,7 +60,7 @@ export const isFilterValid = function<T extends string>(
return isFilterFieldValid(filter); return isFilterFieldValid(filter);
}; };
export const extractInvalidFilters = function<T extends string>( export const extractInvalidFilters = function <T extends string>(
filtersData: Array<FilterElement<T>>, filtersData: Array<FilterElement<T>>,
filtersDataStructure: Array<FilterElement<T>>, filtersDataStructure: Array<FilterElement<T>>,
): InvalidFilters<T> { ): InvalidFilters<T> {
@ -118,3 +119,19 @@ export const extractInvalidFilters = function<T extends string>(
{} as InvalidFilters<T>, {} as InvalidFilters<T>,
); );
}; };
export const getSelectedFiltersAmount = <TFilterKeys extends string = string>(
menu: IFilter<TFilterKeys>,
data: Array<FilterElement<string>>,
) =>
menu.reduce((acc, filterElement) => {
const dataFilterElement = data.find(
({ name }) => name === filterElement.name,
);
if (!dataFilterElement) {
return acc;
}
return acc + (dataFilterElement.active ? 1 : 0);
}, 0);

View file

@ -0,0 +1,61 @@
import { Box, Dropdown, List, RemoveIcon, Text } from "@saleor/macaw-ui/next";
import React, { MouseEvent } from "react";
interface FilterPresetItemProps {
onSelect: (e: MouseEvent<HTMLLIElement>) => void;
onRemove: () => void;
isActive?: boolean;
children: React.ReactNode;
}
export const FilterPresetItem = ({
children,
onSelect,
isActive,
onRemove,
}: FilterPresetItemProps) => {
const [hasHover, setHasHover] = React.useState(false);
return (
<Dropdown.Item>
<List.Item
paddingLeft={4}
paddingRight={11}
paddingY={3}
gap={6}
borderRadius={2}
position="relative"
display="flex"
justifyContent="space-between"
onClick={onSelect}
onMouseOver={() => setHasHover(true)}
onMouseLeave={() => setHasHover(false)}
>
<Text ellipsis variant={isActive ? "bodyStrong" : "body"}>
{children}
</Text>
{hasHover && (
<Box
cursor="pointer"
zIndex="2"
position="absolute"
__top="50%"
right={4}
__transform="translateY(-50%)"
onClick={onRemove}
display="flex"
alignItems="center"
>
<RemoveIcon
color={{
default: "iconNeutralSubdued",
hover: "iconNeutralPlain",
focusVisible: "iconNeutralPlain",
}}
/>
</Box>
)}
</List.Item>
</Dropdown.Item>
);
};

View file

@ -0,0 +1,196 @@
import { commonMessages } from "@dashboard/intl";
import { Tooltip } from "@saleor/macaw-ui";
import {
Box,
Button,
Dropdown,
DropdownButton,
List,
PlusIcon,
sprinkles,
Text,
vars,
} from "@saleor/macaw-ui/next";
import React, { MouseEvent } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { FilterPresetItem } from "./FilterPresetItem";
import { messages } from "./messages";
import { getSeparatorWidth } from "./utils";
interface FilterPresetsSelectProps {
activePreset?: number;
savedPresets: string[];
selectAllLabel: string;
isOpen?: boolean;
presetsChanged?: boolean;
onSave: () => void;
onSelectAll: () => void;
onRemove: (filterIndex: number) => void;
onUpdate: (tabName: string) => void;
onSelect: (filterIndex: number) => void;
onOpenChange?: (isOpen: boolean) => void;
}
export const FilterPresetsSelect = ({
onSelect,
onRemove,
onSave,
savedPresets,
activePreset,
onSelectAll,
selectAllLabel,
isOpen,
onUpdate,
onOpenChange,
presetsChanged,
}: FilterPresetsSelectProps) => {
const intl = useIntl();
const showUpdateButton =
presetsChanged && savedPresets.length > 0 && activePreset;
const showSaveButton = presetsChanged;
const getLabel = () => {
if (!activePreset) {
return selectAllLabel;
}
return savedPresets[activePreset - 1];
};
const handleSelectPreset = (e: MouseEvent<HTMLElement>, index: number) => {
const target = e.target as HTMLElement;
// Prevent run onSelect when click on remove button
if (!["LI", "SPAN"].includes(target.tagName)) {
return;
}
onSelect(index);
};
const renderDropdown = () => {
if (!savedPresets?.length) {
return (
<Box display="flex" alignItems="center">
<Tooltip title={intl.formatMessage(messages.noPresets)}>
<Text variant="title" size="small">
{selectAllLabel}
</Text>
</Tooltip>
</Box>
);
}
return (
<Dropdown
open={isOpen}
onOpenChange={open => {
if (onOpenChange) {
onOpenChange(open);
}
}}
>
<Dropdown.Trigger>
<DropdownButton
variant="text"
size="medium"
data-test-id="show-saved-filters-button"
style={{
borderColor: isOpen ? vars.colors.border.brandDefault : undefined,
}}
>
<Box __maxWidth="200px">
<Text ellipsis variant="title" size="small" display="block">
{getLabel()}
</Text>
</Box>
</DropdownButton>
</Dropdown.Trigger>
<Dropdown.Content align="start">
<List
__maxWidth={250}
__minWidth={175}
__maxHeight={400}
overflowY="auto"
padding={3}
borderRadius={3}
boxShadow="overlay"
borderColor="neutralHighlight"
borderStyle="solid"
borderWidth={1}
width="100%"
marginTop={2}
backgroundColor="surfaceNeutralPlain"
>
<Dropdown.Item>
<List.Item
paddingX={4}
paddingY={3}
gap={6}
borderRadius={3}
onClick={onSelectAll}
>
<Text variant={activePreset === 0 ? "bodyStrong" : "body"}>
{selectAllLabel}
</Text>
</List.Item>
</Dropdown.Item>
{savedPresets.length > 0 && (
<Box
height={1}
marginY={3}
__backgroundColor={vars.colors.border.neutralHighlight}
__marginLeft={-4}
__width={getSeparatorWidth("4px")}
/>
)}
<Box display="flex" flexDirection="column" gap={2}>
{savedPresets.map((preset, index) => (
<FilterPresetItem
isActive={activePreset === index + 1}
onSelect={e => handleSelectPreset(e, index + 1)}
onRemove={() => {
onRemove(index + 1);
}}
>
{preset}
</FilterPresetItem>
))}
</Box>
</List>
</Dropdown.Content>
</Dropdown>
);
};
return (
<Box display="flex" alignItems="center">
{renderDropdown()}
{showUpdateButton && (
<Button
className={sprinkles({
marginLeft: 6,
})}
onClick={() => onUpdate(savedPresets[activePreset - 1])}
variant="secondary"
size="small"
>
<FormattedMessage {...commonMessages.update} />
</Button>
)}
{showSaveButton && (
<Tooltip title={intl.formatMessage(messages.savePreset)}>
<Button
className={sprinkles({
marginLeft: 6,
})}
icon={<PlusIcon />}
onClick={onSave}
variant="secondary"
size="small"
/>
</Tooltip>
)}
</Box>
);
};

View file

@ -0,0 +1 @@
export * from "./FilterPresetsSelect";

View file

@ -0,0 +1,20 @@
import { defineMessages } from "react-intl";
export const messages = defineMessages({
unsavedPreset: {
defaultMessage: "Unsaved preset",
id: "A+g/VP",
},
filterPreset: {
defaultMessage: "Filter preset",
id: "3PVGWj",
},
savePreset: {
defaultMessage: "Save search as preset",
id: "scTuDZ",
},
noPresets: {
defaultMessage: "Saved search queries will appear here",
id: "eW36Jx",
},
});

View file

@ -0,0 +1,5 @@
export const getSeparatorWidth = (paddingValue: string) =>
// Separator should cover the whole width of the container,
// as the container has padding we have to add it to the width
// because container is moved to the left by the padding value
`calc(100% + ${paddingValue})`;

View file

@ -38,7 +38,7 @@ const SaveFilterTabDialog: React.FC<SaveFilterTabDialogProps> = ({
const intl = useIntl(); const intl = useIntl();
const [errors, setErrors] = React.useState(false); const [errors, setErrors] = React.useState(false);
const handleErrors = data => { const handleErrors = data => {
if (data.name.length) { if (data.name.trim().length) {
onSubmit(data); onSubmit(data);
setErrors(false); setErrors(false);
} else { } else {
@ -50,9 +50,9 @@ const SaveFilterTabDialog: React.FC<SaveFilterTabDialogProps> = ({
<Dialog onClose={onClose} open={open} fullWidth maxWidth="sm"> <Dialog onClose={onClose} open={open} fullWidth maxWidth="sm">
<DialogTitle disableTypography> <DialogTitle disableTypography>
<FormattedMessage <FormattedMessage
id="liLrVs" id="P9YktI"
defaultMessage="Save Custom Search" defaultMessage="Save view preset"
description="save filter tab, header" description="save preset, header"
/> />
</DialogTitle> </DialogTitle>
<Form initial={initialForm} onSubmit={handleErrors}> <Form initial={initialForm} onSubmit={handleErrors}>
@ -63,9 +63,9 @@ const SaveFilterTabDialog: React.FC<SaveFilterTabDialogProps> = ({
autoFocus autoFocus
fullWidth fullWidth
label={intl.formatMessage({ label={intl.formatMessage({
id: "QcIFCs", id: "zhnwl6",
defaultMessage: "Search Name", defaultMessage: "Preset name",
description: "save search tab", description: "save preset name",
})} })}
name={"name" as keyof SaveFilterTabDialogFormData} name={"name" as keyof SaveFilterTabDialogFormData}
value={data.name} value={data.name}
@ -75,7 +75,9 @@ const SaveFilterTabDialog: React.FC<SaveFilterTabDialogProps> = ({
/> />
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<BackButton onClick={onClose} /> <BackButton onClick={onClose}>
<FormattedMessage {...buttonMessages.cancel} />
</BackButton>
<ConfirmButton <ConfirmButton
transitionState={confirmButtonState} transitionState={confirmButtonState}
onClick={submit} onClick={submit}

View file

@ -1,6 +1,6 @@
import useNavigator from "@dashboard/hooks/useNavigator"; import useNavigator from "@dashboard/hooks/useNavigator";
import { Sort } from "@dashboard/types"; import { Sort } from "@dashboard/types";
import { useEffect } from "react"; import { useEffect, useRef } from "react";
export type SortByRankUrlQueryParams<T extends string> = Sort<T> & { export type SortByRankUrlQueryParams<T extends string> = Sort<T> & {
query?: string; query?: string;
@ -24,17 +24,35 @@ export function useSortRedirects<SortField extends string>({
resetToDefault, resetToDefault,
}: UseSortRedirectsOpts<SortField>) { }: UseSortRedirectsOpts<SortField>) {
const navigate = useNavigator(); const navigate = useNavigator();
const prevAscParams = useRef<boolean | null>(null);
const hasQuery = !!params.query?.trim(); const hasQuery = !!params.query?.trim();
const getAscParam = () => {
if (hasQuery) {
return false;
}
if (!hasQuery && prevAscParams.current !== null) {
return true;
}
return params.asc;
};
useEffect(() => { useEffect(() => {
const sortWithQuery = "rank" as SortField; const sortWithQuery = "rank" as SortField;
const sortWithoutQuery = const sortWithoutQuery =
params.sort === "rank" ? defaultSortField : params.sort; params.sort === "rank" ? defaultSortField : params.sort;
if (hasQuery || params.sort === "rank") {
prevAscParams.current = params.asc;
}
navigate( navigate(
urlFunc({ urlFunc({
...params, ...params,
asc: hasQuery ? false : params.asc, asc: getAscParam(),
sort: hasQuery ? sortWithQuery : sortWithoutQuery, sort: hasQuery ? sortWithQuery : sortWithoutQuery,
}), }),
{ replace: true }, { replace: true },

View file

@ -131,6 +131,10 @@ export const commonMessages = defineMessages({
id: "JqiqNj", id: "JqiqNj",
defaultMessage: "Something went wrong", defaultMessage: "Something went wrong",
}, },
update: {
defaultMessage: "Update",
id: "BWpuKl",
},
startDate: { startDate: {
id: "QirE3M", id: "QirE3M",
defaultMessage: "Start Date", defaultMessage: "Start Date",

View file

@ -33,7 +33,8 @@ const props: OrderListPageProps = {
}, },
channel: { channel: {
active: false, active: false,
value: [ value: [],
choices: [
{ {
label: "Channel PLN", label: "Channel PLN",
value: "channelId", value: "channelId",

View file

@ -1,5 +1,5 @@
import { IFilter } from "@dashboard/components/Filter"; import { IFilter } from "@dashboard/components/Filter";
import { MultiAutocompleteChoiceType } from "@dashboard/components/MultiAutocompleteSelectField"; import { SingleAutocompleteChoiceType } from "@dashboard/components/SingleAutocompleteSelectField";
import { OrderStatusFilter, PaymentChargeStatusEnum } from "@dashboard/graphql"; import { OrderStatusFilter, PaymentChargeStatusEnum } from "@dashboard/graphql";
import { import {
commonMessages, commonMessages,
@ -39,7 +39,7 @@ export interface OrderListFilterOpts {
customer: FilterOpts<string>; customer: FilterOpts<string>;
status: FilterOpts<OrderStatusFilter[]>; status: FilterOpts<OrderStatusFilter[]>;
paymentStatus: FilterOpts<PaymentChargeStatusEnum[]>; paymentStatus: FilterOpts<PaymentChargeStatusEnum[]>;
channel?: FilterOpts<MultiAutocompleteChoiceType[]>; channel: FilterOpts<string[]> & { choices: SingleAutocompleteChoiceType[] };
clickAndCollect: FilterOpts<boolean>; clickAndCollect: FilterOpts<boolean>;
preorder: FilterOpts<boolean>; preorder: FilterOpts<boolean>;
giftCard: FilterOpts<OrderFilterGiftCard[]>; giftCard: FilterOpts<OrderFilterGiftCard[]>;
@ -247,15 +247,15 @@ export function createFilterStructure(
), ),
active: opts.metadata.active, active: opts.metadata.active,
}, },
...(opts?.channel?.value.length ...(opts?.channel?.choices?.length
? [ ? [
{ {
...createOptionsField( ...createOptionsField(
OrderFilterKeys.channel, OrderFilterKeys.channel,
intl.formatMessage(messages.channel), intl.formatMessage(messages.channel),
[],
true,
opts.channel.value, opts.channel.value,
true,
opts.channel.choices,
), ),
active: opts.channel.active, active: opts.channel.active,
}, },

View file

@ -51,7 +51,8 @@ describe("Filtering URL params", () => {
}, },
channel: { channel: {
active: false, active: false,
value: [ value: [],
choices: [
{ {
label: "Channel PLN", label: "Channel PLN",
value: "channelId", value: "channelId",

View file

@ -53,7 +53,8 @@ export function getFilterOpts(
channel: channels channel: channels
? { ? {
active: params?.channel !== undefined, active: params?.channel !== undefined,
value: channels, choices: channels,
value: params?.channel ?? [],
} }
: null, : null,
created: { created: {

View file

@ -58,6 +58,7 @@ interface ProductListDatagridProps
>; >;
onColumnQueryChange: (query: string) => void; onColumnQueryChange: (query: string) => void;
isAttributeLoading?: boolean; isAttributeLoading?: boolean;
hasRowHover?: boolean;
} }
export const ProductListDatagrid: React.FC<ProductListDatagridProps> = ({ export const ProductListDatagrid: React.FC<ProductListDatagridProps> = ({
@ -80,6 +81,7 @@ export const ProductListDatagrid: React.FC<ProductListDatagridProps> = ({
onColumnQueryChange, onColumnQueryChange,
activeAttributeSortId, activeAttributeSortId,
filterDependency, filterDependency,
hasRowHover,
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
const searchProductType = useSearchProductTypes(); const searchProductType = useSearchProductTypes();
@ -230,6 +232,7 @@ export const ProductListDatagrid: React.FC<ProductListDatagridProps> = ({
rowMarkers="none" rowMarkers="none"
columnSelect="single" columnSelect="single"
freezeColumns={2} freezeColumns={2}
hasRowHover={hasRowHover}
onColumnMoved={handleColumnMoved} onColumnMoved={handleColumnMoved}
onColumnResize={handleColumnResize} onColumnResize={handleColumnResize}
verticalBorder={col => (col > 1 ? true : false)} verticalBorder={col => (col > 1 ? true : false)}

View file

@ -36,6 +36,10 @@ const props: ProductListPageProps = {
}, },
channels: [], channels: [],
columnQuery: "", columnQuery: "",
currentTab: 0,
hasPresetsChanged: false,
onTabSave: () => undefined,
onTabUpdate: () => undefined,
availableInGridAttributes: [], availableInGridAttributes: [],
onColumnQueryChange: () => undefined, onColumnQueryChange: () => undefined,
}, },

View file

@ -4,10 +4,11 @@ import {
mapToMenuItemsForProductOverviewActions, mapToMenuItemsForProductOverviewActions,
useExtensions, useExtensions,
} from "@dashboard/apps/hooks/useExtensions"; } from "@dashboard/apps/hooks/useExtensions";
import { FilterBar } from "@dashboard/components/AppLayout/FilterBar"; import { ListFilters } from "@dashboard/components/AppLayout/ListFilters";
import { TopNav } from "@dashboard/components/AppLayout/TopNav"; import { TopNav } from "@dashboard/components/AppLayout/TopNav";
import { ButtonWithDropdown } from "@dashboard/components/ButtonWithDropdown"; import { ButtonWithDropdown } from "@dashboard/components/ButtonWithDropdown";
import { getByName } from "@dashboard/components/Filter/utils"; import { getByName } from "@dashboard/components/Filter/utils";
import { FilterPresetsSelect } from "@dashboard/components/FilterPresetsSelect";
import { ListPageLayout } from "@dashboard/components/Layouts"; import { ListPageLayout } from "@dashboard/components/Layouts";
import LimitReachedAlert from "@dashboard/components/LimitReachedAlert"; import LimitReachedAlert from "@dashboard/components/LimitReachedAlert";
import { TopNavMenu } from "@dashboard/components/TopNavMenu"; import { TopNavMenu } from "@dashboard/components/TopNavMenu";
@ -32,8 +33,8 @@ import {
} from "@dashboard/types"; } from "@dashboard/types";
import { hasLimits, isLimitReached } from "@dashboard/utils/limits"; import { hasLimits, isLimitReached } from "@dashboard/utils/limits";
import { Card } from "@material-ui/core"; import { Card } from "@material-ui/core";
import { Box, Button, Text } from "@saleor/macaw-ui/next"; import { Box, Button, ChevronRightIcon, Text } from "@saleor/macaw-ui/next";
import React from "react"; import React, { useState } from "react";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import { ProductListUrlSortField, productUrl } from "../../urls"; import { ProductListUrlSortField, productUrl } from "../../urls";
@ -49,7 +50,10 @@ import {
export interface ProductListPageProps export interface ProductListPageProps
extends PageListProps<ProductListColumns>, extends PageListProps<ProductListColumns>,
ListActions, ListActions,
FilterPageProps<ProductFilterKeys, ProductListFilterOpts>, Omit<
FilterPageProps<ProductFilterKeys, ProductListFilterOpts>,
"onTabDelete"
>,
FetchMoreProps, FetchMoreProps,
SortPage<ProductListUrlSortField>, SortPage<ProductListUrlSortField>,
ChannelProps { ChannelProps {
@ -63,9 +67,12 @@ export interface ProductListPageProps
limits: RefreshLimitsQuery["shop"]["limits"]; limits: RefreshLimitsQuery["shop"]["limits"];
products: RelayToFlat<ProductListQuery["products"]>; products: RelayToFlat<ProductListQuery["products"]>;
selectedProductIds: string[]; selectedProductIds: string[];
hasPresetsChanged: boolean;
onAdd: () => void; onAdd: () => void;
onExport: () => void; onExport: () => void;
onColumnQueryChange: (query: string) => void; onColumnQueryChange: (query: string) => void;
onTabUpdate: (tabName: string) => void;
onTabDelete: (tabIndex: number) => void;
} }
export type ProductListViewType = "datagrid" | "tile"; export type ProductListViewType = "datagrid" | "tile";
@ -95,11 +102,20 @@ export const ProductListPage: React.FC<ProductListPageProps> = props => {
selectedChannelId, selectedChannelId,
selectedProductIds, selectedProductIds,
activeAttributeSortId, activeAttributeSortId,
onTabChange,
onTabDelete,
onTabSave,
onAll,
currentTab,
tabs,
onTabUpdate,
hasPresetsChanged,
...listProps ...listProps
} = props; } = props;
const intl = useIntl(); const intl = useIntl();
const navigate = useNavigator(); const navigate = useNavigator();
const filterStructure = createFilterStructure(intl, filterOpts); const filterStructure = createFilterStructure(intl, filterOpts);
const [isFilterPresetOpen, setFilterPresetOpen] = useState(false);
const filterDependency = filterStructure.find(getByName("channel")); const filterDependency = filterStructure.find(getByName("channel"));
@ -122,59 +138,93 @@ export const ProductListPage: React.FC<ProductListPageProps> = props => {
return ( return (
<ListPageLayout> <ListPageLayout>
<TopNav withoutBorder title={intl.formatMessage(sectionNames.products)}> <TopNav
<Box display="flex" alignItems="center" gap={5}> withoutBorder
{hasLimits(limits, "productVariants") && ( isAlignToRight={false}
<Text variant="caption"> title={intl.formatMessage(sectionNames.products)}
{intl.formatMessage( >
<Box
__flex={1}
display="flex"
justifyContent="space-between"
alignItems="center"
>
<Box display="flex">
<Box marginX={6} display="flex" alignItems="center">
<ChevronRightIcon />
</Box>
<FilterPresetsSelect
presetsChanged={hasPresetsChanged}
onSelect={onTabChange}
onRemove={onTabDelete}
onUpdate={onTabUpdate}
savedPresets={tabs}
activePreset={currentTab}
onSelectAll={onAll}
onSave={onTabSave}
isOpen={isFilterPresetOpen}
onOpenChange={setFilterPresetOpen}
selectAllLabel={intl.formatMessage({
id: "tCLTCb",
defaultMessage: "All products",
description: "tab name",
})}
/>
</Box>
<Box display="flex" alignItems="center" gap={5}>
{hasLimits(limits, "productVariants") && (
<Text variant="caption">
{intl.formatMessage(
{
id: "Kw0jHS",
defaultMessage: "{count}/{max} SKUs used",
description: "created products counter",
},
{
count: limits.currentUsage.productVariants,
max: limits.allowedUsage.productVariants,
},
)}
</Text>
)}
<TopNavMenu
dataTestId="menu"
items={[
{ {
id: "Kw0jHS", label: intl.formatMessage({
defaultMessage: "{count}/{max} SKUs used", id: "7FL+WZ",
description: "created products counter", defaultMessage: "Export Products",
description: "export products to csv file, button",
}),
onSelect: onExport,
testId: "export",
}, },
{ ...extensionMenuItems,
count: limits.currentUsage.productVariants, ]}
max: limits.allowedUsage.productVariants, />
}, {extensionCreateButtonItems.length > 0 ? (
)} <ButtonWithDropdown
</Text> onClick={onAdd}
)} testId={"add-product"}
<TopNavMenu options={extensionCreateButtonItems}
dataTestId="menu" >
items={[ <FormattedMessage
{ id="JFmOfi"
label: intl.formatMessage({ defaultMessage="Create Product"
id: "7FL+WZ", description="button"
defaultMessage: "Export Products", />
description: "export products to csv file, button", </ButtonWithDropdown>
}), ) : (
onSelect: onExport, <Button data-test-id="add-product" onClick={onAdd}>
testId: "export", <FormattedMessage
}, id="JFmOfi"
...extensionMenuItems, defaultMessage="Create Product"
]} description="button"
/> />
{extensionCreateButtonItems.length > 0 ? ( </Button>
<ButtonWithDropdown )}
onClick={onAdd} </Box>
testId={"add-product"}
options={extensionCreateButtonItems}
>
<FormattedMessage
id="JFmOfi"
defaultMessage="Create Product"
description="button"
/>
</ButtonWithDropdown>
) : (
<Button data-test-id="add-product" onClick={onAdd}>
<FormattedMessage
id="JFmOfi"
defaultMessage="Create Product"
description="button"
/>
</Button>
)}
</Box> </Box>
</TopNav> </TopNav>
{limitReached && ( {limitReached && (
@ -199,7 +249,7 @@ export const ProductListPage: React.FC<ProductListPageProps> = props => {
alignItems="stretch" alignItems="stretch"
justifyContent="space-between" justifyContent="space-between"
> >
<FilterBar <ListFilters
currencySymbol={currencySymbol} currencySymbol={currencySymbol}
initialSearch={initialSearch} initialSearch={initialSearch}
onFilterChange={onFilterChange} onFilterChange={onFilterChange}
@ -221,6 +271,7 @@ export const ProductListPage: React.FC<ProductListPageProps> = props => {
{isDatagridView ? ( {isDatagridView ? (
<ProductListDatagrid <ProductListDatagrid
{...listProps} {...listProps}
hasRowHover={!isFilterPresetOpen}
filterDependency={filterDependency} filterDependency={filterDependency}
activeAttributeSortId={activeAttributeSortId} activeAttributeSortId={activeAttributeSortId}
columnQuery={columnQuery} columnQuery={columnQuery}

View file

@ -2,6 +2,7 @@ import { IFilter } from "@dashboard/components/Filter";
import { SingleAutocompleteChoiceType } from "@dashboard/components/SingleAutocompleteSelectField"; import { SingleAutocompleteChoiceType } from "@dashboard/components/SingleAutocompleteSelectField";
import { AttributeInputTypeEnum, StockAvailability } from "@dashboard/graphql"; import { AttributeInputTypeEnum, StockAvailability } from "@dashboard/graphql";
import { commonMessages, sectionNames } from "@dashboard/intl"; import { commonMessages, sectionNames } from "@dashboard/intl";
import { parseBoolean } from "@dashboard/misc";
import { ProductListUrlFiltersAsDictWithMultipleValues } from "@dashboard/products/urls"; import { ProductListUrlFiltersAsDictWithMultipleValues } from "@dashboard/products/urls";
import { import {
AutocompleteFilterOpts, AutocompleteFilterOpts,
@ -32,7 +33,8 @@ export const ProductFilterKeys = {
channel: "channel", channel: "channel",
productKind: "productKind", productKind: "productKind",
} as const; } as const;
export type ProductFilterKeys = typeof ProductFilterKeys[keyof typeof ProductFilterKeys]; export type ProductFilterKeys =
(typeof ProductFilterKeys)[keyof typeof ProductFilterKeys];
export type AttributeFilterOpts = FilterOpts<string[]> & { export type AttributeFilterOpts = FilterOpts<string[]> & {
id: string; id: string;
@ -105,9 +107,9 @@ const messages = defineMessages({
}, },
}); });
const filterByType = (type: AttributeInputTypeEnum) => ( const filterByType =
attribute: AttributeFilterOpts, (type: AttributeInputTypeEnum) => (attribute: AttributeFilterOpts) =>
) => attribute.inputType === type; attribute.inputType === type;
export function createFilterStructure( export function createFilterStructure(
intl: IntlShape, intl: IntlShape,
@ -254,7 +256,7 @@ export function createFilterStructure(
attr.slug, attr.slug,
attr.name, attr.name,
Array.isArray(attr.value) Array.isArray(attr.value)
? undefined ? parseBoolean(attr.value[0], undefined)
: (attr.value as unknown) === "true", : (attr.value as unknown) === "true",
{ {
positive: intl.formatMessage(commonMessages.yes), positive: intl.formatMessage(commonMessages.yes),

View file

@ -76,6 +76,7 @@ export interface ProductListUrlQueryParams
Pagination, Pagination,
ActiveTab { ActiveTab {
attributeId?: string; attributeId?: string;
presestesChanged?: string;
} }
export const productListUrl = (params?: ProductListUrlQueryParams): string => export const productListUrl = (params?: ProductListUrlQueryParams): string =>
productListPath + "?" + stringifyQs(params); productListPath + "?" + stringifyQs(params);

View file

@ -58,12 +58,14 @@ import useCategorySearch from "@dashboard/searches/useCategorySearch";
import useCollectionSearch from "@dashboard/searches/useCollectionSearch"; import useCollectionSearch from "@dashboard/searches/useCollectionSearch";
import useProductTypeSearch from "@dashboard/searches/useProductTypeSearch"; import useProductTypeSearch from "@dashboard/searches/useProductTypeSearch";
import { ListViews } from "@dashboard/types"; import { ListViews } from "@dashboard/types";
import { prepareQs } from "@dashboard/utils/filters/qs";
import createDialogActionHandlers from "@dashboard/utils/handlers/dialogActionHandlers"; import createDialogActionHandlers from "@dashboard/utils/handlers/dialogActionHandlers";
import createFilterHandlers from "@dashboard/utils/handlers/filterHandlers"; import createFilterHandlers from "@dashboard/utils/handlers/filterHandlers";
import { mapEdgesToItems, mapNodeToChoice } from "@dashboard/utils/maps"; import { mapEdgesToItems, mapNodeToChoice } from "@dashboard/utils/maps";
import { getSortUrlVariables } from "@dashboard/utils/sort"; import { getSortUrlVariables } from "@dashboard/utils/sort";
import { DialogContentText } from "@material-ui/core"; import { DialogContentText } from "@material-ui/core";
import { DeleteIcon, IconButton } from "@saleor/macaw-ui"; import { DeleteIcon, IconButton } from "@saleor/macaw-ui";
import { stringify } from "qs";
import React, { useState } from "react"; import React, { useState } from "react";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
@ -71,16 +73,20 @@ import { useSortRedirects } from "../../../hooks/useSortRedirects";
import ProductListPage from "../../components/ProductListPage"; import ProductListPage from "../../components/ProductListPage";
import { import {
deleteFilterTab, deleteFilterTab,
getActiveFilters,
getFilterOpts, getFilterOpts,
getFilterQueryParam, getFilterQueryParam,
getFiltersCurrentTab,
getFilterTabs, getFilterTabs,
getFilterVariables, getFilterVariables,
saveFilterTab, saveFilterTab,
updateFilterTab,
} from "./filters"; } from "./filters";
import { canBeSorted, DEFAULT_SORT_KEY, getSortQueryVariables } from "./sort"; import { canBeSorted, DEFAULT_SORT_KEY, getSortQueryVariables } from "./sort";
import { getAvailableProductKinds, getProductKindOpts } from "./utils"; import {
getActiveTabIndexAfterTabDelete,
getAvailableProductKinds,
getNextUniqueTabName,
getProductKindOpts,
} from "./utils";
interface ProductListProps { interface ProductListProps {
params: ProductListUrlQueryParams; params: ProductListUrlQueryParams;
@ -91,6 +97,7 @@ export const ProductList: React.FC<ProductListProps> = ({ params }) => {
const notify = useNotifier(); const notify = useNotifier();
const { queue } = useBackgroundTask(); const { queue } = useBackgroundTask();
const [tabIndexToDelete, setTabIndexToDelete] = useState<number | null>(null);
const { isSelected, listElements, reset, toggle, toggleAll } = useBulkActions( const { isSelected, listElements, reset, toggle, toggleAll } = useBulkActions(
[], [],
); );
@ -190,7 +197,8 @@ export const ProductList: React.FC<ProductListProps> = ({ params }) => {
const tabs = getFilterTabs(); const tabs = getFilterTabs();
const currentTab = getFiltersCurrentTab(params, tabs); const currentTab =
params.activeTab !== undefined ? parseInt(params.activeTab, 10) : undefined;
const countAllProducts = useProductCountQuery({ const countAllProducts = useProductCountQuery({
skip: params.action !== "export", skip: params.action !== "export",
@ -227,29 +235,59 @@ export const ProductList: React.FC<ProductListProps> = ({ params }) => {
getFilterQueryParam, getFilterQueryParam,
navigate, navigate,
params, params,
keepActiveTab: true,
}); });
const handleTabChange = (tab: number) => { const handleTabChange = (tab: number) => {
reset(); reset();
navigate( const qs = new URLSearchParams(getFilterTabs()[tab - 1]?.data ?? "");
productListUrl({ qs.append("activeTab", tab.toString());
activeTab: tab.toString(),
...getFilterTabs()[tab - 1].data, navigate(productListUrl() + qs.toString());
}),
);
}; };
const handleFilterTabDelete = () => { const handleFilterTabDelete = () => {
deleteFilterTab(currentTab); deleteFilterTab(tabIndexToDelete);
reset(); reset();
navigate(productListUrl());
// When deleting the current tab, navigate to the All products
if (tabIndexToDelete === currentTab) {
navigate(productListUrl());
} else {
const currentParams = { ...params };
// When deleting a tab that is not the current one, only remove the action param from the query
delete currentParams.action;
// When deleting a tab that is before the current one, decrease the activeTab param by 1
currentParams.activeTab = getActiveTabIndexAfterTabDelete(
currentTab,
tabIndexToDelete,
);
navigate(productListUrl() + stringify(currentParams));
}
}; };
const handleFilterTabSave = (data: SaveFilterTabDialogFormData) => { const handleFilterTabSave = (data: SaveFilterTabDialogFormData) => {
saveFilterTab(data.name, getActiveFilters(params)); const { paresedQs } = prepareQs(location.search);
saveFilterTab(
getNextUniqueTabName(
data.name,
tabs.map(tab => tab.name),
),
stringify(paresedQs),
);
handleTabChange(tabs.length + 1); handleTabChange(tabs.length + 1);
}; };
const hanleFilterTabUpdate = (tabName: string) => {
const { paresedQs, activeTab } = prepareQs(location.search);
updateFilterTab(tabName, stringify(paresedQs));
paresedQs.activeTab = activeTab;
navigate(productListUrl() + stringify(paresedQs));
};
const handleSort = (field: ProductListUrlSortField, attributeId?: string) => const handleSort = (field: ProductListUrlSortField, attributeId?: string) =>
navigate( navigate(
productListUrl({ productListUrl({
@ -355,6 +393,17 @@ export const ProductList: React.FC<ProductListProps> = ({ params }) => {
channelOpts, channelOpts,
); );
const hasPresetsChanged = () => {
const activeTab = tabs[currentTab - 1];
const { paresedQs } = prepareQs(location.search);
return (
activeTab?.data !== stringify(paresedQs) &&
location.search !== "" &&
stringify(paresedQs) !== ""
);
};
const paginationValues = usePaginator({ const paginationValues = usePaginator({
pageInfo: data?.products?.pageInfo, pageInfo: data?.products?.pageInfo,
paginationState, paginationState,
@ -416,10 +465,15 @@ export const ProductList: React.FC<ProductListProps> = ({ params }) => {
onFilterChange={changeFilters} onFilterChange={changeFilters}
onFilterAttributeFocus={setFocusedAttribute} onFilterAttributeFocus={setFocusedAttribute}
onTabSave={() => openModal("save-search")} onTabSave={() => openModal("save-search")}
onTabDelete={() => openModal("delete-search")} onTabUpdate={hanleFilterTabUpdate}
onTabDelete={(tabIndex: number) => {
setTabIndexToDelete(tabIndex);
openModal("delete-search");
}}
onTabChange={handleTabChange} onTabChange={handleTabChange}
hasPresetsChanged={hasPresetsChanged()}
initialSearch={params.query || ""} initialSearch={params.query || ""}
tabs={getFilterTabs().map(tab => tab.name)} tabs={tabs.map(tab => tab.name)}
onExport={() => openModal("export")} onExport={() => openModal("export")}
selectedChannelId={selectedChannel?.id} selectedChannelId={selectedChannel?.id}
columnQuery={availableInGridAttributesOpts.query} columnQuery={availableInGridAttributesOpts.query}
@ -498,7 +552,7 @@ export const ProductList: React.FC<ProductListProps> = ({ params }) => {
confirmButtonState="default" confirmButtonState="default"
onClose={closeModal} onClose={closeModal}
onSubmit={handleFilterTabDelete} onSubmit={handleFilterTabDelete}
tabName={maybe(() => tabs[currentTab - 1].name, "...")} tabName={tabs[tabIndexToDelete - 1]?.name ?? "..."}
/> />
<ProductTypePickerDialog <ProductTypePickerDialog
confirmButtonState="success" confirmButtonState="success"

View file

@ -56,7 +56,7 @@ import {
ProductListUrlQueryParams, ProductListUrlQueryParams,
} from "../../urls"; } from "../../urls";
import { getProductGiftCardFilterParam } from "./utils"; import { getProductGiftCardFilterParam } from "./utils";
export const PRODUCT_FILTERS_KEY = "productFilters"; export const PRODUCT_FILTERS_KEY = "productPresets";
function getAttributeFilterParamType(inputType: AttributeInputTypeEnum) { function getAttributeFilterParamType(inputType: AttributeInputTypeEnum) {
switch (inputType) { switch (inputType) {
@ -468,14 +468,12 @@ export const {
deleteFilterTab, deleteFilterTab,
getFilterTabs, getFilterTabs,
saveFilterTab, saveFilterTab,
} = createFilterTabUtils<ProductListUrlFilters>(PRODUCT_FILTERS_KEY); updateFilterTab,
} = createFilterTabUtils<string>(PRODUCT_FILTERS_KEY);
export const { export const { areFiltersApplied, getActiveFilters, getFiltersCurrentTab } =
areFiltersApplied, createFilterUtils<ProductListUrlQueryParams, ProductListUrlFilters>({
getActiveFilters, ...ProductListUrlFiltersEnum,
getFiltersCurrentTab, ...ProductListUrlFiltersWithMultipleValues,
} = createFilterUtils<ProductListUrlQueryParams, ProductListUrlFilters>({ ...ProductListUrlFiltersAsDictWithMultipleValues,
...ProductListUrlFiltersEnum, });
...ProductListUrlFiltersWithMultipleValues,
...ProductListUrlFiltersAsDictWithMultipleValues,
});

View file

@ -0,0 +1,49 @@
import { getActiveTabIndexAfterTabDelete, getNextUniqueTabName } from "./utils";
describe("ProductList utils", () => {
describe("getNextUniqueTabName", () => {
it("should return unique name", () => {
// Arrange
const name = "test";
const availableNames = ["test", "test 1", "test 2"];
// Act
const result = getNextUniqueTabName(name, availableNames);
// Assert
expect(result).toEqual("test 3");
});
});
describe("getActiveTabIndexAfterDelete", () => {
it("should return active tab index descread by one when delete index before current tab index", () => {
// Arrange
const currentTab = 5;
const tabIndexToDelete = 1;
// Act
const result = getActiveTabIndexAfterTabDelete(
currentTab,
tabIndexToDelete,
);
// Assert
expect(result).toEqual("4");
});
it("should return active tab same active tab index when delete tab index higher than current tab", () => {
// Arrange
const currentTab = 5;
const tabIndexToDelete = 7;
// Act
const result = getActiveTabIndexAfterTabDelete(
currentTab,
tabIndexToDelete,
);
// Assert
expect(result).toEqual("5");
});
});
});

View file

@ -44,3 +44,21 @@ export const getProductGiftCardFilterParam = (productKind?: string) => {
return productKind === ProductTypeKindEnum.GIFT_CARD; return productKind === ProductTypeKindEnum.GIFT_CARD;
}; };
export const getNextUniqueTabName = (name: string, avialabeNames: string[]) => {
let uniqueName = name;
let i = 1;
while (avialabeNames.includes(uniqueName)) {
uniqueName = `${name} ${i}`;
i++;
}
return uniqueName;
};
export const getActiveTabIndexAfterTabDelete = (
currentTab: number,
tabIndexToDelete: number,
): string =>
tabIndexToDelete < currentTab ? `${currentTab - 1}` : `${currentTab}`;

View file

@ -83,10 +83,9 @@ export interface ListActionsWithoutToolbar {
isChecked: (id: string) => boolean; isChecked: (id: string) => boolean;
selected: number; selected: number;
} }
export type TabListActions< export type TabListActions<TToolbars extends string> =
TToolbars extends string ListActionsWithoutToolbar &
> = ListActionsWithoutToolbar & Record<TToolbars, React.ReactNode | React.ReactNodeArray>;
Record<TToolbars, React.ReactNode | React.ReactNodeArray>;
export interface ListActions extends ListActionsWithoutToolbar { export interface ListActions extends ListActionsWithoutToolbar {
toolbar: React.ReactNode | React.ReactNodeArray; toolbar: React.ReactNode | React.ReactNodeArray;
} }
@ -129,7 +128,7 @@ export interface ChannelProps {
export interface PartialMutationProviderOutput< export interface PartialMutationProviderOutput<
TData extends {} = {}, TData extends {} = {},
TVariables extends {} = {} TVariables extends {} = {},
> { > {
opts: MutationResult<TData> & MutationResultAdditionalProps; opts: MutationResult<TData> & MutationResultAdditionalProps;
mutate: (variables: TVariables) => Promise<FetchResult<TData>>; mutate: (variables: TVariables) => Promise<FetchResult<TData>>;

View file

@ -13,7 +13,7 @@ function createFilterUtils<
>(filters: {}) { >(filters: {}) {
function getActiveFilters(params: TQueryParams): TFilters { function getActiveFilters(params: TQueryParams): TFilters {
return Object.keys(params) return Object.keys(params)
.filter(key => Object.keys(filters).includes(key)) .filter(key => Object.values(filters).includes(key))
.reduce((acc, key) => { .reduce((acc, key) => {
acc[key] = params[key]; acc[key] = params[key];
return acc; return acc;

View file

@ -0,0 +1,16 @@
import { prepareQs } from "./qs";
describe("Filters: preapreQS", () => {
it("should remove activeTab, action, sort, asc from query string", () => {
const qs = prepareQs(
"?category=1&activeTab=1&channel=usa&action=save-search&sort=name&asc=4",
);
expect(qs).toEqual({
activeTab: "1",
paresedQs: {
category: "1",
channel: "usa",
},
});
});
});

19
src/utils/filters/qs.ts Normal file
View file

@ -0,0 +1,19 @@
import { parse } from "qs";
const paramsToRemove = ["activeTab", "action", "sort", "asc"];
export const prepareQs = (searchQuery: string) => {
const paresedQs = parse(
searchQuery.startsWith("?") ? searchQuery.slice(1) : searchQuery,
);
const activeTab = paresedQs.activeTab;
paramsToRemove.forEach(param => {
delete paresedQs[param];
});
return {
activeTab,
paresedQs,
};
};

View file

@ -31,6 +31,26 @@ function saveFilterTab<TUrlFilters>(
); );
} }
function updateFilterTab<TUrlFilters>(
tabName: string,
data: TUrlFilters,
key: string,
) {
const userFilters = getFilterTabs<TUrlFilters>(key);
const updatedFilters = userFilters.map(tab => {
if (tab.name === tabName) {
return {
data,
name: tabName,
};
}
return tab;
});
localStorage.setItem(key, JSON.stringify([...updatedFilters]));
}
function deleteFilterTab(id: number, key: string) { function deleteFilterTab(id: number, key: string) {
const userFilters = getFilterTabs(key); const userFilters = getFilterTabs(key);
@ -46,6 +66,8 @@ function createFilterTabUtils<TUrlFilters>(key: string) {
getFilterTabs: () => getFilterTabs<TUrlFilters>(key), getFilterTabs: () => getFilterTabs<TUrlFilters>(key),
saveFilterTab: (name: string, data: TUrlFilters) => saveFilterTab: (name: string, data: TUrlFilters) =>
saveFilterTab<TUrlFilters>(name, data, key), saveFilterTab<TUrlFilters>(name, data, key),
updateFilterTab: (tabName: string, data: TUrlFilters) =>
updateFilterTab<TUrlFilters>(tabName, data, key),
}; };
} }

View file

@ -4,7 +4,10 @@ import { ActiveTab, Pagination, Search, Sort } from "@dashboard/types";
import { GetFilterQueryParam, getFilterQueryParams } from "../filters"; import { GetFilterQueryParam, getFilterQueryParams } from "../filters";
type RequiredParams = ActiveTab & Search & Sort<any> & Pagination; type RequiredParams = ActiveTab &
Search &
Sort<any> &
Pagination & { presestesChanged?: string };
type CreateUrl = (params: RequiredParams) => string; type CreateUrl = (params: RequiredParams) => string;
type CreateFilterHandlers<TFilterKeys extends string> = [ type CreateFilterHandlers<TFilterKeys extends string> = [
(filter: IFilter<TFilterKeys>) => void, (filter: IFilter<TFilterKeys>) => void,
@ -21,19 +24,40 @@ function createFilterHandlers<
createUrl: CreateUrl; createUrl: CreateUrl;
params: RequiredParams; params: RequiredParams;
cleanupFn?: () => void; cleanupFn?: () => void;
keepActiveTab?: boolean;
}): CreateFilterHandlers<TFilterKeys> { }): CreateFilterHandlers<TFilterKeys> {
const { getFilterQueryParam, navigate, createUrl, params, cleanupFn } = opts; const {
getFilterQueryParam,
navigate,
createUrl,
params,
cleanupFn,
keepActiveTab,
} = opts;
const getActiveTabValue = (removeActiveTab: boolean) => {
if (!keepActiveTab || removeActiveTab) {
return undefined;
}
return params.activeTab;
};
const changeFilters = (filters: IFilter<TFilterKeys>) => { const changeFilters = (filters: IFilter<TFilterKeys>) => {
if (!!cleanupFn) { if (!!cleanupFn) {
cleanupFn(); cleanupFn();
} }
const filtersQueryParams = getFilterQueryParams(
filters,
getFilterQueryParam,
);
navigate( navigate(
createUrl({ createUrl({
...params, ...params,
...getFilterQueryParams(filters, getFilterQueryParam), ...filtersQueryParams,
activeTab: undefined, activeTab: getActiveTabValue(
checkIfParamsEmpty(filtersQueryParams) && !params.query?.length,
),
}), }),
); );
}; };
@ -55,14 +79,17 @@ function createFilterHandlers<
if (!!cleanupFn) { if (!!cleanupFn) {
cleanupFn(); cleanupFn();
} }
const trimmedQuery = query?.trim() ?? "";
navigate( navigate(
createUrl({ createUrl({
...params, ...params,
after: undefined, after: undefined,
before: undefined, before: undefined,
activeTab: undefined, activeTab: getActiveTabValue(
query: query?.trim(), checkIfParamsEmpty(params) && trimmedQuery === "",
),
query: trimmedQuery !== "" ? trimmedQuery : undefined,
}), }),
); );
}; };
@ -70,4 +97,12 @@ function createFilterHandlers<
return [changeFilters, resetFilters, handleSearchChange]; return [changeFilters, resetFilters, handleSearchChange];
} }
function checkIfParamsEmpty(params: RequiredParams): boolean {
const paramsToOmit = ["activeTab", "sort", "asc", "query"];
return Object.entries(params)
.filter(([name]) => !paramsToOmit.includes(name))
.every(([_, value]) => value === undefined);
}
export default createFilterHandlers; export default createFilterHandlers;