Product list bulk delete (#3562)

* Datagrid column checkboxes with delete action

* Handle product bulk delete

* Fix border line between columns

* Clear selection after product delete mutation

* Handle row selection

* Improve handleRowDelete, use clearRowSelection

* Fix restart row selection

* Cleanup

* Fix for delete modal

* Fix showing delete modal

* Add delete button next to switcher

* Extract messages

* Fix checking rows in product list datagrid

* Check console

* Check on row click

* Fix fire click when selection checkbox

* Simplifie vertiacal border condition

* Clear selected product when pagination

* Fix issue with columns and row selections

* Fix hiding tooltip when modal close

* Update storybook props fix accordion use

* Fix row selection

* Fix clear selection after pagination change

* Apply cr changes
This commit is contained in:
Paweł Chyła 2023-05-10 14:09:52 +02:00 committed by GitHub
parent 86932d1cd9
commit 5eb0be2dc3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 259 additions and 117 deletions

View file

@ -6483,6 +6483,9 @@
"context": "VariantDetailsChannelsAvailabilityCard no items available", "context": "VariantDetailsChannelsAvailabilityCard no items available",
"string": "This variant is not available at any of the channels" "string": "This variant is not available at any of the channels"
}, },
"jrBxCQ": {
"string": "Bulk product delete"
},
"jswILH": { "jswILH": {
"context": "add attribute as column in product list table", "context": "add attribute as column in product list table",
"string": "Add to Column Options" "string": "Add to Column Options"

14
package-lock.json generated
View file

@ -27,7 +27,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.73", "@saleor/macaw-ui": "0.8.0-pre.81",
"@saleor/sdk": "^0.5.0", "@saleor/sdk": "^0.5.0",
"@sentry/react": "^6.0.0", "@sentry/react": "^6.0.0",
"@types/faker": "^5.1.6", "@types/faker": "^5.1.6",
@ -7976,9 +7976,9 @@
} }
}, },
"node_modules/@saleor/macaw-ui": { "node_modules/@saleor/macaw-ui": {
"version": "0.8.0-pre.73", "version": "0.8.0-pre.81",
"resolved": "https://registry.npmjs.org/@saleor/macaw-ui/-/macaw-ui-0.8.0-pre.73.tgz", "resolved": "https://registry.npmjs.org/@saleor/macaw-ui/-/macaw-ui-0.8.0-pre.81.tgz",
"integrity": "sha512-7+bFDATTV8ZjX3dpU20z5QMUMSZHoNgidLWEzUBkHWk6EgiDf+V5Mn2t8Eexd34uLOIKCQ0j4f/mVG+slRsj/w==", "integrity": "sha512-6s15zUyn82492DwMDNm0yI6uAzik8t/OlI+LrH23L3EgzcyzprbA7DYzwcV4C7vlCEiMxywLnfS/0b1RYnhM1w==",
"dependencies": { "dependencies": {
"@dessert-box/react": "^0.4.0", "@dessert-box/react": "^0.4.0",
"@floating-ui/react-dom-interactions": "^0.5.0", "@floating-ui/react-dom-interactions": "^0.5.0",
@ -43372,9 +43372,9 @@
} }
}, },
"@saleor/macaw-ui": { "@saleor/macaw-ui": {
"version": "0.8.0-pre.73", "version": "0.8.0-pre.81",
"resolved": "https://registry.npmjs.org/@saleor/macaw-ui/-/macaw-ui-0.8.0-pre.73.tgz", "resolved": "https://registry.npmjs.org/@saleor/macaw-ui/-/macaw-ui-0.8.0-pre.81.tgz",
"integrity": "sha512-7+bFDATTV8ZjX3dpU20z5QMUMSZHoNgidLWEzUBkHWk6EgiDf+V5Mn2t8Eexd34uLOIKCQ0j4f/mVG+slRsj/w==", "integrity": "sha512-6s15zUyn82492DwMDNm0yI6uAzik8t/OlI+LrH23L3EgzcyzprbA7DYzwcV4C7vlCEiMxywLnfS/0b1RYnhM1w==",
"requires": { "requires": {
"@dessert-box/react": "^0.4.0", "@dessert-box/react": "^0.4.0",
"@floating-ui/react-dom-interactions": "^0.5.0", "@floating-ui/react-dom-interactions": "^0.5.0",

View file

@ -34,7 +34,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.73", "@saleor/macaw-ui": "0.8.0-pre.81",
"@saleor/sdk": "^0.5.0", "@saleor/sdk": "^0.5.0",
"@sentry/react": "^6.0.0", "@sentry/react": "^6.0.0",
"@types/faker": "^5.1.6", "@types/faker": "^5.1.6",

View file

@ -79,7 +79,7 @@ export const Attributes: React.FC<AttributesProps> = ({
<Box display="flex" flexDirection="column" gap={5}> <Box display="flex" flexDirection="column" gap={5}>
<Accordion defaultValue="attributes-accordion"> <Accordion defaultValue="attributes-accordion">
<Accordion.Item value="attributes-accordion"> <Accordion.Item value="attributes-accordion">
<Accordion.Item.Trigger buttonDataTestId="attributes-expand"> <Accordion.Trigger buttonDataTestId="attributes-expand">
<Text variant="caption" color="textNeutralSubdued"> <Text variant="caption" color="textNeutralSubdued">
<FormattedMessage <FormattedMessage
{...messages.attributesNumber} {...messages.attributesNumber}
@ -88,8 +88,8 @@ export const Attributes: React.FC<AttributesProps> = ({
}} }}
/> />
</Text> </Text>
</Accordion.Item.Trigger> </Accordion.Trigger>
<Accordion.Item.Content> <Accordion.Content>
{attributes.length > 0 && ( {attributes.length > 0 && (
<ul> <ul>
<Divider /> <Divider />
@ -108,7 +108,7 @@ export const Attributes: React.FC<AttributesProps> = ({
))} ))}
</ul> </ul>
)} )}
</Accordion.Item.Content> </Accordion.Content>
</Accordion.Item> </Accordion.Item>
</Accordion> </Accordion>
</Box> </Box>

