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

View file

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

View file

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

View file

@ -98,7 +98,7 @@ const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
borderColor="neutralPlain"
__maxWidth={contentMaxWidth}
margin="auto"
zIndex="3"
zIndex="2"
/>
</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 React, { ReactNode } from "react";
import { Filter } from "./Filter";
import SearchInput from "./SearchInput";
import { FiltersSelect } from "./components/FiltersSelect";
import SearchInput from "./components/SearchInput";
export interface FilterBarProps<TKeys extends string = string>
export interface ListFiltersProps<TKeys extends string = string>
extends FilterProps<TKeys>,
SearchPageProps {
searchPlaceholder: string;
@ -15,7 +15,7 @@ export interface FilterBarProps<TKeys extends string = string>
actions?: ReactNode;
}
export const FilterBar: React.FC<FilterBarProps> = ({
export const ListFilters = ({
currencySymbol,
filterStructure,
initialSearch,
@ -25,11 +25,11 @@ export const FilterBar: React.FC<FilterBarProps> = ({
onFilterAttributeFocus,
errorMessages,
actions,
}: FilterBarProps) => (
}: ListFiltersProps) => (
<>
<Box
display="grid"
__gridTemplateColumns="1fr 1fr"
gridTemplateColumns={2}
gap={7}
paddingBottom={5}
paddingX={9}
@ -38,13 +38,14 @@ export const FilterBar: React.FC<FilterBarProps> = ({
borderBottomWidth={1}
>
<Box display="flex" alignItems="center" gap={7}>
<Filter
<FiltersSelect
errorMessages={errorMessages}
menu={filterStructure}
currencySymbol={currencySymbol}
onFilterAdd={onFilterChange}
onFilterAttributeFocus={onFilterAttributeFocus}
/>
<Box __width="320px">
<SearchInput
initialSearch={initialSearch}
@ -59,4 +60,4 @@ export const FilterBar: React.FC<FilterBarProps> = ({
</Box>
</>
);
FilterBar.displayName = "FilterBar";
ListFilters.displayName = "FilterBar";

View file

@ -8,11 +8,11 @@ import {
import useFilter from "@dashboard/components/Filter/useFilter";
import { extractInvalidFilters } from "@dashboard/components/Filter/utils";
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 { FormattedMessage } from "react-intl";
import { FilterButton } from "./FilterButton";
import { getSelectedFilterAmount } from "./utils";
import { getSelectedFilterAmount } from "../utils";
export interface FilterProps<TFilterKeys extends string = string> {
currencySymbol?: string;
@ -22,7 +22,7 @@ export interface FilterProps<TFilterKeys extends string = string> {
onFilterAttributeFocus?: (id?: string) => void;
}
export const Filter = ({
export const FiltersSelect = ({
currencySymbol,
menu,
onFilterAdd,
@ -68,11 +68,19 @@ export const Filter = ({
mouseEvent="onMouseUp"
>
<div ref={anchor}>
<FilterButton
isFilterActive={isFilterActive}
<DropdownButton
data-test-id="show-filters-button"
onClick={() => setFilterMenuOpened(!isFilterMenuOpened)}
selectedFilterAmount={selectedFilterAmount}
>
<FormattedMessage
id="FNpv6K"
defaultMessage="Filters"
description="button"
/>
{isFilterActive && selectedFilterAmount > 0 && (
<>({selectedFilterAmount})</>
)}
</DropdownButton>
<Popper
className={sprinkles({
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;
href?: string;
withoutBorder?: boolean;
isAlignToRight?: boolean;
}
export const TopNav: React.FC<PropsWithChildren<TopNavProps>> = ({
title,
href,
withoutBorder = false,
isAlignToRight = true,
children,
}) => {
const { availableChannels, channel, isPickerActive, setChannel } =
@ -24,10 +26,16 @@ export const TopNav: React.FC<PropsWithChildren<TopNavProps>> = ({
return (
<TopNavWrapper withoutBorder={withoutBorder}>
{href && <TopNavLink to={href} />}
<Box __flex={1}>
<Text variant="title">{title}</Text>
<Box __flex={isAlignToRight ? 1 : 0}>
<Text variant="title" size="small">
{title}
</Text>
</Box>
<Box display="flex" flexWrap="nowrap">
<Box
display="flex"
flexWrap="nowrap"
__flex={isAlignToRight ? "initial" : 1}
>
{isPickerActive && (
<AppChannelSelect
channels={availableChannels}

View file

@ -1,12 +1,16 @@
import { Button } from "@dashboard/components/Button";
import { buttonMessages } from "@dashboard/intl";
import { ButtonProps } from "@saleor/macaw-ui";
import React from "react";
import React, { ReactNode } from "react";
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}>
<FormattedMessage {...buttonMessages.back} />
{children ?? <FormattedMessage {...buttonMessages.back} />}
</Button>
);

View file

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

View file

@ -1,3 +1,4 @@
import { buttonMessages } from "@dashboard/intl";
import { DialogContentText } from "@material-ui/core";
import { ConfirmButtonTransitionState } from "@saleor/macaw-ui";
import React from "react";
@ -26,19 +27,20 @@ const DeleteFilterTabDialog: React.FC<DeleteFilterTabDialogProps> = ({
<ActionDialog
open={open}
confirmButtonState={confirmButtonState}
backButtonText={intl.formatMessage(buttonMessages.cancel)}
onClose={onClose}
onConfirm={onSubmit}
title={intl.formatMessage({
id: "7NfoiJ",
defaultMessage: "Delete Search",
description: "custom search delete, dialog header",
id: "xy66ru",
defaultMessage: "Delete preset",
description: "custom preset delete, dialog header",
})}
variant="delete"
>
<DialogContentText>
<FormattedMessage
id="UaYJJ8"
defaultMessage="Are you sure you want to delete {name} search tab?"
id="U5CH0u"
defaultMessage="Are you sure you want to delete {name} preset?"
values={{
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 { vars } from "@saleor/macaw-ui/next";
import clsx from "clsx";
import React, { useState } from "react";
import React, { useMemo, useState } from "react";
import { FormattedMessage } from "react-intl";
import { FilterContent } from ".";
@ -14,7 +14,7 @@ import {
InvalidFilters,
} from "./types";
import useFilter from "./useFilter";
import { extractInvalidFilters } from "./utils";
import { extractInvalidFilters, getSelectedFiltersAmount } from "./utils";
export interface FilterProps<TFilterKeys extends string = string> {
currencySymbol?: string;
@ -103,6 +103,11 @@ const Filter: React.FC<FilterProps> = props => {
const isFilterActive = menu.some(filterElement => filterElement.active);
const selectedFilterAmount = useMemo(
() => getSelectedFiltersAmount(menu, data),
[data, menu],
);
const handleSubmit = () => {
const invalidFilters = extractInvalidFilters(data, menu);
@ -147,23 +152,8 @@ const Filter: React.FC<FilterProps> = props => {
description="button"
/>
</Typography>
{isFilterActive && (
<>
<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>
</>
{isFilterActive && selectedFilterAmount > 0 && (
<>({selectedFilterAmount})</>
)}
</Button>
<Popper

View file

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

View file

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

View file

@ -3,6 +3,7 @@ import compact from "lodash/compact";
import {
FieldType,
FilterElement,
IFilter,
InvalidFilters,
ValidationErrorCode,
} from "./types";
@ -10,13 +11,13 @@ import {
export const getByName = (nameToCompare: string) => (obj: { name: string }) =>
obj.name === nameToCompare;
export const isAutocompleteFilterFieldValid = function<T extends string>({
export const isAutocompleteFilterFieldValid = function <T extends string>({
value,
}: FilterElement<T>) {
return !!compact(value).length;
};
export const isNumberFilterFieldValid = function<T extends string>({
export const isNumberFilterFieldValid = function <T extends string>({
value,
}: FilterElement<T>) {
const [min, max] = value;
@ -28,7 +29,7 @@ export const isNumberFilterFieldValid = function<T extends string>({
return true;
};
export const isFilterFieldValid = function<T extends string>(
export const isFilterFieldValid = function <T extends string>(
filter: FilterElement<T>,
) {
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>,
) {
const { required, active } = filter;
@ -59,7 +60,7 @@ export const isFilterValid = function<T extends string>(
return isFilterFieldValid(filter);
};
export const extractInvalidFilters = function<T extends string>(
export const extractInvalidFilters = function <T extends string>(
filtersData: Array<FilterElement<T>>,
filtersDataStructure: Array<FilterElement<T>>,
): InvalidFilters<T> {
@ -118,3 +119,19 @@ export const extractInvalidFilters = function<T extends string>(
{} 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 [errors, setErrors] = React.useState(false);
const handleErrors = data => {
if (data.name.length) {
if (data.name.trim().length) {
onSubmit(data);
setErrors(false);
} else {
@ -50,9 +50,9 @@ const SaveFilterTabDialog: React.FC<SaveFilterTabDialogProps> = ({
<Dialog onClose={onClose} open={open} fullWidth maxWidth="sm">
<DialogTitle disableTypography>
<FormattedMessage
id="liLrVs"
defaultMessage="Save Custom Search"
description="save filter tab, header"
id="P9YktI"
defaultMessage="Save view preset"
description="save preset, header"
/>
</DialogTitle>
<Form initial={initialForm} onSubmit={handleErrors}>
@ -63,9 +63,9 @@ const SaveFilterTabDialog: React.FC<SaveFilterTabDialogProps> = ({
autoFocus
fullWidth
label={intl.formatMessage({
id: "QcIFCs",
defaultMessage: "Search Name",
description: "save search tab",
id: "zhnwl6",
defaultMessage: "Preset name",
description: "save preset name",
})}
name={"name" as keyof SaveFilterTabDialogFormData}
value={data.name}
@ -75,7 +75,9 @@ const SaveFilterTabDialog: React.FC<SaveFilterTabDialogProps> = ({
/>
</DialogContent>
<DialogActions>
<BackButton onClick={onClose} />
<BackButton onClick={onClose}>
<FormattedMessage {...buttonMessages.cancel} />
</BackButton>
<ConfirmButton
transitionState={confirmButtonState}
onClick={submit}

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
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 {
commonMessages,
@ -39,7 +39,7 @@ export interface OrderListFilterOpts {
customer: FilterOpts<string>;
status: FilterOpts<OrderStatusFilter[]>;
paymentStatus: FilterOpts<PaymentChargeStatusEnum[]>;
channel?: FilterOpts<MultiAutocompleteChoiceType[]>;
channel: FilterOpts<string[]> & { choices: SingleAutocompleteChoiceType[] };
clickAndCollect: FilterOpts<boolean>;
preorder: FilterOpts<boolean>;
giftCard: FilterOpts<OrderFilterGiftCard[]>;
@ -247,15 +247,15 @@ export function createFilterStructure(
),
active: opts.metadata.active,
},
...(opts?.channel?.value.length
...(opts?.channel?.choices?.length
? [
{
...createOptionsField(
OrderFilterKeys.channel,
intl.formatMessage(messages.channel),
[],
true,
opts.channel.value,
true,
opts.channel.choices,
),
active: opts.channel.active,
},

View file

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

View file

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

View file

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

View file

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

View file

@ -4,10 +4,11 @@ import {
mapToMenuItemsForProductOverviewActions,
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 { ButtonWithDropdown } from "@dashboard/components/ButtonWithDropdown";
import { getByName } from "@dashboard/components/Filter/utils";
import { FilterPresetsSelect } from "@dashboard/components/FilterPresetsSelect";
import { ListPageLayout } from "@dashboard/components/Layouts";
import LimitReachedAlert from "@dashboard/components/LimitReachedAlert";
import { TopNavMenu } from "@dashboard/components/TopNavMenu";
@ -32,8 +33,8 @@ import {
} from "@dashboard/types";
import { hasLimits, isLimitReached } from "@dashboard/utils/limits";
import { Card } from "@material-ui/core";
import { Box, Button, Text } from "@saleor/macaw-ui/next";
import React from "react";
import { Box, Button, ChevronRightIcon, Text } from "@saleor/macaw-ui/next";
import React, { useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { ProductListUrlSortField, productUrl } from "../../urls";
@ -49,7 +50,10 @@ import {
export interface ProductListPageProps
extends PageListProps<ProductListColumns>,
ListActions,
Omit<
FilterPageProps<ProductFilterKeys, ProductListFilterOpts>,
"onTabDelete"
>,
FetchMoreProps,
SortPage<ProductListUrlSortField>,
ChannelProps {
@ -63,9 +67,12 @@ export interface ProductListPageProps
limits: RefreshLimitsQuery["shop"]["limits"];
products: RelayToFlat<ProductListQuery["products"]>;
selectedProductIds: string[];
hasPresetsChanged: boolean;
onAdd: () => void;
onExport: () => void;
onColumnQueryChange: (query: string) => void;
onTabUpdate: (tabName: string) => void;
onTabDelete: (tabIndex: number) => void;
}
export type ProductListViewType = "datagrid" | "tile";
@ -95,11 +102,20 @@ export const ProductListPage: React.FC<ProductListPageProps> = props => {
selectedChannelId,
selectedProductIds,
activeAttributeSortId,
onTabChange,
onTabDelete,
onTabSave,
onAll,
currentTab,
tabs,
onTabUpdate,
hasPresetsChanged,
...listProps
} = props;
const intl = useIntl();
const navigate = useNavigator();
const filterStructure = createFilterStructure(intl, filterOpts);
const [isFilterPresetOpen, setFilterPresetOpen] = useState(false);
const filterDependency = filterStructure.find(getByName("channel"));
@ -122,7 +138,40 @@ export const ProductListPage: React.FC<ProductListPageProps> = props => {
return (
<ListPageLayout>
<TopNav withoutBorder title={intl.formatMessage(sectionNames.products)}>
<TopNav
withoutBorder
isAlignToRight={false}
title={intl.formatMessage(sectionNames.products)}
>
<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">
@ -176,6 +225,7 @@ export const ProductListPage: React.FC<ProductListPageProps> = props => {
</Button>
)}
</Box>
</Box>
</TopNav>
{limitReached && (
<LimitReachedAlert
@ -199,7 +249,7 @@ export const ProductListPage: React.FC<ProductListPageProps> = props => {
alignItems="stretch"
justifyContent="space-between"
>
<FilterBar
<ListFilters
currencySymbol={currencySymbol}
initialSearch={initialSearch}
onFilterChange={onFilterChange}
@ -221,6 +271,7 @@ export const ProductListPage: React.FC<ProductListPageProps> = props => {
{isDatagridView ? (
<ProductListDatagrid
{...listProps}
hasRowHover={!isFilterPresetOpen}
filterDependency={filterDependency}
activeAttributeSortId={activeAttributeSortId}
columnQuery={columnQuery}

View file

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

View file

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

View file

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

View file

@ -56,7 +56,7 @@ import {
ProductListUrlQueryParams,
} from "../../urls";
import { getProductGiftCardFilterParam } from "./utils";
export const PRODUCT_FILTERS_KEY = "productFilters";
export const PRODUCT_FILTERS_KEY = "productPresets";
function getAttributeFilterParamType(inputType: AttributeInputTypeEnum) {
switch (inputType) {
@ -468,14 +468,12 @@ export const {
deleteFilterTab,
getFilterTabs,
saveFilterTab,
} = createFilterTabUtils<ProductListUrlFilters>(PRODUCT_FILTERS_KEY);
updateFilterTab,
} = createFilterTabUtils<string>(PRODUCT_FILTERS_KEY);
export const {
areFiltersApplied,
getActiveFilters,
getFiltersCurrentTab,
} = createFilterUtils<ProductListUrlQueryParams, ProductListUrlFilters>({
export const { areFiltersApplied, getActiveFilters, getFiltersCurrentTab } =
createFilterUtils<ProductListUrlQueryParams, ProductListUrlFilters>({
...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;
};
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,9 +83,8 @@ export interface ListActionsWithoutToolbar {
isChecked: (id: string) => boolean;
selected: number;
}
export type TabListActions<
TToolbars extends string
> = ListActionsWithoutToolbar &
export type TabListActions<TToolbars extends string> =
ListActionsWithoutToolbar &
Record<TToolbars, React.ReactNode | React.ReactNodeArray>;
export interface ListActions extends ListActionsWithoutToolbar {
toolbar: React.ReactNode | React.ReactNodeArray;
@ -129,7 +128,7 @@ export interface ChannelProps {
export interface PartialMutationProviderOutput<
TData extends {} = {},
TVariables extends {} = {}
TVariables extends {} = {},
> {
opts: MutationResult<TData> & MutationResultAdditionalProps;
mutate: (variables: TVariables) => Promise<FetchResult<TData>>;

View file

@ -13,7 +13,7 @@ function createFilterUtils<
>(filters: {}) {
function getActiveFilters(params: TQueryParams): TFilters {
return Object.keys(params)
.filter(key => Object.keys(filters).includes(key))
.filter(key => Object.values(filters).includes(key))
.reduce((acc, key) => {
acc[key] = params[key];
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) {
const userFilters = getFilterTabs(key);
@ -46,6 +66,8 @@ function createFilterTabUtils<TUrlFilters>(key: string) {
getFilterTabs: () => getFilterTabs<TUrlFilters>(key),
saveFilterTab: (name: string, data: TUrlFilters) =>
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";
type RequiredParams = ActiveTab & Search & Sort<any> & Pagination;
type RequiredParams = ActiveTab &
Search &
Sort<any> &
Pagination & { presestesChanged?: string };
type CreateUrl = (params: RequiredParams) => string;
type CreateFilterHandlers<TFilterKeys extends string> = [
(filter: IFilter<TFilterKeys>) => void,
@ -21,19 +24,40 @@ function createFilterHandlers<
createUrl: CreateUrl;
params: RequiredParams;
cleanupFn?: () => void;
keepActiveTab?: boolean;
}): 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>) => {
if (!!cleanupFn) {
cleanupFn();
}
const filtersQueryParams = getFilterQueryParams(
filters,
getFilterQueryParam,
);
navigate(
createUrl({
...params,
...getFilterQueryParams(filters, getFilterQueryParam),
activeTab: undefined,
...filtersQueryParams,
activeTab: getActiveTabValue(
checkIfParamsEmpty(filtersQueryParams) && !params.query?.length,
),
}),
);
};
@ -55,14 +79,17 @@ function createFilterHandlers<
if (!!cleanupFn) {
cleanupFn();
}
const trimmedQuery = query?.trim() ?? "";
navigate(
createUrl({
...params,
after: undefined,
before: undefined,
activeTab: undefined,
query: query?.trim(),
activeTab: getActiveTabValue(
checkIfParamsEmpty(params) && trimmedQuery === "",
),
query: trimmedQuery !== "" ? trimmedQuery : undefined,
}),
);
};
@ -70,4 +97,12 @@ function createFilterHandlers<
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;