View file

@ -16,13 +16,11 @@ export const ChannelAvailabilityItemWrapper: React.FC<
> = ({ data: { name }, messages, children }) => ( > = ({ data: { name }, messages, children }) => (
<Accordion data-test-id="channel-availability-item"> <Accordion data-test-id="channel-availability-item">
<Accordion.Item value="channel-availability-item" gap={12}> <Accordion.Item value="channel-availability-item" gap={12}>
<Accordion.Item.Trigger buttonDataTestId="expand-icon"> <Accordion.Trigger buttonDataTestId="expand-icon">
<Text variant={"bodyEmp"}>{name}</Text> <Text variant={"bodyEmp"}>{name}</Text>
<Label text={messages.availableDateText} /> <Label text={messages.availableDateText} />
</Accordion.Item.Trigger> </Accordion.Trigger>
<Accordion.Item.Content paddingLeft={6}> <Accordion.Content paddingLeft={6}>{children}</Accordion.Content>
{children}
</Accordion.Item.Content>
</Accordion.Item> </Accordion.Item>
</Accordion> </Accordion>
); );

View file

@ -25,6 +25,7 @@ import React, {
ReactElement, ReactElement,
ReactNode, ReactNode,
useCallback, useCallback,
useEffect,
useMemo, useMemo,
useRef, useRef,
useState, useState,
@ -54,7 +55,10 @@ import useStyles, {
useFullScreenStyles, useFullScreenStyles,
} from "./styles"; } from "./styles";
import { AvailableColumn } from "./types"; import { AvailableColumn } from "./types";
import { getDefultColumnPickerProps } from "./utils"; import {
getDefultColumnPickerProps,
preventRowClickOnSelectionCheckbox,
} from "./utils";
export interface GetCellContentOpts { export interface GetCellContentOpts {
changes: MutableRefObject<DatagridChange[]>; changes: MutableRefObject<DatagridChange[]>;
@ -91,6 +95,7 @@ export interface DatagridProps {
onRowClick?: (item: Item) => void; onRowClick?: (item: Item) => void;
onColumnMoved?: (startIndex: number, endIndex: number) => void; onColumnMoved?: (startIndex: number, endIndex: number) => void;
onColumnResize?: (column: GridColumn, newSize: number) => void; onColumnResize?: (column: GridColumn, newSize: number) => void;
onRowSelectionChange?: (rowsId: number[], clearSelection: () => void) => void;
readonly?: boolean; readonly?: boolean;
hasRowHover?: boolean; hasRowHover?: boolean;
rowMarkers?: DataEditorProps["rowMarkers"]; rowMarkers?: DataEditorProps["rowMarkers"];
@ -126,6 +131,7 @@ export const Datagrid: React.FC<DatagridProps> = ({
loading, loading,
rowAnchor, rowAnchor,
hasRowHover = false, hasRowHover = false,
onRowSelectionChange,
...datagridProps ...datagridProps
}): ReactElement => { }): ReactElement => {
const classes = useStyles(); const classes = useStyles();
@ -151,6 +157,16 @@ export const Datagrid: React.FC<DatagridProps> = ({
const [selection, setSelection] = useState<GridSelection>(); const [selection, setSelection] = useState<GridSelection>();
const [hoverRow, setHoverRow] = useState<number | undefined>(undefined); const [hoverRow, setHoverRow] = useState<number | undefined>(undefined);
// Allow to listen to which row is selected and notfiy parent component
useEffect(() => {
if (onRowSelectionChange && selection) {
// Second parameter is callback to clear selection from parent component
onRowSelectionChange(Array.from(selection.rows), () => {
setSelection(undefined);
});
}
}, [onRowSelectionChange, selection]);
usePortalClasses({ className: classes.portal }); usePortalClasses({ className: classes.portal });
usePreventHistoryBack(scroller); usePreventHistoryBack(scroller);
@ -242,6 +258,10 @@ export const Datagrid: React.FC<DatagridProps> = ({
return; return;
} }
if (preventRowClickOnSelectionCheckbox(rowMarkers, args.location[0])) {
return;
}
hackARef.current.style.left = `${window.scrollX + args.bounds.x}px`; hackARef.current.style.left = `${window.scrollX + args.bounds.x}px`;
hackARef.current.style.width = `${args.bounds.width}px`; hackARef.current.style.width = `${args.bounds.width}px`;
hackARef.current.style.top = `${window.scrollY + args.bounds.y}px`; hackARef.current.style.top = `${window.scrollY + args.bounds.y}px`;
@ -250,20 +270,26 @@ export const Datagrid: React.FC<DatagridProps> = ({
getAppMountUri() + (href.startsWith("/") ? href.slice(1) : href); getAppMountUri() + (href.startsWith("/") ? href.slice(1) : href);
hackARef.current.dataset.reactRouterPath = href; hackARef.current.dataset.reactRouterPath = href;
}, },
[hasRowHover, rowAnchor], [hasRowHover, rowAnchor, rowMarkers],
); );
const handleCellClick = useCallback( const handleCellClick = useCallback(
(item: Item, args: CellClickedEventArgs) => { (item: Item, args: CellClickedEventArgs) => {
if (onRowClick && item[0] !== -1) { if (preventRowClickOnSelectionCheckbox(rowMarkers, item[0])) {
return;
}
if (onRowClick) {
onRowClick(item); onRowClick(item);
} }
handleRowHover(args); handleRowHover(args);
if (hackARef.current) { if (hackARef.current) {
hackARef.current.click(); hackARef.current.click();
} }
}, },
[onRowClick, handleRowHover], [rowMarkers, onRowClick, handleRowHover],
); );
const handleGridSelectionChange = (gridSelection: GridSelection) => { const handleGridSelectionChange = (gridSelection: GridSelection) => {

View file

@ -1,3 +1,5 @@
import { DataEditorProps } from "@glideapps/glide-data-grid";
import { ColumnPickerProps } from "../ColumnPicker"; import { ColumnPickerProps } from "../ColumnPicker";
export const getDefultColumnPickerProps = ( export const getDefultColumnPickerProps = (
@ -9,3 +11,8 @@ export const getDefultColumnPickerProps = (
hoverOutline: false, hoverOutline: false,
}, },
}); });
export const preventRowClickOnSelectionCheckbox = (
rowMarkers: DataEditorProps["rowMarkers"],
location: number,
) => !["number", "none"].includes(rowMarkers) && location === -1;

View file

@ -15,12 +15,8 @@ export type TablePaginationWithContextProps = Omit<
export const TablePaginationWithContext = ( export const TablePaginationWithContext = (
props: TablePaginationWithContextProps, props: TablePaginationWithContextProps,
) => { ) => {
const { const { hasNextPage, hasPreviousPage, paginatorType, ...paginationProps } =
hasNextPage, usePaginatorContext();
hasPreviousPage,
paginatorType,
...paginationProps
} = usePaginatorContext();
if (paginatorType === "click") { if (paginatorType === "click") {
const { loadNextPage, loadPreviousPage } = paginationProps; const { loadNextPage, loadPreviousPage } = paginationProps;

View file

@ -56,6 +56,7 @@ interface ProductListDatagridProps
SearchAvailableInGridAttributesQuery["availableInGrid"] SearchAvailableInGridAttributesQuery["availableInGrid"]
>; >;
onColumnQueryChange: (query: string) => void; onColumnQueryChange: (query: string) => void;
onSelectProductIds: (rowsIndex: number[], clearSelection: () => void) => void;
isAttributeLoading?: boolean; isAttributeLoading?: boolean;
hasRowHover?: boolean; hasRowHover?: boolean;
} }
@ -80,6 +81,7 @@ export const ProductListDatagrid: React.FC<ProductListDatagridProps> = ({
onColumnQueryChange, onColumnQueryChange,
activeAttributeSortId, activeAttributeSortId,
filterDependency, filterDependency,
onSelectProductIds,
hasRowHover, hasRowHover,
rowAnchor, rowAnchor,
}) => { }) => {
@ -103,22 +105,6 @@ export const ProductListDatagrid: React.FC<ProductListDatagridProps> = ({
const handleColumnMoved = useCallback( const handleColumnMoved = useCallback(
(startIndex: number, endIndex: number): void => { (startIndex: number, endIndex: number): void => {
// Keep empty column always at beginning
if (startIndex === 0) {
return setColumns(prevColumns => [...prevColumns]);
}
// Keep empty column always at beginning
if (endIndex === 0) {
return setColumns(old =>
addAtIndex(
old[startIndex],
removeAtIndex(old, startIndex),
endIndex + 1,
),
);
}
setColumns(old => setColumns(old =>
addAtIndex(old[startIndex], removeAtIndex(old, startIndex), endIndex), addAtIndex(old[startIndex], removeAtIndex(old, startIndex), endIndex),
); );
@ -243,13 +229,12 @@ export const ProductListDatagrid: React.FC<ProductListDatagridProps> = ({
<Datagrid <Datagrid
readonly readonly
loading={loading} loading={loading}
rowMarkers="none" rowMarkers="checkbox"
columnSelect="single" columnSelect="single"
freezeColumns={2}
hasRowHover={hasRowHover} hasRowHover={hasRowHover}
onColumnMoved={handleColumnMoved} onColumnMoved={handleColumnMoved}
onColumnResize={handleColumnResize} onColumnResize={handleColumnResize}
verticalBorder={col => (col > 1 ? true : false)} verticalBorder={col => col > 0}
getColumnTooltipContent={handleGetColumnTooltipContent} getColumnTooltipContent={handleGetColumnTooltipContent}
availableColumns={columns} availableColumns={columns}
onHeaderClicked={handleHeaderClicked} onHeaderClicked={handleHeaderClicked}
@ -258,6 +243,7 @@ export const ProductListDatagrid: React.FC<ProductListDatagridProps> = ({
getCellError={() => false} getCellError={() => false}
menuItems={() => []} menuItems={() => []}
rows={productsLength} rows={productsLength}
onRowSelectionChange={onSelectProductIds}
selectionActions={() => null} selectionActions={() => null}
fullScreenTitle={intl.formatMessage(messages.products)} fullScreenTitle={intl.formatMessage(messages.products)}
onRowClick={handleRowClick} onRowClick={handleRowClick}

View file

@ -1,4 +1,3 @@
import { useEmptyColumn } from "@dashboard/components/Datagrid/hooks/useEmptyColumn";
import { AvailableColumn } from "@dashboard/components/Datagrid/types"; import { AvailableColumn } from "@dashboard/components/Datagrid/types";
import { ProductListColumns } from "@dashboard/config"; import { ProductListColumns } from "@dashboard/config";
import { GridAttributesQuery } from "@dashboard/graphql"; import { GridAttributesQuery } from "@dashboard/graphql";
@ -26,7 +25,6 @@ export const useDatagridColumns = ({
settings, settings,
}: UseDatagridColumnsProps) => { }: UseDatagridColumnsProps) => {
const intl = useIntl(); const intl = useIntl();
const emptyColumn = useEmptyColumn();
const initialColumns = useRef( const initialColumns = useRef(
getColumns({ getColumns({
@ -35,13 +33,11 @@ export const useDatagridColumns = ({
gridAttributes, gridAttributes,
gridAttributesFromSettings, gridAttributesFromSettings,
activeAttributeSortId, activeAttributeSortId,
emptyColumn,
}), }),
); );
const [columns, setColumns] = useState<AvailableColumn[]>([ const [columns, setColumns] = useState<AvailableColumn[]>([
initialColumns.current[0], initialColumns.current[0],
initialColumns.current[1],
...initialColumns.current.filter(col => ...initialColumns.current.filter(col =>
settings.columns.includes(col.id as ProductListColumns), settings.columns.includes(col.id as ProductListColumns),
), ),
@ -86,7 +82,7 @@ function byColumnsInSettingsOrStaticColumns(
) { ) {
return (column: AvailableColumn) => return (column: AvailableColumn) =>
settings.columns.includes(column.id as ProductListColumns) || settings.columns.includes(column.id as ProductListColumns) ||
["empty", "name"].includes(column.id); ["name"].includes(column.id);
} }
function toCurrentColumnData( function toCurrentColumnData(

View file

@ -17,7 +17,7 @@ import { getMoneyRange } from "@dashboard/components/MoneyRange";
import { ProductListColumns } from "@dashboard/config"; import { ProductListColumns } from "@dashboard/config";
import { GridAttributesQuery, ProductListQuery } from "@dashboard/graphql"; import { GridAttributesQuery, ProductListQuery } from "@dashboard/graphql";
import { commonMessages } from "@dashboard/intl"; import { commonMessages } from "@dashboard/intl";
import { getDatagridRowDataIndex, isFirstColumn } from "@dashboard/misc"; import { getDatagridRowDataIndex } from "@dashboard/misc";
import { ProductListUrlSortField } from "@dashboard/products/urls"; import { ProductListUrlSortField } from "@dashboard/products/urls";
import { RelayToFlat, Sort } from "@dashboard/types"; import { RelayToFlat, Sort } from "@dashboard/types";
import { getColumnSortDirectionIcon } from "@dashboard/utils/columns/getColumnSortDirectionIcon"; import { getColumnSortDirectionIcon } from "@dashboard/utils/columns/getColumnSortDirectionIcon";
@ -34,7 +34,6 @@ interface GetColumnsProps {
gridAttributes: RelayToFlat<GridAttributesQuery["grid"]>; gridAttributes: RelayToFlat<GridAttributesQuery["grid"]>;
gridAttributesFromSettings: ProductListColumns[]; gridAttributesFromSettings: ProductListColumns[];
activeAttributeSortId: string; activeAttributeSortId: string;
emptyColumn: AvailableColumn;
} }
export function getColumns({ export function getColumns({
@ -43,10 +42,8 @@ export function getColumns({
gridAttributes, gridAttributes,
gridAttributesFromSettings, gridAttributesFromSettings,
activeAttributeSortId, activeAttributeSortId,
emptyColumn,
}: GetColumnsProps): AvailableColumn[] { }: GetColumnsProps): AvailableColumn[] {
return [ return [
emptyColumn,
{ {
id: "name", id: "name",
title: intl.formatMessage(commonMessages.product), title: intl.formatMessage(commonMessages.product),
@ -149,10 +146,6 @@ export function createGetCellContent({
[column, row]: Item, [column, row]: Item,
{ changes, getChangeIndex, added, removed }: GetCellContentOpts, { changes, getChangeIndex, added, removed }: GetCellContentOpts,
) => { ) => {
if (isFirstColumn(column)) {
return readonlyTextCell("");
}
const columnId = columns[column]?.id; const columnId = columns[column]?.id;
if (!columnId) { if (!columnId) {

View file

@ -0,0 +1,41 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import React from "react";
import { ProductListDeleteButton } from "./ProductListDeleteButton";
jest.mock("react-intl", () => ({
FormattedMessage: ({ defaultMessage }) => <>{defaultMessage}</>,
}));
describe("ProductListDeleteButton", () => {
it("should return null when show is equal false", () => {
// Arrange & Act
const { container } = render(
<ProductListDeleteButton show={false} onClick={jest.fn()} />,
);
// Assert
expect(container).toBeEmptyDOMElement();
});
it("should render button", async () => {
// Arrange & Act
render(<ProductListDeleteButton show onClick={jest.fn()} />);
// Assert
expect(screen.getByRole("button")).toBeInTheDocument();
});
it("should fire callback on click", async () => {
// Arrange
const onClick = jest.fn();
// Act
render(<ProductListDeleteButton show onClick={onClick} />);
await userEvent.click(screen.getByRole("button"));
// Assert
expect(onClick).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,34 @@
import { Button, Tooltip, TrashBinIcon } from "@saleor/macaw-ui/next";
import React, { forwardRef } from "react";
import { FormattedMessage } from "react-intl";
interface ProductListDeleteButtonProps {
onClick: () => void;
show?: boolean;
}
export const ProductListDeleteButton = forwardRef<
HTMLButtonElement,
ProductListDeleteButtonProps
>(({ onClick, show = false }, ref) => {
if (!show) {
return null;
}
return (
<Tooltip>
<Tooltip.Trigger>
<Button
ref={ref}
onClick={onClick}
icon={<TrashBinIcon />}
variant="secondary"
/>
</Tooltip.Trigger>
<Tooltip.Content side="bottom">
<Tooltip.Arrow />
<FormattedMessage defaultMessage="Bulk product delete" id="jrBxCQ" />
</Tooltip.Content>
</Tooltip>
);
});

View file

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

View file

@ -34,6 +34,8 @@ const props: ProductListPageProps = {
...sortPageProps.sort, ...sortPageProps.sort,
sort: ProductListUrlSortField.name, sort: ProductListUrlSortField.name,
}, },
onProductsDelete: () => undefined,
onSelectProductIds: () => undefined,
channels: [], channels: [],
columnQuery: "", columnQuery: "",
currentTab: 0, currentTab: 0,
@ -53,6 +55,8 @@ const props: ProductListPageProps = {
products, products,
selectedChannelId: "123", selectedChannelId: "123",
selectedProductIds: ["123"], selectedProductIds: ["123"],
setBulkDeleteButtonRef: () => undefined,
clearRowSelection: () => undefined,
settings: { settings: {
...pageListProps.default.settings, ...pageListProps.default.settings,
columns: ["availability", "productType", "price"], columns: ["availability", "productType", "price"],

View file

@ -25,7 +25,6 @@ import {
ChannelProps, ChannelProps,
FetchMoreProps, FetchMoreProps,
FilterPageProps, FilterPageProps,
ListActions,
PageListProps, PageListProps,
RelayToFlat, RelayToFlat,
SortPage, SortPage,
@ -38,6 +37,7 @@ import { FormattedMessage, useIntl } from "react-intl";
import { ProductListUrlSortField, productUrl } from "../../urls"; import { ProductListUrlSortField, productUrl } from "../../urls";
import { ProductListDatagrid } from "../ProductListDatagrid"; import { ProductListDatagrid } from "../ProductListDatagrid";
import { ProductListDeleteButton } from "../ProductListDeleteButton";
import { ProductListTiles } from "../ProductListTiles/ProductListTiles"; import { ProductListTiles } from "../ProductListTiles/ProductListTiles";
import { ProductListViewSwitch } from "../ProductListViewSwitch"; import { ProductListViewSwitch } from "../ProductListViewSwitch";
import { import {
@ -48,7 +48,6 @@ import {
export interface ProductListPageProps export interface ProductListPageProps
extends PageListProps<ProductListColumns>, extends PageListProps<ProductListColumns>,
ListActions,
Omit< Omit<
FilterPageProps<ProductFilterKeys, ProductListFilterOpts>, FilterPageProps<ProductFilterKeys, ProductListFilterOpts>,
"onTabDelete" "onTabDelete"
@ -72,6 +71,10 @@ export interface ProductListPageProps
onColumnQueryChange: (query: string) => void; onColumnQueryChange: (query: string) => void;
onTabUpdate: (tabName: string) => void; onTabUpdate: (tabName: string) => void;
onTabDelete: (tabIndex: number) => void; onTabDelete: (tabIndex: number) => void;
onProductsDelete: () => void;
onSelectProductIds: (ids: number[], clearSelection: () => void) => void;
clearRowSelection: () => void;
setBulkDeleteButtonRef: (ref: HTMLButtonElement) => void;
} }
export type ProductListViewType = "datagrid" | "tile"; export type ProductListViewType = "datagrid" | "tile";
@ -99,7 +102,6 @@ export const ProductListPage: React.FC<ProductListPageProps> = props => {
onSearchChange, onSearchChange,
onUpdateListSettings, onUpdateListSettings,
selectedChannelId, selectedChannelId,
selectedProductIds,
activeAttributeSortId, activeAttributeSortId,
onTabChange, onTabChange,
onTabDelete, onTabDelete,
@ -109,6 +111,10 @@ export const ProductListPage: React.FC<ProductListPageProps> = props => {
tabs, tabs,
onTabUpdate, onTabUpdate,
hasPresetsChanged, hasPresetsChanged,
selectedProductIds,
onProductsDelete,
clearRowSelection,
setBulkDeleteButtonRef,
...listProps ...listProps
} = props; } = props;
const intl = useIntl(); const intl = useIntl();
@ -133,6 +139,7 @@ export const ProductListPage: React.FC<ProductListPageProps> = props => {
"productListViewType", "productListViewType",
DEFAULT_PRODUCT_LIST_VIEW_TYPE, DEFAULT_PRODUCT_LIST_VIEW_TYPE,
); );
const isDatagridView = storedProductListViewType === "datagrid"; const isDatagridView = storedProductListViewType === "datagrid";
return ( return (
@ -260,10 +267,20 @@ export const ProductListPage: React.FC<ProductListPageProps> = props => {
defaultMessage: "Search Products...", defaultMessage: "Search Products...",
})} })}
actions={ actions={
<Box display="flex" gap={7}>
<ProductListDeleteButton
ref={setBulkDeleteButtonRef}
onClick={onProductsDelete}
show={selectedProductIds.length > 0}
/>
<ProductListViewSwitch <ProductListViewSwitch
defaultValue={storedProductListViewType} defaultValue={storedProductListViewType}
setProductListViewType={setProductListViewType} setProductListViewType={props => {
setProductListViewType(props);
clearRowSelection();
}}
/> />
</Box>
} }
/> />
</Box> </Box>

View file

@ -27,7 +27,6 @@ import {
useWarehouseListQuery, useWarehouseListQuery,
} from "@dashboard/graphql"; } from "@dashboard/graphql";
import useBackgroundTask from "@dashboard/hooks/useBackgroundTask"; import useBackgroundTask from "@dashboard/hooks/useBackgroundTask";
import useBulkActions from "@dashboard/hooks/useBulkActions";
import useListSettings from "@dashboard/hooks/useListSettings"; import useListSettings from "@dashboard/hooks/useListSettings";
import useNavigator from "@dashboard/hooks/useNavigator"; import useNavigator from "@dashboard/hooks/useNavigator";
import useNotifier from "@dashboard/hooks/useNotifier"; import useNotifier from "@dashboard/hooks/useNotifier";
@ -64,9 +63,9 @@ 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 isEqual from "lodash/isEqual";
import { stringify } from "qs"; import { stringify } from "qs";
import React, { useState } from "react"; import React, { useCallback, useEffect, useRef, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import { useSortRedirects } from "../../../hooks/useSortRedirects"; import { useSortRedirects } from "../../../hooks/useSortRedirects";
@ -98,14 +97,34 @@ export const ProductList: React.FC<ProductListProps> = ({ params }) => {
const { queue } = useBackgroundTask(); const { queue } = useBackgroundTask();
const [tabIndexToDelete, setTabIndexToDelete] = useState<number | null>(null); const [tabIndexToDelete, setTabIndexToDelete] = useState<number | null>(null);
const { isSelected, listElements, reset, toggle, toggleAll } = useBulkActions( const [selectedProductIds, setSelectedProductIds] = useState<string[]>([]);
[], const deleteButtonRef = useRef<HTMLButtonElement>(null);
);
const { updateListSettings, settings } = useListSettings<ProductListColumns>( const { updateListSettings, settings } = useListSettings<ProductListColumns>(
ListViews.PRODUCT_LIST, ListViews.PRODUCT_LIST,
); );
// Keep reference to clear datagrid selection function
const clearRowSelectionCallback = React.useRef<() => void | null>(null);
const clearRowSelection = () => {
setSelectedProductIds([]);
if (clearRowSelectionCallback.current) {
clearRowSelectionCallback.current();
}
};
// Whenever pagination change we need to clear datagrid selection
useEffect(() => {
clearRowSelection();
}, [params.after, params.before]);
// Remove focus from delete button after delete action
useEffect(() => {
if (!params.action && deleteButtonRef.current) {
deleteButtonRef.current.blur();
}
}, [params.action]);
usePaginationReset(productListUrl, params, settings.rowNumber); usePaginationReset(productListUrl, params, settings.rowNumber);
const intl = useIntl(); const intl = useIntl();
@ -223,14 +242,30 @@ export const ProductList: React.FC<ProductListProps> = ({ params }) => {
id: data.exportProducts.exportFile.id, id: data.exportProducts.exportFile.id,
}); });
closeModal(); closeModal();
reset(); clearRowSelection();
}
},
});
const [productBulkDelete, productBulkDeleteOpts] =
useProductBulkDeleteMutation({
onCompleted: data => {
if (data.productBulkDelete.errors.length === 0) {
closeModal();
notify({
status: "success",
text: intl.formatMessage(commonMessages.savedChanges),
});
refetch();
limitOpts.refetch();
clearRowSelection();
} }
}, },
}); });
const [changeFilters, resetFilters, handleSearchChange] = const [changeFilters, resetFilters, handleSearchChange] =
createFilterHandlers({ createFilterHandlers({
cleanupFn: reset, cleanupFn: clearRowSelection,
createUrl: productListUrl, createUrl: productListUrl,
getFilterQueryParam, getFilterQueryParam,
navigate, navigate,
@ -239,7 +274,8 @@ export const ProductList: React.FC<ProductListProps> = ({ params }) => {
}); });
const handleTabChange = (tab: number) => { const handleTabChange = (tab: number) => {
reset(); clearRowSelection();
const qs = new URLSearchParams(getFilterTabs()[tab - 1]?.data ?? ""); const qs = new URLSearchParams(getFilterTabs()[tab - 1]?.data ?? "");
qs.append("activeTab", tab.toString()); qs.append("activeTab", tab.toString());
@ -248,7 +284,7 @@ export const ProductList: React.FC<ProductListProps> = ({ params }) => {
const handleFilterTabDelete = () => { const handleFilterTabDelete = () => {
deleteFilterTab(tabIndexToDelete); deleteFilterTab(tabIndexToDelete);
reset(); clearRowSelection();
// When deleting the current tab, navigate to the All products // When deleting the current tab, navigate to the All products
if (tabIndexToDelete === currentTab) { if (tabIndexToDelete === currentTab) {
@ -298,6 +334,14 @@ export const ProductList: React.FC<ProductListProps> = ({ params }) => {
}), }),
); );
const handleSubmitBulkDelete = () => {
productBulkDelete({
variables: { ids: selectedProductIds },
});
deleteButtonRef.current.blur();
clearRowSelection();
};
const kindOpts = getProductKindOpts(availableProductKinds, intl); const kindOpts = getProductKindOpts(availableProductKinds, intl);
const paginationState = createPaginationState(settings.rowNumber, params); const paginationState = createPaginationState(settings.rowNumber, params);
const channelOpts = availableChannels const channelOpts = availableChannels
@ -330,6 +374,26 @@ export const ProductList: React.FC<ProductListProps> = ({ params }) => {
}, },
}); });
const products = mapEdgesToItems(data?.products);
const handleSetSelectedProductIds = useCallback(
(rows: number[], clearSelection: () => void) => {
if (!products) {
return;
}
const rowsIds = rows.map(row => products[row].id);
const haveSaveValues = isEqual(rowsIds, selectedProductIds);
if (!haveSaveValues) {
setSelectedProductIds(rowsIds);
}
clearRowSelectionCallback.current = clearSelection;
},
[products, selectedProductIds],
);
const availableInGridAttributesOpts = useAvailableInGridAttributesSearch({ const availableInGridAttributesOpts = useAvailableInGridAttributesSearch({
variables: { variables: {
...DEFAULT_INITIAL_SEARCH_DATA, ...DEFAULT_INITIAL_SEARCH_DATA,
@ -341,22 +405,6 @@ export const ProductList: React.FC<ProductListProps> = ({ params }) => {
skip: filteredColumnIds.length === 0, skip: filteredColumnIds.length === 0,
}); });
const [productBulkDelete, productBulkDeleteOpts] =
useProductBulkDeleteMutation({
onCompleted: data => {
if (data.productBulkDelete.errors.length === 0) {
closeModal();
notify({
status: "success",
text: intl.formatMessage(commonMessages.savedChanges),
});
reset();
refetch();
limitOpts.refetch();
}
},
});
const { const {
loadMore: loadMoreDialogProductTypes, loadMore: loadMoreDialogProductTypes,
search: searchDialogProductTypes, search: searchDialogProductTypes,
@ -441,26 +489,15 @@ export const ProductList: React.FC<ProductListProps> = ({ params }) => {
)} )}
disabled={loading} disabled={loading}
limits={limitOpts.data?.shop.limits} limits={limitOpts.data?.shop.limits}
products={mapEdgesToItems(data?.products)} products={products}
selectedProductIds={listElements}
onColumnQueryChange={availableInGridAttributesOpts.search} onColumnQueryChange={availableInGridAttributesOpts.search}
onFetchMore={availableInGridAttributesOpts.loadMore} onFetchMore={availableInGridAttributesOpts.loadMore}
onUpdateListSettings={updateListSettings} onUpdateListSettings={(...props) => {
clearRowSelection();
updateListSettings(...props);
}}
onAdd={() => openModal("create-product")} onAdd={() => openModal("create-product")}
onAll={resetFilters} onAll={resetFilters}
toolbar={
<IconButton
variant="secondary"
color="primary"
onClick={() => openModal("delete")}
>
<DeleteIcon />
</IconButton>
}
isChecked={isSelected}
selected={listElements.length}
toggle={toggle}
toggleAll={toggleAll}
onSearchChange={handleSearchChange} onSearchChange={handleSearchChange}
onFilterChange={changeFilters} onFilterChange={changeFilters}
onFilterAttributeFocus={setFocusedAttribute} onFilterAttributeFocus={setFocusedAttribute}
@ -470,23 +507,26 @@ export const ProductList: React.FC<ProductListProps> = ({ params }) => {
setTabIndexToDelete(tabIndex); setTabIndexToDelete(tabIndex);
openModal("delete-search"); openModal("delete-search");
}} }}
onProductsDelete={() => openModal("delete")}
onTabChange={handleTabChange} onTabChange={handleTabChange}
hasPresetsChanged={hasPresetsChanged()} hasPresetsChanged={hasPresetsChanged()}
initialSearch={params.query || ""} initialSearch={params.query || ""}
tabs={tabs.map(tab => tab.name)} tabs={tabs.map(tab => tab.name)}
onExport={() => openModal("export")} onExport={() => openModal("export")}
selectedChannelId={selectedChannel?.id} selectedChannelId={selectedChannel?.id}
selectedProductIds={selectedProductIds}
onSelectProductIds={handleSetSelectedProductIds}
columnQuery={availableInGridAttributesOpts.query} columnQuery={availableInGridAttributesOpts.query}
clearRowSelection={clearRowSelection}
setBulkDeleteButtonRef={(ref: HTMLButtonElement) => {
deleteButtonRef.current = ref;
}}
/> />
<ActionDialog <ActionDialog
open={params.action === "delete"} open={params.action === "delete"}
confirmButtonState={productBulkDeleteOpts.status} confirmButtonState={productBulkDeleteOpts.status}
onClose={closeModal} onClose={closeModal}
onConfirm={() => { onConfirm={handleSubmitBulkDelete}
productBulkDelete({
variables: { ids: listElements },
});
}}
title={intl.formatMessage({ title={intl.formatMessage({
id: "F4WdSO", id: "F4WdSO",
defaultMessage: "Delete Products", defaultMessage: "Delete Products",
@ -500,8 +540,8 @@ export const ProductList: React.FC<ProductListProps> = ({ params }) => {
defaultMessage="{counter,plural,one{Are you sure you want to delete this product?} other{Are you sure you want to delete {displayQuantity} products?}}" defaultMessage="{counter,plural,one{Are you sure you want to delete this product?} other{Are you sure you want to delete {displayQuantity} products?}}"
description="dialog content" description="dialog content"
values={{ values={{
counter: params?.ids?.length, counter: selectedProductIds.length,
displayQuantity: <strong>{params?.ids?.length}</strong>, displayQuantity: <strong>{selectedProductIds.length}</strong>,
}} }}
/> />
</DialogContentText> </DialogContentText>
@ -525,7 +565,7 @@ export const ProductList: React.FC<ProductListProps> = ({ params }) => {
all: countAllProducts.data?.products?.totalCount, all: countAllProducts.data?.products?.totalCount,
filter: data?.products?.totalCount, filter: data?.products?.totalCount,
}} }}
selectedProducts={listElements.length} selectedProducts={selectedProductIds.length}
warehouses={mapEdgesToItems(warehouses?.data?.warehouses) || []} warehouses={mapEdgesToItems(warehouses?.data?.warehouses) || []}
channels={availableChannels} channels={availableChannels}
onClose={closeModal} onClose={closeModal}
@ -535,7 +575,7 @@ export const ProductList: React.FC<ProductListProps> = ({ params }) => {
input: { input: {
...data, ...data,
filter, filter,
ids: listElements, ids: selectedProductIds,
}, },
}, },
}) })