Category listing datagrid (#3760)

Co-authored-by: wojteknowacki <wojciech.nowacki@saleor.io>
This commit is contained in:
Paweł Chyła 2023-07-04 09:23:27 +02:00 committed by GitHub
parent 1cb6e8b5fc
commit b4f11eff66
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
37 changed files with 997 additions and 812 deletions

View file

@ -0,0 +1,5 @@
---
"saleor-dashboard": minor
---
Introduce datagrid on category listing page

View file

@ -3,10 +3,7 @@
import faker from "faker"; import faker from "faker";
import { import { CATEGORIES_LIST_SELECTORS } from "../../elements/catalog/categories/categories-list";
CATEGORIES_LIST_SELECTORS,
categoryRow,
} from "../../elements/catalog/categories/categories-list";
import { CATEGORY_DETAILS_SELECTORS } from "../../elements/catalog/categories/category-details"; import { CATEGORY_DETAILS_SELECTORS } from "../../elements/catalog/categories/category-details";
import { BUTTON_SELECTORS } from "../../elements/shared/button-selectors"; import { BUTTON_SELECTORS } from "../../elements/shared/button-selectors";
import { SHARED_ELEMENTS } from "../../elements/shared/sharedElements"; import { SHARED_ELEMENTS } from "../../elements/shared/sharedElements";
@ -17,6 +14,7 @@ import {
} from "../../support/api/requests/Category"; } from "../../support/api/requests/Category";
import * as channelsUtils from "../../support/api/utils/channelsUtils"; import * as channelsUtils from "../../support/api/utils/channelsUtils";
import * as productsUtils from "../../support/api/utils/products/productsUtils"; import * as productsUtils from "../../support/api/utils/products/productsUtils";
import { ensureCanvasStatic } from "../../support/customCommands/sharedElementsOperations/canvas";
import { import {
createCategory, createCategory,
updateCategory, updateCategory,
@ -109,9 +107,13 @@ describe("As an admin I want to manage categories", () => {
.click(); .click();
createCategory({ name: categoryName, description: categoryName }) createCategory({ name: categoryName, description: categoryName })
.visit(categoryDetailsUrl(category.id)) .visit(categoryDetailsUrl(category.id))
.contains(CATEGORY_DETAILS_SELECTORS.categoryChildrenRow, categoryName) .get(SHARED_ELEMENTS.dataGridTable)
.scrollIntoView() .scrollIntoView();
.should("be.visible"); ensureCanvasStatic(SHARED_ELEMENTS.dataGridTable);
cy.contains(SHARED_ELEMENTS.dataGridTable, categoryName).should(
"be.visible",
);
getCategory(category.id).then(categoryResp => { getCategory(category.id).then(categoryResp => {
expect(categoryResp.children.edges[0].node.name).to.eq(categoryName); expect(categoryResp.children.edges[0].node.name).to.eq(categoryName);
}); });
@ -136,19 +138,22 @@ describe("As an admin I want to manage categories", () => {
"should be able to remove product from category. TC: SALEOR_0204", "should be able to remove product from category. TC: SALEOR_0204",
{ tags: ["@category", "@allEnv", "@stable"] }, { tags: ["@category", "@allEnv", "@stable"] },
() => { () => {
cy.addAliasToGraphRequest("productBulkDelete");
cy.visit(categoryDetailsUrl(category.id)) cy.visit(categoryDetailsUrl(category.id))
.get(CATEGORY_DETAILS_SELECTORS.productsTab) .get(CATEGORY_DETAILS_SELECTORS.productsTab)
.click(); .click();
cy.contains(CATEGORY_DETAILS_SELECTORS.productRow, product.name) ensureCanvasStatic(SHARED_ELEMENTS.dataGridTable);
.find(BUTTON_SELECTORS.checkbox) cy.contains(SHARED_ELEMENTS.dataGridTable, product.name).should(
"be.visible",
);
// selects first row
cy.clickGridCell(0, 0);
cy.get(CATEGORY_DETAILS_SELECTORS.deleteCategoriesButton)
.click() .click()
.get(BUTTON_SELECTORS.deleteIcon)
.click()
.addAliasToGraphRequest("productBulkDelete")
.get(BUTTON_SELECTORS.submit) .get(BUTTON_SELECTORS.submit)
.click() .click()
.confirmationMessageShouldDisappear(); .confirmationMessageShouldDisappear();
cy.contains(CATEGORY_DETAILS_SELECTORS.productRow, product.name) cy.contains(SHARED_ELEMENTS.dataGridTable, product.name)
.should("not.exist") .should("not.exist")
.waitForRequestAndCheckIfNoErrors("@productBulkDelete"); .waitForRequestAndCheckIfNoErrors("@productBulkDelete");
getCategory(category.id).then(categoryResp => { getCategory(category.id).then(categoryResp => {
@ -164,7 +169,12 @@ describe("As an admin I want to manage categories", () => {
cy.visit(urlList.categories) cy.visit(urlList.categories)
.get(SHARED_ELEMENTS.searchInput) .get(SHARED_ELEMENTS.searchInput)
.type(category.name); .type(category.name);
cy.contains(SHARED_ELEMENTS.tableRow, category.name).click(); ensureCanvasStatic(SHARED_ELEMENTS.dataGridTable);
cy.contains(SHARED_ELEMENTS.dataGridTable, category.name).should(
"be.visible",
);
// opens first row details
cy.clickGridCell(1, 0);
cy.contains(SHARED_ELEMENTS.header, category.name).should("be.visible"); cy.contains(SHARED_ELEMENTS.header, category.name).should("be.visible");
}, },
); );
@ -174,6 +184,7 @@ describe("As an admin I want to manage categories", () => {
{ tags: ["@category", "@allEnv", "@stable"] }, { tags: ["@category", "@allEnv", "@stable"] },
() => { () => {
const categoryName = `${startsWith}${faker.datatype.number()}`; const categoryName = `${startsWith}${faker.datatype.number()}`;
cy.addAliasToGraphRequest("CategoryDelete");
createCategoryRequest({ createCategoryRequest({
name: categoryName, name: categoryName,
@ -181,7 +192,6 @@ describe("As an admin I want to manage categories", () => {
cy.visit(categoryDetailsUrl(categoryResp.id)) cy.visit(categoryDetailsUrl(categoryResp.id))
.get(BUTTON_SELECTORS.deleteButton) .get(BUTTON_SELECTORS.deleteButton)
.click() .click()
.addAliasToGraphRequest("CategoryDelete")
.get(BUTTON_SELECTORS.submit) .get(BUTTON_SELECTORS.submit)
.click() .click()
.waitForRequestAndCheckIfNoErrors("@CategoryDelete"); .waitForRequestAndCheckIfNoErrors("@CategoryDelete");
@ -222,35 +232,43 @@ describe("As an admin I want to manage categories", () => {
() => { () => {
const firstCategoryName = `${startsWith}${faker.datatype.number()}`; const firstCategoryName = `${startsWith}${faker.datatype.number()}`;
const secondCategoryName = `${startsWith}${faker.datatype.number()}`; const secondCategoryName = `${startsWith}${faker.datatype.number()}`;
let firstCategory; cy.addAliasToGraphRequest("CategoryBulkDelete");
let secondCategory;
createCategoryRequest({ createCategoryRequest({
name: firstCategoryName, name: firstCategoryName,
}).then(categoryResp => {
firstCategory = categoryResp;
}); });
createCategoryRequest({ createCategoryRequest({
name: secondCategoryName, name: secondCategoryName,
}).then(categoryResp => { }).then(() => {
secondCategory = categoryResp; cy.visit(urlList.categories).searchInTable(startsWith);
cy.visit(urlList.categories) ensureCanvasStatic(SHARED_ELEMENTS.dataGridTable);
.searchInTable(startsWith) cy.get(SHARED_ELEMENTS.firstRowDataGrid)
.get(categoryRow(firstCategory.id)) .invoke("text")
.find(BUTTON_SELECTORS.checkbox) .then(firstOnListCategoryName => {
.click() cy.get(SHARED_ELEMENTS.secondRowDataGrid)
.get(categoryRow(secondCategory.id)) .invoke("text")
.find(BUTTON_SELECTORS.checkbox) .then(secondOnListCategoryName => {
.click() // deletes two first rows from categories list view
.get(BUTTON_SELECTORS.deleteIcon) cy.clickGridCell(0, 0);
.click() cy.clickGridCell(0, 1);
.addAliasToGraphRequest("CategoryBulkDelete")
.get(BUTTON_SELECTORS.submit) cy.get(CATEGORY_DETAILS_SELECTORS.deleteCategoriesButton)
.click() .click()
.waitForRequestAndCheckIfNoErrors("@CategoryBulkDelete"); .get(BUTTON_SELECTORS.submit)
cy.get(categoryRow(firstCategory.id)).should("not.exist"); .click()
cy.get(categoryRow(secondCategory.id)).should("not.exist"); .waitForRequestAndCheckIfNoErrors("@CategoryBulkDelete");
ensureCanvasStatic(SHARED_ELEMENTS.dataGridTable);
cy.contains(
SHARED_ELEMENTS.dataGridTable,
firstOnListCategoryName,
).should("not.exist");
cy.contains(
SHARED_ELEMENTS.dataGridTable,
secondOnListCategoryName,
).should("not.exist");
});
});
}); });
}, },
); );
@ -263,6 +281,7 @@ describe("As an admin I want to manage categories", () => {
const mainCategoryName = `${startsWith}${faker.datatype.number()}`; const mainCategoryName = `${startsWith}${faker.datatype.number()}`;
let subCategory; let subCategory;
let mainCategory; let mainCategory;
cy.addAliasToGraphRequest("CategoryBulkDelete");
createCategoryRequest({ createCategoryRequest({
name: mainCategoryName, name: mainCategoryName,
@ -277,14 +296,16 @@ describe("As an admin I want to manage categories", () => {
.then(categoryResp => { .then(categoryResp => {
subCategory = categoryResp; subCategory = categoryResp;
cy.visit(categoryDetailsUrl(mainCategory.id)) cy.visit(categoryDetailsUrl(mainCategory.id))
.get(categoryRow(subCategory.id)) .get(SHARED_ELEMENTS.dataGridTable)
.find(BUTTON_SELECTORS.checkbox) .scrollIntoView();
ensureCanvasStatic(SHARED_ELEMENTS.dataGridTable);
// selects first row of subcategories
cy.clickGridCell(0, 0);
cy.get(CATEGORY_DETAILS_SELECTORS.deleteCategoriesButton)
.click() .click()
.get(BUTTON_SELECTORS.deleteIcon)
.click()
.addAliasToGraphRequest("CategoryBulkDelete")
.get(BUTTON_SELECTORS.submit) .get(BUTTON_SELECTORS.submit)
.click() .click()
.confirmationMessageShouldDisappear()
.waitForRequestAndCheckIfNoErrors("@CategoryBulkDelete"); .waitForRequestAndCheckIfNoErrors("@CategoryBulkDelete");
getCategory(subCategory.id).should("be.null"); getCategory(subCategory.id).should("be.null");
getCategory(mainCategory.id); getCategory(mainCategory.id);

View file

@ -6,4 +6,5 @@ export const CATEGORY_DETAILS_SELECTORS = {
productsTab: '[data-test-id="products-tab"]', productsTab: '[data-test-id="products-tab"]',
addProducts: '[data-test-id="add-products"]', addProducts: '[data-test-id="add-products"]',
productRow: '[data-test-id="product-row"]', productRow: '[data-test-id="product-row"]',
deleteCategoriesButton: '[data-test-id="delete-categories-button"]',
}; };

View file

@ -7,6 +7,8 @@ export const SHARED_ELEMENTS = {
dataGridTable: "[data-testid='data-grid-canvas']", dataGridTable: "[data-testid='data-grid-canvas']",
skeleton: '[data-test-id="skeleton"]', skeleton: '[data-test-id="skeleton"]',
table: 'table[class*="Table"]', table: 'table[class*="Table"]',
firstRowDataGrid: "[data-testid='glide-cell-1-0']",
secondRowDataGrid: "[id='glide-cell-1-1']",
tableRow: '[data-test-id*="id"], [class*="MuiTableRow"]', tableRow: '[data-test-id*="id"], [class*="MuiTableRow"]',
notificationSuccess: notificationSuccess:
'[data-test-id="notification"][data-test-type="success"]', '[data-test-id="notification"][data-test-type="success"]',

View file

@ -14,17 +14,16 @@ export function updateCategory({ name, description }) {
export function fillUpCategoryGeneralInfo({ name, description }) { export function fillUpCategoryGeneralInfo({ name, description }) {
return cy return cy
.get(CATEGORY_DETAILS_SELECTORS.nameInput)
.clearAndType(name)
.get(CATEGORY_DETAILS_SELECTORS.descriptionInput) .get(CATEGORY_DETAILS_SELECTORS.descriptionInput)
.find(SHARED_ELEMENTS.contentEditable) .find(SHARED_ELEMENTS.contentEditable)
.should("be.visible") .should("be.visible")
.get(CATEGORY_DETAILS_SELECTORS.descriptionInput) .get(CATEGORY_DETAILS_SELECTORS.descriptionInput)
.click() .click()
.get(CATEGORY_DETAILS_SELECTORS.descriptionInput) .get(CATEGORY_DETAILS_SELECTORS.descriptionInput)
.find(SHARED_ELEMENTS.contentEditable) .clearAndType(description);
.get(CATEGORY_DETAILS_SELECTORS.descriptionInput)
.clearAndType(description)
.get(CATEGORY_DETAILS_SELECTORS.nameInput)
.clearAndType(name);
} }
export function saveCategory(alias = "CategoryCreate") { export function saveCategory(alias = "CategoryCreate") {

View file

@ -545,6 +545,9 @@
"context": "product variants, title", "context": "product variants, title",
"string": "Variants" "string": "Variants"
}, },
"1X6HtI": {
"string": "All Categories"
},
"1div9r": { "1div9r": {
"string": "Search Attribute" "string": "Search Attribute"
}, },
@ -1784,10 +1787,6 @@
"context": "webhooks and events section name", "context": "webhooks and events section name",
"string": "Webhooks & Events" "string": "Webhooks & Events"
}, },
"BHQrgz": {
"context": "number of subcategories",
"string": "Subcategories"
},
"BJtUQI": { "BJtUQI": {
"context": "button", "context": "button",
"string": "Add" "string": "Add"
@ -2329,6 +2328,9 @@
"context": "Webhook details objects", "context": "Webhook details objects",
"string": "Objects" "string": "Objects"
}, },
"F7DxHw": {
"string": "Subcategories"
},
"F8gsds": { "F8gsds": {
"context": "unpublish page, button", "context": "unpublish page, button",
"string": "Unpublish" "string": "Unpublish"
@ -3057,9 +3059,6 @@
"context": "dialog search placeholder", "context": "dialog search placeholder",
"string": "Search by collection name, etc..." "string": "Search by collection name, etc..."
}, },
"JiXNEV": {
"string": "Search Category"
},
"Jj0de8": { "Jj0de8": {
"context": "voucher status", "context": "voucher status",
"string": "Scheduled" "string": "Scheduled"
@ -4306,6 +4305,9 @@
"context": "header", "context": "header",
"string": "Create Variant" "string": "Create Variant"
}, },
"T83iU7": {
"string": "Search categories..."
},
"T8rvXs": { "T8rvXs": {
"context": "order subtotal price", "context": "order subtotal price",
"string": "Subtotal" "string": "Subtotal"
@ -4659,10 +4661,6 @@
"VOiUXQ": { "VOiUXQ": {
"string": "Used to calculate rates for shipping for products of this product type, when specific weight is not given" "string": "Used to calculate rates for shipping for products of this product type, when specific weight is not given"
}, },
"VQLIXd": {
"context": "product",
"string": "Name"
},
"VSj89H": { "VSj89H": {
"context": "fulfill button label", "context": "fulfill button label",
"string": "Fulfill anyway" "string": "Fulfill anyway"
@ -5247,6 +5245,9 @@
"ZMy18J": { "ZMy18J": {
"string": "You have reached your channel limit, you will be no longer able to add channels to your store. If you would like to up your limit, contact your administration staff about raising your limits." "string": "You have reached your channel limit, you will be no longer able to add channels to your store. If you would like to up your limit, contact your administration staff about raising your limits."
}, },
"ZN5IZl": {
"string": "Bulk categories delete"
},
"ZPOyI1": { "ZPOyI1": {
"context": "fulfilled fulfillment, section header", "context": "fulfilled fulfillment, section header",
"string": "Fulfilled from {warehouseName}" "string": "Fulfilled from {warehouseName}"
@ -5671,6 +5672,9 @@
"context": "product attribute type", "context": "product attribute type",
"string": "Multiple Select" "string": "Multiple Select"
}, },
"cLcy6F": {
"string": "Number of products"
},
"cMFlOp": { "cMFlOp": {
"context": "input label", "context": "input label",
"string": "New Password" "string": "New Password"
@ -5774,6 +5778,9 @@
"context": "config type section title", "context": "config type section title",
"string": "Configuration Type" "string": "Configuration Type"
}, },
"cxOmce": {
"string": "Bulk products delete"
},
"cy8sV7": { "cy8sV7": {
"context": "volume units types", "context": "volume units types",
"string": "Volume" "string": "Volume"
@ -6607,10 +6614,6 @@
"context": "tooltip content when product is in preorder", "context": "tooltip content when product is in preorder",
"string": "This product is still in preorder. You will be able to fulfill it after it reaches its release date" "string": "This product is still in preorder. You will be able to fulfill it after it reaches its release date"
}, },
"k8ZJ5L": {
"context": "number of products",
"string": "No. of Products"
},
"k8bltk": { "k8bltk": {
"string": "No Results" "string": "No Results"
}, },
@ -6712,6 +6715,9 @@
"context": "balance amound missing error message", "context": "balance amound missing error message",
"string": "Balance amount is missing" "string": "Balance amount is missing"
}, },
"kgVqk1": {
"string": "Category name"
},
"ki7Mr8": { "ki7Mr8": {
"context": "product export to csv file, header", "context": "product export to csv file, header",
"string": "Export Settings" "string": "Export Settings"
@ -7454,6 +7460,9 @@
"context": "order line total price", "context": "order line total price",
"string": "Total" "string": "Total"
}, },
"qU/z0Q": {
"string": "Bulk category delete"
},
"qZHHed": { "qZHHed": {
"context": "stock exceeded dialog title", "context": "stock exceeded dialog title",
"string": "Not enough stock" "string": "Not enough stock"
@ -7626,9 +7635,6 @@
"context": "header", "context": "header",
"string": "Top Products" "string": "Top Products"
}, },
"rrbzZt": {
"string": "No subcategories found"
},
"rs815i": { "rs815i": {
"context": "text field label", "context": "text field label",
"string": "Group name" "string": "Group name"
@ -8234,10 +8240,6 @@
"context": "draft order", "context": "draft order",
"string": "Created" "string": "Created"
}, },
"vy7fjd": {
"context": "tab name",
"string": "All Categories"
},
"vzce9B": { "vzce9B": {
"context": "customer gift cards card subtitle", "context": "customer gift cards card subtitle",
"string": "Only five newest gift cards are shown here" "string": "Only five newest gift cards are shown here"

View file

@ -0,0 +1,38 @@
import { Button, Tooltip, TrashBinIcon } from "@saleor/macaw-ui/next";
import React, { forwardRef, ReactNode, useState } from "react";
interface CategoryDeleteButtonProps {
onClick: () => void;
children: ReactNode;
}
export const CategoryDeleteButton = forwardRef<
HTMLButtonElement,
CategoryDeleteButtonProps
>(({ onClick, children }, ref) => {
const [isTooltipOpen, setIsTooltipOpen] = useState(false);
return (
<Tooltip open={isTooltipOpen}>
<Tooltip.Trigger>
<Button
ref={ref}
onMouseOver={() => {
setIsTooltipOpen(true);
}}
onMouseLeave={() => {
setIsTooltipOpen(false);
}}
onClick={onClick}
icon={<TrashBinIcon />}
variant="secondary"
data-test-id="delete-categories-button"
/>
</Tooltip.Trigger>
<Tooltip.Content side="bottom">
<Tooltip.Arrow />
{children}
</Tooltip.Content>
</Tooltip>
);
});

View file

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

View file

@ -1,218 +0,0 @@
// @ts-strict-ignore
import {
CategoryListUrlSortField,
categoryUrl,
} from "@dashboard/categories/urls";
import Checkbox from "@dashboard/components/Checkbox";
import ResponsiveTable from "@dashboard/components/ResponsiveTable";
import Skeleton from "@dashboard/components/Skeleton";
import TableCellHeader from "@dashboard/components/TableCellHeader";
import TableHead from "@dashboard/components/TableHead";
import { TablePaginationWithContext } from "@dashboard/components/TablePagination";
import TableRowLink from "@dashboard/components/TableRowLink";
import { CategoryFragment } from "@dashboard/graphql";
import { maybe, renderCollection } from "@dashboard/misc";
import { ListActions, ListProps, SortPage } from "@dashboard/types";
import { getArrowDirection } from "@dashboard/utils/sort";
import { TableBody, TableCell, TableFooter } from "@material-ui/core";
import { makeStyles } from "@saleor/macaw-ui";
import React from "react";
import { FormattedMessage } from "react-intl";
const useStyles = makeStyles(
theme => ({
[theme.breakpoints.up("lg")]: {
colName: {
width: "auto",
},
colProducts: {
width: 160,
},
colSubcategories: {
width: 160,
},
},
colName: {
paddingLeft: 0,
},
colProducts: {
textAlign: "center",
},
colSubcategories: {
textAlign: "center",
},
tableRow: {
cursor: "pointer",
},
}),
{ name: "CategoryList" },
);
interface CategoryListProps
extends ListProps,
ListActions,
SortPage<CategoryListUrlSortField> {
categories?: CategoryFragment[];
isRoot: boolean;
}
const CategoryList: React.FC<CategoryListProps> = props => {
const {
categories,
disabled,
settings,
sort,
isChecked,
isRoot,
selected,
toggle,
toggleAll,
toolbar,
onUpdateListSettings,
onSort,
} = props;
const classes = useStyles(props);
const numberOfColumns = categories?.length === 0 ? 3 : 4;
return (
<ResponsiveTable>
<TableHead
colSpan={numberOfColumns}
selected={selected}
disabled={disabled}
items={categories}
toggleAll={toggleAll}
toolbar={toolbar}
>
<TableCellHeader
direction={
isRoot && sort.sort === CategoryListUrlSortField.name
? getArrowDirection(sort.asc)
: undefined
}
arrowPosition="right"
className={classes.colName}
disabled={!isRoot}
onClick={() => isRoot && onSort(CategoryListUrlSortField.name)}
>
<FormattedMessage id="vEYtiq" defaultMessage="Category Name" />
</TableCellHeader>
<TableCellHeader
direction={
isRoot && sort.sort === CategoryListUrlSortField.subcategoryCount
? getArrowDirection(sort.asc)
: undefined
}
className={classes.colSubcategories}
disabled={!isRoot}
onClick={() =>
isRoot && onSort(CategoryListUrlSortField.subcategoryCount)
}
>
<FormattedMessage
id="BHQrgz"
defaultMessage="Subcategories"
description="number of subcategories"
/>
</TableCellHeader>
<TableCellHeader
direction={
isRoot && sort.sort === CategoryListUrlSortField.productCount
? getArrowDirection(sort.asc)
: undefined
}
className={classes.colProducts}
disabled={!isRoot}
onClick={() =>
isRoot && onSort(CategoryListUrlSortField.productCount)
}
>
<FormattedMessage
id="k8ZJ5L"
defaultMessage="No. of Products"
description="number of products"
/>
</TableCellHeader>
</TableHead>
<TableFooter>
<TableRowLink>
<TablePaginationWithContext
colSpan={numberOfColumns}
settings={settings}
onUpdateListSettings={onUpdateListSettings}
/>
</TableRowLink>
</TableFooter>
<TableBody>
{renderCollection(
categories,
category => {
const isSelected = category ? isChecked(category.id) : false;
return (
<TableRowLink
className={classes.tableRow}
hover={!!category}
href={category && categoryUrl(category.id)}
key={category ? category.id : "skeleton"}
selected={isSelected}
data-test-id={"id-" + maybe(() => category.id)}
>
<TableCell padding="checkbox">
<Checkbox
checked={isSelected}
disabled={disabled}
disableClickPropagation
onChange={() => toggle(category.id)}
/>
</TableCell>
<TableCell className={classes.colName} data-test-id="name">
{category && category.name ? category.name : <Skeleton />}
</TableCell>
<TableCell className={classes.colSubcategories}>
{category &&
category.children &&
category.children.totalCount !== undefined ? (
category.children.totalCount
) : (
<Skeleton />
)}
</TableCell>
<TableCell className={classes.colProducts}>
{category &&
category.products &&
category.products.totalCount !== undefined ? (
category.products.totalCount
) : (
<Skeleton />
)}
</TableCell>
</TableRowLink>
);
},
() => (
<TableRowLink>
<TableCell colSpan={numberOfColumns}>
{isRoot ? (
<FormattedMessage
id="dM86a2"
defaultMessage="No categories found"
/>
) : (
<FormattedMessage
id="rrbzZt"
defaultMessage="No subcategories found"
/>
)}
</TableCell>
</TableRowLink>
),
)}
</TableBody>
</ResponsiveTable>
);
};
CategoryList.displayName = "CategoryList";
export default CategoryList;

View file

@ -1,2 +0,0 @@
export { default } from "./CategoryList";
export * from "./CategoryList";

View file

@ -0,0 +1,129 @@
// @ts-strict-ignore
import {
CategoryListUrlSortField,
categoryUrl,
} from "@dashboard/categories/urls";
import ColumnPicker from "@dashboard/components/ColumnPicker";
import Datagrid from "@dashboard/components/Datagrid/Datagrid";
import { useColumnsDefault } from "@dashboard/components/Datagrid/hooks/useColumnsDefault";
import {
DatagridChangeStateContext,
useDatagridChangeState,
} from "@dashboard/components/Datagrid/hooks/useDatagridChange";
import { TablePaginationWithContext } from "@dashboard/components/TablePagination";
import { CategoryFragment } from "@dashboard/graphql";
import { PageListProps, SortPage } from "@dashboard/types";
import { Item } from "@glideapps/glide-data-grid";
import { Box } from "@saleor/macaw-ui/next";
import React, { ReactNode, useCallback, useMemo } from "react";
import { useIntl } from "react-intl";
import { createGetCellContent, getColumns } from "./datagrid";
import { messages } from "./messages";
interface CategoryListDatagridProps
extends Partial<SortPage<CategoryListUrlSortField>>,
PageListProps {
categories?: CategoryFragment[];
disabled: boolean;
onSelectCategoriesIds: (ids: number[], clearSelection: () => void) => void;
selectionActionButton?: ReactNode | null;
hasRowHover?: boolean;
}
export const CategoryListDatagrid = ({
sort,
onSort,
categories,
disabled,
onSelectCategoriesIds,
settings,
onUpdateListSettings,
selectionActionButton = null,
hasRowHover = true,
}: CategoryListDatagridProps) => {
const datagridState = useDatagridChangeState();
const intl = useIntl();
const availableColumns = useMemo(() => getColumns(intl, sort), [intl, sort]);
const {
availableColumnsChoices,
columnChoices,
columns,
defaultColumns,
onColumnMoved,
onColumnResize,
onColumnsChange,
picker,
} = useColumnsDefault(availableColumns);
// eslint-disable-next-line react-hooks/exhaustive-deps
const getCellContent = useCallback(
createGetCellContent(categories, columns),
[categories, columns],
);
const handleHeaderClick = useCallback(
(col: number) => {
if (sort !== undefined) {
onSort(columns[col].id as CategoryListUrlSortField);
}
},
[columns, onSort, sort],
);
const handleRowAnchor = useCallback(
([, row]: Item) => categoryUrl(categories[row].id),
[categories],
);
return (
<DatagridChangeStateContext.Provider value={datagridState}>
<Datagrid
readonly
hasRowHover={hasRowHover}
loading={disabled}
columnSelect={sort !== undefined ? "single" : undefined}
verticalBorder={col => col > 0}
rowMarkers="checkbox"
availableColumns={columns}
rows={categories?.length ?? 0}
getCellContent={getCellContent}
getCellError={() => false}
emptyText={intl.formatMessage(messages.noData)}
onHeaderClicked={handleHeaderClick}
rowAnchor={handleRowAnchor}
menuItems={() => []}
actionButtonPosition="right"
selectionActions={() => selectionActionButton}
onColumnResize={onColumnResize}
onColumnMoved={onColumnMoved}
onRowSelectionChange={onSelectCategoriesIds}
renderColumnPicker={defaultProps => (
<ColumnPicker
{...defaultProps}
availableColumns={availableColumnsChoices}
initialColumns={columnChoices}
defaultColumns={defaultColumns}
onSave={onColumnsChange}
hasMore={false}
loading={false}
onFetchMore={() => undefined}
onQueryChange={picker.setQuery}
query={picker.query}
/>
)}
/>
<Box paddingX={6}>
<TablePaginationWithContext
component="div"
colSpan={1}
settings={settings}
disabled={disabled}
onUpdateListSettings={onUpdateListSettings}
/>
</Box>
</DatagridChangeStateContext.Provider>
);
};

View file

@ -0,0 +1,67 @@
// @ts-strict-ignore
import { CategoryListUrlSortField } from "@dashboard/categories/urls";
import { readonlyTextCell } from "@dashboard/components/Datagrid/customCells/cells";
import { AvailableColumn } from "@dashboard/components/Datagrid/types";
import { CategoryFragment } from "@dashboard/graphql";
import { Sort } from "@dashboard/types";
import { getColumnSortDirectionIcon } from "@dashboard/utils/columns/getColumnSortDirectionIcon";
import { GridCell, Item } from "@glideapps/glide-data-grid";
import { IntlShape } from "react-intl";
import { columnsMessages } from "./messages";
export const getColumns = (
intl: IntlShape,
sort?: Sort<CategoryListUrlSortField>,
): AvailableColumn[] => [
{
id: "name",
title: intl.formatMessage(columnsMessages.categoryName),
width: 350,
icon: sort
? getColumnSortDirectionIcon(sort, CategoryListUrlSortField.name)
: undefined,
},
{
id: "subcategories",
title: intl.formatMessage(columnsMessages.subcategories),
width: 300,
icon: sort
? getColumnSortDirectionIcon(
sort,
CategoryListUrlSortField.subcategoryCount,
)
: undefined,
},
{
id: "products",
title: intl.formatMessage(columnsMessages.numberOfProducts),
width: 300,
icon: sort
? getColumnSortDirectionIcon(sort, CategoryListUrlSortField.productCount)
: undefined,
},
];
export const createGetCellContent =
(categories: CategoryFragment[], columns: AvailableColumn[]) =>
([column, row]: Item): GridCell => {
const columnId = columns[column]?.id;
if (!columnId) {
return readonlyTextCell("");
}
const rowData = categories[row];
switch (columnId) {
case "name":
return readonlyTextCell(rowData?.name ?? "");
case "subcategories":
return readonlyTextCell(rowData?.children?.totalCount.toString() ?? "");
case "products":
return readonlyTextCell(rowData?.products?.totalCount.toString() ?? "");
default:
return readonlyTextCell("", false);
}
};

View file

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

View file

@ -0,0 +1,23 @@
import { defineMessages } from "react-intl";
export const columnsMessages = defineMessages({
categoryName: {
id: "kgVqk1",
defaultMessage: "Category name",
},
subcategories: {
defaultMessage: "Subcategories",
id: "F7DxHw",
},
numberOfProducts: {
defaultMessage: "Number of products",
id: "cLcy6F",
},
});
export const messages = defineMessages({
noData: {
defaultMessage: "No categories found",
id: "dM86a2",
},
});

View file

@ -25,6 +25,11 @@ const categoryTableProps: CategoryTableProps = {
...sortPageProps.sort, ...sortPageProps.sort,
sort: CategoryListUrlSortField.name, sort: CategoryListUrlSortField.name,
}, },
onCategoriesDelete: () => undefined,
onSelectCategoriesIds: () => undefined,
selectedCategoriesIds: [],
hasPresetsChanged: false,
onTabUpdate: () => undefined,
}; };
const meta: Meta<typeof CategoryListPage> = { const meta: Meta<typeof CategoryListPage> = {
@ -47,6 +52,7 @@ export const Default: Story = {
export const Loading: Story = { export const Loading: Story = {
args: { args: {
...categoryTableProps, ...categoryTableProps,
disabled: true,
categories: undefined, categories: undefined,
}, },
parameters: { parameters: {

View file

@ -2,32 +2,40 @@ import {
categoryAddUrl, categoryAddUrl,
CategoryListUrlSortField, CategoryListUrlSortField,
} from "@dashboard/categories/urls"; } from "@dashboard/categories/urls";
import SearchInput from "@dashboard/components/AppLayout/ListFilters/components/SearchInput";
import { TopNav } from "@dashboard/components/AppLayout/TopNav"; import { TopNav } from "@dashboard/components/AppLayout/TopNav";
import { Button } from "@dashboard/components/Button"; import { Button } from "@dashboard/components/Button";
import { FilterPresetsSelect } from "@dashboard/components/FilterPresetsSelect";
import { ListPageLayout } from "@dashboard/components/Layouts"; import { ListPageLayout } from "@dashboard/components/Layouts";
import SearchBar from "@dashboard/components/SearchBar";
import { CategoryFragment } from "@dashboard/graphql"; import { CategoryFragment } from "@dashboard/graphql";
import { sectionNames } from "@dashboard/intl"; import { sectionNames } from "@dashboard/intl";
import { import {
ListActions,
PageListProps, PageListProps,
SearchPageProps, SearchPageProps,
SortPage, SortPage,
TabPageProps, TabPageProps,
} from "@dashboard/types"; } from "@dashboard/types";
import { Card } from "@material-ui/core"; import { Card } from "@material-ui/core";
import React from "react"; import { Box, ChevronRightIcon } from "@saleor/macaw-ui/next";
import React, { useState } from "react";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import CategoryList from "../CategoryList"; import { CategoryDeleteButton } from "../CategoryDeleteButton";
import { CategoryListDatagrid } from "../CategoryListDatagrid";
import { messages } from "./messages";
export interface CategoryTableProps export interface CategoryTableProps
extends PageListProps, extends PageListProps,
ListActions,
SearchPageProps, SearchPageProps,
SortPage<CategoryListUrlSortField>, SortPage<CategoryListUrlSortField>,
TabPageProps { Omit<TabPageProps, "onTabDelete"> {
categories: CategoryFragment[]; categories: CategoryFragment[];
hasPresetsChanged: boolean;
selectedCategoriesIds: string[];
onTabDelete: (tabIndex: number) => void;
onTabUpdate: (tabName: string) => void;
onCategoriesDelete: () => void;
onSelectCategoriesIds: (ids: number[], clearSelection: () => void) => void;
} }
export const CategoryListPage: React.FC<CategoryTableProps> = ({ export const CategoryListPage: React.FC<CategoryTableProps> = ({
@ -35,69 +43,88 @@ export const CategoryListPage: React.FC<CategoryTableProps> = ({
currentTab, currentTab,
disabled, disabled,
initialSearch, initialSearch,
isChecked,
selected,
settings,
tabs, tabs,
toggle,
toggleAll,
toolbar,
onAll, onAll,
onSearchChange, onSearchChange,
onTabChange, onTabChange,
onTabDelete, onTabDelete,
onTabSave, onTabSave,
onUpdateListSettings, onTabUpdate,
hasPresetsChanged,
onCategoriesDelete,
selectedCategoriesIds,
...listProps ...listProps
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
const [isFilterPresetOpen, setFilterPresetOpen] = useState(false);
return ( return (
<ListPageLayout> <ListPageLayout>
<TopNav title={intl.formatMessage(sectionNames.categories)}> <TopNav
<Button title={intl.formatMessage(sectionNames.categories)}
variant="primary" isAlignToRight={false}
href={categoryAddUrl()} withoutBorder
data-test-id="create-category" >
<Box
__flex={1}
display="flex"
justifyContent="space-between"
alignItems="center"
> >
<FormattedMessage <Box display="flex">
id="vof5TR" <Box marginX={3} display="flex" alignItems="center">
defaultMessage="Create category" <ChevronRightIcon />
description="button" </Box>
/>
</Button> <FilterPresetsSelect
presetsChanged={hasPresetsChanged}
onSelect={onTabChange}
onRemove={onTabDelete}
onUpdate={onTabUpdate}
savedPresets={tabs}
activePreset={currentTab}
onSelectAll={onAll}
onSave={onTabSave}
isOpen={isFilterPresetOpen}
onOpenChange={setFilterPresetOpen}
selectAllLabel={intl.formatMessage(messages.allCategories)}
/>
</Box>
<Button
variant="primary"
href={categoryAddUrl()}
data-test-id="create-category"
>
<FormattedMessage {...messages.createCategory} />
</Button>
</Box>
</TopNav> </TopNav>
<Card> <Card>
<SearchBar <Box
allTabLabel={intl.formatMessage({ display="flex"
id: "vy7fjd", justifyContent="space-between"
defaultMessage: "All Categories", alignItems="center"
description: "tab name", paddingX={6}
})} marginBottom={2}
currentTab={currentTab} >
initialSearch={initialSearch} <Box __width="320px">
searchPlaceholder={intl.formatMessage({ <SearchInput
id: "JiXNEV", initialSearch={initialSearch}
defaultMessage: "Search Category", placeholder={intl.formatMessage(messages.searchCategory)}
})} onSearchChange={onSearchChange}
tabs={tabs} />
onAll={onAll} </Box>
onSearchChange={onSearchChange} {selectedCategoriesIds.length > 0 && (
onTabChange={onTabChange} <CategoryDeleteButton onClick={onCategoriesDelete}>
onTabDelete={onTabDelete} <FormattedMessage {...messages.bulkCategoryDelete} />
onTabSave={onTabSave} </CategoryDeleteButton>
/> )}
<CategoryList </Box>
categories={categories} <CategoryListDatagrid
disabled={disabled} disabled={disabled}
isChecked={isChecked} categories={categories}
isRoot={true} hasRowHover={!isFilterPresetOpen}
selected={selected}
settings={settings}
toggle={toggle}
toggleAll={toggleAll}
toolbar={toolbar}
onUpdateListSettings={onUpdateListSettings}
{...listProps} {...listProps}
/> />
</Card> </Card>

View file

@ -0,0 +1,21 @@
import { defineMessages } from "react-intl";
export const messages = defineMessages({
allCategories: {
id: "1X6HtI",
defaultMessage: "All Categories",
},
createCategory: {
id: "vof5TR",
defaultMessage: "Create category",
description: "button",
},
searchCategory: {
id: "T83iU7",
defaultMessage: "Search categories...",
},
bulkCategoryDelete: {
defaultMessage: "Bulk category delete",
id: "qU/z0Q",
},
});

View file

@ -1,156 +0,0 @@
// @ts-strict-ignore
import Checkbox from "@dashboard/components/Checkbox";
import ResponsiveTable from "@dashboard/components/ResponsiveTable";
import Skeleton from "@dashboard/components/Skeleton";
import TableCellAvatar from "@dashboard/components/TableCellAvatar";
import { AVATAR_MARGIN } from "@dashboard/components/TableCellAvatar/Avatar";
import TableHead from "@dashboard/components/TableHead";
import { TablePaginationWithContext } from "@dashboard/components/TablePagination";
import TableRowLink from "@dashboard/components/TableRowLink";
import { CategoryDetailsQuery } from "@dashboard/graphql";
import { maybe, renderCollection } from "@dashboard/misc";
import { productUrl } from "@dashboard/products/urls";
import { ListActions, ListProps, RelayToFlat } from "@dashboard/types";
import { TableBody, TableCell, TableFooter } from "@material-ui/core";
import { makeStyles } from "@saleor/macaw-ui";
import React from "react";
import { FormattedMessage } from "react-intl";
const useStyles = makeStyles(
theme => ({
[theme.breakpoints.up("lg")]: {
colName: {
width: "auto",
},
},
colFill: {
padding: 0,
width: "100%",
},
colName: {},
colNameHeader: {
marginLeft: AVATAR_MARGIN,
},
link: {
cursor: "pointer",
},
table: {
tableLayout: "fixed",
},
tableContainer: {
overflowX: "scroll",
},
textLeft: {
textAlign: "left",
},
textRight: {
textAlign: "right",
},
}),
{
name: "CategoryProductList",
},
);
interface CategoryProductListProps extends ListProps, ListActions {
products: RelayToFlat<CategoryDetailsQuery["category"]["products"]>;
}
export const CategoryProductList: React.FC<
CategoryProductListProps
> = props => {
const {
disabled,
isChecked,
products,
selected,
toggle,
toggleAll,
toolbar,
} = props;
const classes = useStyles(props);
const numberOfColumns = 2;
return (
<div className={classes.tableContainer}>
<ResponsiveTable className={classes.table}>
<colgroup>
<col />
<col className={classes.colName} />
</colgroup>
<TableHead
colSpan={numberOfColumns}
selected={selected}
disabled={disabled}
items={products}
toggleAll={toggleAll}
toolbar={toolbar}
>
<TableCell className={classes.colName}>
<span className={classes.colNameHeader}>
<FormattedMessage
id="VQLIXd"
defaultMessage="Name"
description="product"
/>
</span>
</TableCell>
</TableHead>
<TableFooter>
<TableRowLink>
<TablePaginationWithContext colSpan={numberOfColumns} />
</TableRowLink>
</TableFooter>
<TableBody>
{renderCollection(
products,
product => {
const isSelected = product ? isChecked(product.id) : false;
return (
<TableRowLink
data-test-id="product-row"
selected={isSelected}
hover={!!product}
key={product ? product.id : "skeleton"}
href={product && productUrl(product.id)}
className={classes.link}
>
<TableCell padding="checkbox">
<Checkbox
checked={isSelected}
disabled={disabled}
disableClickPropagation
onChange={() => toggle(product.id)}
/>
</TableCell>
<TableCellAvatar
className={classes.colName}
thumbnail={maybe(() => product.thumbnail.url)}
>
{product ? product.name : <Skeleton />}
</TableCellAvatar>
</TableRowLink>
);
},
() => (
<TableRowLink>
<TableCell colSpan={numberOfColumns}>
<FormattedMessage
id="Q1Uzbb"
defaultMessage="No products found"
/>
</TableCell>
</TableRowLink>
),
)}
</TableBody>
</ResponsiveTable>
</div>
);
};
CategoryProductList.displayName = "CategoryProductList";
export default CategoryProductList;

View file

@ -1,2 +0,0 @@
export { default } from "./CategoryProductList";
export * from "./CategoryProductList";

View file

@ -0,0 +1,87 @@
// @ts-strict-ignore
import Datagrid from "@dashboard/components/Datagrid/Datagrid";
import { useColumnsDefault } from "@dashboard/components/Datagrid/hooks/useColumnsDefault";
import {
DatagridChangeStateContext,
useDatagridChangeState,
} from "@dashboard/components/Datagrid/hooks/useDatagridChange";
import { TablePaginationWithContext } from "@dashboard/components/TablePagination";
import { CategoryDetailsQuery } from "@dashboard/graphql";
import { productUrl } from "@dashboard/products/urls";
import { PageListProps, RelayToFlat } from "@dashboard/types";
import { Item } from "@glideapps/glide-data-grid";
import { Box } from "@saleor/macaw-ui/next";
import React, { ReactNode, useCallback, useMemo } from "react";
import { useIntl } from "react-intl";
import { createGetCellContent, getColumns } from "./datagrid";
interface CategoryListDatagridProps extends PageListProps {
products?: RelayToFlat<CategoryDetailsQuery["category"]["products"]>;
disabled: boolean;
selectionActionButton?: ReactNode | null;
onSelectProductsIds: (ids: number[], clearSelection: () => void) => void;
}
export const CategoryProductListDatagrid = ({
products,
disabled,
onSelectProductsIds,
settings,
onUpdateListSettings,
selectionActionButton = null,
}: CategoryListDatagridProps) => {
const datagridState = useDatagridChangeState();
const intl = useIntl();
const availableColumns = useMemo(() => getColumns(intl), [intl]);
// eslint-disable-next-line react-hooks/exhaustive-deps
const getCellContent = useCallback(
createGetCellContent(products, availableColumns),
[products, availableColumns],
);
const { columns, onColumnMoved, onColumnResize } =
useColumnsDefault(availableColumns);
const handleRowAnchor = useCallback(
([, row]: Item) => productUrl(products[row].id),
[products],
);
return (
<DatagridChangeStateContext.Provider value={datagridState}>
<Datagrid
hasRowHover
readonly
actionButtonPosition="right"
loading={disabled}
verticalBorder={false}
rowMarkers="checkbox"
availableColumns={columns}
rows={products?.length ?? 0}
getCellContent={getCellContent}
getCellError={() => false}
emptyText={intl.formatMessage({
defaultMessage: "No products found",
id: "Q1Uzbb",
})}
rowAnchor={handleRowAnchor}
menuItems={() => []}
selectionActions={() => selectionActionButton}
onColumnResize={onColumnResize}
onColumnMoved={onColumnMoved}
onRowSelectionChange={onSelectProductsIds}
/>
<Box paddingX={6}>
<TablePaginationWithContext
component="div"
colSpan={1}
settings={settings}
disabled={disabled}
onUpdateListSettings={onUpdateListSettings}
/>
</Box>
</DatagridChangeStateContext.Provider>
);
};

View file

@ -0,0 +1,45 @@
// @ts-strict-ignore
import {
readonlyTextCell,
thumbnailCell,
} from "@dashboard/components/Datagrid/customCells/cells";
import { AvailableColumn } from "@dashboard/components/Datagrid/types";
import { CategoryDetailsQuery } from "@dashboard/graphql";
import { RelayToFlat } from "@dashboard/types";
import { GridCell, Item } from "@glideapps/glide-data-grid";
import { IntlShape } from "react-intl";
import { columnsMessages } from "./messages";
export const getColumns = (intl: IntlShape): AvailableColumn[] => [
{
id: "name",
title: intl.formatMessage(columnsMessages.name),
width: 500,
},
];
export const createGetCellContent =
(
products: RelayToFlat<CategoryDetailsQuery["category"]["products"]>,
columns: AvailableColumn[],
) =>
([column, row]: Item): GridCell => {
const columnId = columns[column]?.id;
if (!columnId) {
return readonlyTextCell("");
}
const rowData = products[row];
switch (columnId) {
case "name":
const name = rowData?.name ?? "";
return thumbnailCell(name, rowData?.thumbnail?.url ?? "", {
cursor: "pointer",
});
default:
return readonlyTextCell("", false);
}
};

View file

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

View file

@ -0,0 +1,8 @@
import { defineMessages } from "react-intl";
export const columnsMessages = defineMessages({
name: {
id: "HAlOn1",
defaultMessage: "Name",
},
});

View file

@ -1,91 +1,85 @@
// @ts-strict-ignore // @ts-strict-ignore
import { Button } from "@dashboard/components/Button"; import { DashboardCard } from "@dashboard/components/Card";
import CardTitle from "@dashboard/components/CardTitle";
import HorizontalSpacer from "@dashboard/components/HorizontalSpacer";
import { InternalLink } from "@dashboard/components/InternalLink"; import { InternalLink } from "@dashboard/components/InternalLink";
import { CategoryDetailsQuery } from "@dashboard/graphql"; import { CategoryDetailsQuery } from "@dashboard/graphql";
import { productAddUrl, productListUrl } from "@dashboard/products/urls"; import { productAddUrl, productListUrl } from "@dashboard/products/urls";
import { Card } from "@material-ui/core"; import { RelayToFlat } from "@dashboard/types";
import { Box, Button } from "@saleor/macaw-ui/next";
import React from "react"; import React from "react";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage } from "react-intl";
import { ListActions, PageListProps, RelayToFlat } from "../../../types"; import { CategoryDeleteButton } from "../CategoryDeleteButton";
import CategoryProductList from "../CategoryProductList"; import { CategoryProductListDatagrid } from "../CategoryProductListDatagrid";
import { useStyles } from "./styles";
interface CategoryProductsProps extends PageListProps, ListActions { interface CategoryProductsProps {
products: RelayToFlat<CategoryDetailsQuery["category"]["products"]>; category: CategoryDetailsQuery["category"];
categoryName: string;
categoryId: string; categoryId: string;
products: RelayToFlat<CategoryDetailsQuery["category"]["products"]>;
disabled: boolean;
onProductsDelete: () => void;
onSelectProductsIds: (ids: number[], clearSelection: () => void) => void;
} }
export const CategoryProducts: React.FC<CategoryProductsProps> = ({ export const CategoryProducts = ({
category,
categoryId,
products, products,
disabled, disabled,
categoryId, onProductsDelete,
categoryName, onSelectProductsIds,
isChecked, }: CategoryProductsProps) => (
selected, <DashboardCard>
toggle, <DashboardCard.Title>
toggleAll, <Box display="flex" justifyContent="space-between" alignItems="center">
toolbar, <FormattedMessage
}) => { id="+43JV5"
const intl = useIntl(); defaultMessage="Products in {categoryName}"
const classes = useStyles(); description="header"
values={{ categoryName: category?.name }}
/>
return ( <Box display="flex" gap={4}>
<Card> <InternalLink
<CardTitle to={productListUrl({
title={intl.formatMessage( categories: [categoryId],
{ })}
id: "+43JV5", >
defaultMessage: "Products in {categoryName}", <Button variant="secondary" data-test-id="view-products">
description: "header", <FormattedMessage
}, id="z8jo8h"
{ categoryName }, defaultMessage="View products"
)} description="button"
toolbar={ />
<div className={classes.toolbar}> </Button>
<InternalLink </InternalLink>
to={productListUrl({
categories: [categoryId], <InternalLink to={productAddUrl()}>
})} <Button variant="secondary" data-test-id="add-products">
>
<Button variant="tertiary" data-test-id="view-products">
<FormattedMessage
id="z8jo8h"
defaultMessage="View products"
description="button"
/>
</Button>
</InternalLink>
<HorizontalSpacer />
<Button
variant="tertiary"
href={productAddUrl()}
data-test-id="add-products"
>
<FormattedMessage <FormattedMessage
id="x/pIZ9" id="x/pIZ9"
defaultMessage="Add product" defaultMessage="Add product"
description="button" description="button"
/> />
</Button> </Button>
</div> </InternalLink>
} </Box>
/> </Box>
<CategoryProductList </DashboardCard.Title>
products={products}
disabled={disabled}
selected={selected}
isChecked={isChecked}
toggle={toggle}
toggleAll={toggleAll}
toolbar={toolbar}
/>
</Card>
);
};
CategoryProducts.displayName = "CategoryProducts"; <CategoryProductListDatagrid
export default CategoryProducts; products={products}
disabled={disabled}
onSelectProductsIds={onSelectProductsIds}
selectionActionButton={
<Box paddingRight={5}>
<CategoryDeleteButton onClick={onProductsDelete}>
<FormattedMessage
defaultMessage="Bulk products delete"
id="cxOmce"
/>
</CategoryDeleteButton>
</Box>
}
/>
</DashboardCard>
);

View file

@ -1 +1 @@
export { default } from "./CategoryProducts"; export * from "./CategoryProducts";

View file

@ -1,10 +0,0 @@
import { makeStyles } from "@saleor/macaw-ui";
export const useStyles = makeStyles(
() => ({
toolbar: {
display: "flex",
},
}),
{ name: "CategoryProducts" },
);

View file

@ -0,0 +1,65 @@
// @ts-strict-ignore
import { categoryAddUrl } from "@dashboard/categories/urls";
import { DashboardCard } from "@dashboard/components/Card";
import { InternalLink } from "@dashboard/components/InternalLink";
import { CategoryDetailsQuery } from "@dashboard/graphql";
import { RelayToFlat } from "@dashboard/types";
import { Box, Button } from "@saleor/macaw-ui/next";
import React from "react";
import { FormattedMessage } from "react-intl";
import { CategoryDeleteButton } from "../CategoryDeleteButton";
import { CategoryListDatagrid } from "../CategoryListDatagrid";
interface CategorySubcategoriesProps {
categoryId: string;
disabled: boolean;
subcategories: RelayToFlat<CategoryDetailsQuery["category"]["children"]>;
onCategoriesDelete: () => void;
onSelectCategoriesIds: (ids: number[], clearSelection: () => void) => void;
}
export const CategorySubcategories = ({
categoryId,
subcategories,
disabled,
onCategoriesDelete,
onSelectCategoriesIds,
}: CategorySubcategoriesProps) => (
<DashboardCard>
<DashboardCard.Title>
<Box display="flex" justifyContent="space-between" alignItems="center">
<FormattedMessage
id="NivJal"
defaultMessage="All Subcategories"
description="section header"
/>
<InternalLink to={categoryAddUrl(categoryId)}>
<Button variant="secondary" data-test-id="create-subcategory">
<FormattedMessage
id="UycVMp"
defaultMessage="Create subcategory"
description="button"
/>
</Button>
</InternalLink>
</Box>
</DashboardCard.Title>
<CategoryListDatagrid
categories={subcategories}
disabled={disabled}
onSelectCategoriesIds={onSelectCategoriesIds}
selectionActionButton={
<Box paddingRight={5}>
<CategoryDeleteButton onClick={onCategoriesDelete}>
<FormattedMessage
defaultMessage="Bulk categories delete"
id="ZN5IZl"
/>
</CategoryDeleteButton>
</Box>
}
/>
</DashboardCard>
);

View file

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

View file

@ -26,11 +26,13 @@ const updateProps: Omit<CategoryUpdatePageProps, "classes"> = {
onImageDelete: () => undefined, onImageDelete: () => undefined,
onImageUpload: () => undefined, onImageUpload: () => undefined,
onSubmit: () => undefined, onSubmit: () => undefined,
productListToolbar: null,
products: mapEdgesToItems(category.products), products: mapEdgesToItems(category.products),
saveButtonBarState: "default", saveButtonBarState: "default",
subcategories: mapEdgesToItems(category.children), subcategories: mapEdgesToItems(category.children),
subcategoryListToolbar: null, onCategoriesDelete: () => undefined,
onProductsDelete: () => undefined,
onSelectCategoriesIds: () => undefined,
onSelectProductsIds: () => undefined,
...listActionsProps, ...listActionsProps,
}; };

View file

@ -1,13 +1,7 @@
// @ts-strict-ignore // @ts-strict-ignore
import { import { categoryListUrl, categoryUrl } from "@dashboard/categories/urls";
categoryAddUrl,
categoryListUrl,
categoryUrl,
} from "@dashboard/categories/urls";
import { TopNav } from "@dashboard/components/AppLayout/TopNav"; import { TopNav } from "@dashboard/components/AppLayout/TopNav";
import { Button } from "@dashboard/components/Button";
import { CardSpacer } from "@dashboard/components/CardSpacer"; import { CardSpacer } from "@dashboard/components/CardSpacer";
import CardTitle from "@dashboard/components/CardTitle";
import { ConfirmButtonTransitionState } from "@dashboard/components/ConfirmButton"; import { ConfirmButtonTransitionState } from "@dashboard/components/ConfirmButton";
import { DetailPageLayout } from "@dashboard/components/Layouts"; import { DetailPageLayout } from "@dashboard/components/Layouts";
import { Metadata } from "@dashboard/components/Metadata/Metadata"; import { Metadata } from "@dashboard/components/Metadata/Metadata";
@ -17,17 +11,16 @@ import { Tab, TabContainer } from "@dashboard/components/Tab";
import { CategoryDetailsQuery, ProductErrorFragment } from "@dashboard/graphql"; import { CategoryDetailsQuery, ProductErrorFragment } from "@dashboard/graphql";
import { SubmitPromise } from "@dashboard/hooks/useForm"; import { SubmitPromise } from "@dashboard/hooks/useForm";
import useNavigator from "@dashboard/hooks/useNavigator"; import useNavigator from "@dashboard/hooks/useNavigator";
import { Card } from "@material-ui/core";
import { sprinkles } from "@saleor/macaw-ui/next"; import { sprinkles } from "@saleor/macaw-ui/next";
import React from "react"; import React from "react";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import { maybe } from "../../../misc"; import { maybe } from "../../../misc";
import { RelayToFlat, TabListActions } from "../../../types"; import { RelayToFlat } from "../../../types";
import CategoryDetailsForm from "../../components/CategoryDetailsForm"; import CategoryDetailsForm from "../../components/CategoryDetailsForm";
import CategoryList from "../../components/CategoryList";
import CategoryBackground from "../CategoryBackground"; import CategoryBackground from "../CategoryBackground";
import CategoryProducts from "../CategoryProducts"; import { CategoryProducts } from "../CategoryProducts";
import { CategorySubcategories } from "../CategorySubcategories";
import CategoryUpdateForm, { CategoryUpdateData } from "./form"; import CategoryUpdateForm, { CategoryUpdateData } from "./form";
export enum CategoryPageTab { export enum CategoryPageTab {
@ -35,8 +28,7 @@ export enum CategoryPageTab {
products = "products", products = "products",
} }
export interface CategoryUpdatePageProps export interface CategoryUpdatePageProps {
extends TabListActions<"productListToolbar" | "subcategoryListToolbar"> {
categoryId: string; categoryId: string;
changeTab: (index: CategoryPageTab) => void; changeTab: (index: CategoryPageTab) => void;
currentTab: CategoryPageTab; currentTab: CategoryPageTab;
@ -49,6 +41,10 @@ export interface CategoryUpdatePageProps
addProductHref: string; addProductHref: string;
onImageDelete: () => void; onImageDelete: () => void;
onSubmit: (data: CategoryUpdateData) => SubmitPromise; onSubmit: (data: CategoryUpdateData) => SubmitPromise;
onCategoriesDelete: () => void;
onProductsDelete: () => void;
onSelectProductsIds: (ids: number[], clearSelection: () => void) => void;
onSelectCategoriesIds: (ids: number[], clearSelection: () => void) => void;
onImageUpload(file: File); onImageUpload(file: File);
onDelete(); onDelete();
} }
@ -70,12 +66,10 @@ export const CategoryUpdatePage: React.FC<CategoryUpdatePageProps> = ({
onSubmit, onSubmit,
onImageDelete, onImageDelete,
onImageUpload, onImageUpload,
isChecked, onSelectCategoriesIds,
productListToolbar, onCategoriesDelete,
selected, onProductsDelete,
subcategoryListToolbar, onSelectProductsIds,
toggle,
toggleAll,
}: CategoryUpdatePageProps) => { }: CategoryUpdatePageProps) => {
const intl = useIntl(); const intl = useIntl();
const navigate = useNavigator(); const navigate = useNavigator();
@ -100,7 +94,9 @@ export const CategoryUpdatePage: React.FC<CategoryUpdatePageProps> = ({
errors={errors} errors={errors}
onChange={change} onChange={change}
/> />
<CardSpacer /> <CardSpacer />
<CategoryBackground <CategoryBackground
data={data} data={data}
onImageUpload={onImageUpload} onImageUpload={onImageUpload}
@ -108,7 +104,9 @@ export const CategoryUpdatePage: React.FC<CategoryUpdatePageProps> = ({
image={maybe(() => category.backgroundImage)} image={maybe(() => category.backgroundImage)}
onChange={change} onChange={change}
/> />
<CardSpacer /> <CardSpacer />
<SeoForm <SeoForm
helperText={intl.formatMessage({ helperText={intl.formatMessage({
id: "wQdR8M", id: "wQdR8M",
@ -126,9 +124,13 @@ export const CategoryUpdatePage: React.FC<CategoryUpdatePageProps> = ({
onChange={change} onChange={change}
disabled={disabled} disabled={disabled}
/> />
<CardSpacer /> <CardSpacer />
<Metadata data={data} onChange={handlers.changeMetadata} /> <Metadata data={data} onChange={handlers.changeMetadata} />
<CardSpacer /> <CardSpacer />
<TabContainer className={sprinkles({ paddingX: 9 })}> <TabContainer className={sprinkles({ paddingX: 9 })}>
<CategoriesTab <CategoriesTab
isActive={currentTab === CategoryPageTab.categories} isActive={currentTab === CategoryPageTab.categories}
@ -140,6 +142,7 @@ export const CategoryUpdatePage: React.FC<CategoryUpdatePageProps> = ({
description="number of subcategories in category" description="number of subcategories in category"
/> />
</CategoriesTab> </CategoriesTab>
<ProductsTab <ProductsTab
testId="products-tab" testId="products-tab"
isActive={currentTab === CategoryPageTab.products} isActive={currentTab === CategoryPageTab.products}
@ -152,56 +155,30 @@ export const CategoryUpdatePage: React.FC<CategoryUpdatePageProps> = ({
/> />
</ProductsTab> </ProductsTab>
</TabContainer> </TabContainer>
<CardSpacer /> <CardSpacer />
{currentTab === CategoryPageTab.categories && ( {currentTab === CategoryPageTab.categories && (
<Card> <CategorySubcategories
<CardTitle
title={intl.formatMessage({
id: "NivJal",
defaultMessage: "All Subcategories",
description: "section header",
})}
toolbar={
<Button
variant="tertiary"
href={categoryAddUrl(categoryId)}
data-test-id="create-subcategory"
>
<FormattedMessage
id="UycVMp"
defaultMessage="Create subcategory"
description="button"
/>
</Button>
}
/>
<CategoryList
categories={subcategories}
disabled={disabled}
isChecked={isChecked}
isRoot={false}
selected={selected}
sort={undefined}
toggle={toggle}
toggleAll={toggleAll}
toolbar={subcategoryListToolbar}
onSort={() => undefined}
/>
</Card>
)}
{currentTab === CategoryPageTab.products && (
<CategoryProducts
categoryId={category?.id}
categoryName={category?.name}
products={products}
disabled={disabled} disabled={disabled}
toggle={toggle} subcategories={subcategories}
toggleAll={toggleAll} onCategoriesDelete={onCategoriesDelete}
selected={selected} onSelectCategoriesIds={onSelectCategoriesIds}
isChecked={isChecked} categoryId={categoryId}
toolbar={productListToolbar}
/> />
)} )}
{currentTab === CategoryPageTab.products && (
<CategoryProducts
category={category}
categoryId={categoryId}
products={products}
disabled={disabled}
onProductsDelete={onProductsDelete}
onSelectProductsIds={onSelectProductsIds}
/>
)}
<Savebar <Savebar
onCancel={() => navigate(backHref)} onCancel={() => navigate(backHref)}
onDelete={onDelete} onDelete={onDelete}

View file

@ -15,21 +15,21 @@ import {
useUpdateMetadataMutation, useUpdateMetadataMutation,
useUpdatePrivateMetadataMutation, useUpdatePrivateMetadataMutation,
} from "@dashboard/graphql"; } from "@dashboard/graphql";
import useBulkActions from "@dashboard/hooks/useBulkActions";
import useLocalPaginator, { import useLocalPaginator, {
useSectionLocalPaginationState, useSectionLocalPaginationState,
} from "@dashboard/hooks/useLocalPaginator"; } from "@dashboard/hooks/useLocalPaginator";
import useNavigator from "@dashboard/hooks/useNavigator"; import useNavigator from "@dashboard/hooks/useNavigator";
import useNotifier from "@dashboard/hooks/useNotifier"; import useNotifier from "@dashboard/hooks/useNotifier";
import { PaginatorContext } from "@dashboard/hooks/usePaginator"; import { PaginatorContext } from "@dashboard/hooks/usePaginator";
import { useRowSelection } from "@dashboard/hooks/useRowSelection";
import { commonMessages, errorMessages } from "@dashboard/intl"; import { commonMessages, errorMessages } from "@dashboard/intl";
import createDialogActionHandlers from "@dashboard/utils/handlers/dialogActionHandlers"; import createDialogActionHandlers from "@dashboard/utils/handlers/dialogActionHandlers";
import createMetadataUpdateHandler from "@dashboard/utils/handlers/metadataUpdateHandler"; import createMetadataUpdateHandler from "@dashboard/utils/handlers/metadataUpdateHandler";
import { mapEdgesToItems } from "@dashboard/utils/maps"; import { mapEdgesToItems } from "@dashboard/utils/maps";
import { getParsedDataForJsonStringField } from "@dashboard/utils/richText/misc"; import { getParsedDataForJsonStringField } from "@dashboard/utils/richText/misc";
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 React, { useState } from "react"; import React, { useCallback, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import { PAGINATE_BY } from "../../config"; import { PAGINATE_BY } from "../../config";
@ -64,13 +64,26 @@ export const CategoryDetails: React.FC<CategoryDetailsProps> = ({
}) => { }) => {
const navigate = useNavigator(); const navigate = useNavigator();
const notify = useNotifier(); const notify = useNotifier();
const { isSelected, listElements, reset, toggle, toggleAll } = useBulkActions(
params.ids,
);
const intl = useIntl(); const intl = useIntl();
const [updateMetadata] = useUpdateMetadataMutation({}); const [updateMetadata] = useUpdateMetadataMutation({});
const [updatePrivateMetadata] = useUpdatePrivateMetadataMutation({}); const [updatePrivateMetadata] = useUpdatePrivateMetadataMutation({});
const {
clearRowSelection: clearProductRowSelection,
selectedRowIds: selectedProductRowIds,
setClearDatagridRowSelectionCallback:
setClearProductDatagridRowSelectionCallback,
setSelectedRowIds: setSelectedProductRowIds,
} = useRowSelection();
const {
clearRowSelection: clearCategryRowSelection,
selectedRowIds: selectedCategoryRowIds,
setClearDatagridRowSelectionCallback:
setClearCategoryDatagridRowSelectionCallback,
setSelectedRowIds: setSelectedCategoryRowIds,
} = useRowSelection();
const [activeTab, setActiveTab] = useState<CategoryPageTab>( const [activeTab, setActiveTab] = useState<CategoryPageTab>(
CategoryPageTab.categories, CategoryPageTab.categories,
); );
@ -80,7 +93,8 @@ export const CategoryDetails: React.FC<CategoryDetailsProps> = ({
); );
const paginate = useLocalPaginator(setPaginationState); const paginate = useLocalPaginator(setPaginationState);
const changeTab = (tab: CategoryPageTab) => { const changeTab = (tab: CategoryPageTab) => {
reset(); clearProductRowSelection();
clearCategryRowSelection();
setActiveTab(tab); setActiveTab(tab);
}; };
@ -90,6 +104,8 @@ export const CategoryDetails: React.FC<CategoryDetailsProps> = ({
}); });
const category = data?.category; const category = data?.category;
const subcategories = mapEdgesToItems(data?.category?.children);
const products = mapEdgesToItems(data?.category?.products);
const handleCategoryDelete = (data: CategoryDeleteMutation) => { const handleCategoryDelete = (data: CategoryDeleteMutation) => {
if (data.categoryDelete.errors.length === 0) { if (data.categoryDelete.errors.length === 0) {
@ -100,6 +116,7 @@ export const CategoryDetails: React.FC<CategoryDetailsProps> = ({
defaultMessage: "Category deleted", defaultMessage: "Category deleted",
}), }),
}); });
clearProductRowSelection();
navigate(categoryListUrl()); navigate(categoryListUrl());
} }
}; };
@ -109,6 +126,7 @@ export const CategoryDetails: React.FC<CategoryDetailsProps> = ({
}); });
const handleCategoryUpdate = (data: CategoryUpdateMutation) => { const handleCategoryUpdate = (data: CategoryUpdateMutation) => {
clearProductRowSelection();
if (data.categoryUpdate.errors.length > 0) { if (data.categoryUpdate.errors.length > 0) {
const backgroundImageError = data.categoryUpdate.errors.find( const backgroundImageError = data.categoryUpdate.errors.find(
error => error.field === ("backgroundImage" as keyof CategoryInput), error => error.field === ("backgroundImage" as keyof CategoryInput),
@ -133,13 +151,13 @@ export const CategoryDetails: React.FC<CategoryDetailsProps> = ({
}); });
const handleBulkCategoryDelete = (data: CategoryBulkDeleteMutation) => { const handleBulkCategoryDelete = (data: CategoryBulkDeleteMutation) => {
clearCategryRowSelection();
if (data.categoryBulkDelete.errors.length === 0) { if (data.categoryBulkDelete.errors.length === 0) {
closeModal(); closeModal();
notify({ notify({
status: "success", status: "success",
text: intl.formatMessage(commonMessages.savedChanges), text: intl.formatMessage(commonMessages.savedChanges),
}); });
reset();
} }
}; };
@ -151,6 +169,7 @@ export const CategoryDetails: React.FC<CategoryDetailsProps> = ({
const [productBulkDelete, productBulkDeleteOpts] = const [productBulkDelete, productBulkDeleteOpts] =
useProductBulkDeleteMutation({ useProductBulkDeleteMutation({
onCompleted: data => { onCompleted: data => {
clearProductRowSelection();
if (data.productBulkDelete.errors.length === 0) { if (data.productBulkDelete.errors.length === 0) {
closeModal(); closeModal();
notify({ notify({
@ -158,7 +177,6 @@ export const CategoryDetails: React.FC<CategoryDetailsProps> = ({
text: intl.formatMessage(commonMessages.savedChanges), text: intl.formatMessage(commonMessages.savedChanges),
}); });
refetch(); refetch();
reset();
} }
}, },
}); });
@ -194,6 +212,52 @@ export const CategoryDetails: React.FC<CategoryDetailsProps> = ({
}), }),
); );
const handleSetSelectedCategoryIds = useCallback(
(rows: number[], clearSelection: () => void) => {
if (!subcategories) {
return;
}
const rowsIds = rows.map(row => subcategories[row].id);
const haveSaveValues = isEqual(rowsIds, selectedCategoryRowIds);
if (!haveSaveValues) {
setSelectedCategoryRowIds(rowsIds);
}
setClearCategoryDatagridRowSelectionCallback(clearSelection);
},
[
selectedCategoryRowIds,
setClearCategoryDatagridRowSelectionCallback,
setSelectedCategoryRowIds,
subcategories,
],
);
const handleSetSelectedPrductIds = useCallback(
(rows: number[], clearSelection: () => void) => {
if (!products) {
return;
}
const rowsIds = rows.map(row => products[row].id);
const haveSaveValues = isEqual(rowsIds, selectedProductRowIds);
if (!haveSaveValues) {
setSelectedProductRowIds(rowsIds);
}
setClearProductDatagridRowSelectionCallback(clearSelection);
},
[
products,
selectedProductRowIds,
setClearProductDatagridRowSelectionCallback,
setSelectedProductRowIds,
],
);
const handleSubmit = createMetadataUpdateHandler( const handleSubmit = createMetadataUpdateHandler(
data?.category, data?.category,
handleUpdate, handleUpdate,
@ -238,41 +302,19 @@ export const CategoryDetails: React.FC<CategoryDetailsProps> = ({
}) })
} }
onSubmit={handleSubmit} onSubmit={handleSubmit}
products={mapEdgesToItems(data?.category?.products)} products={products}
saveButtonBarState={updateResult.status} saveButtonBarState={updateResult.status}
subcategories={mapEdgesToItems(data?.category?.children)} subcategories={subcategories}
subcategoryListToolbar={ onSelectCategoriesIds={handleSetSelectedCategoryIds}
<IconButton onSelectProductsIds={handleSetSelectedPrductIds}
data-test-id="delete-icon" onCategoriesDelete={() => {
variant="secondary" openModal("delete-categories");
color="primary" }}
onClick={() => onProductsDelete={() => {
openModal("delete-categories", { openModal("delete-products");
ids: listElements, }}
})
}
>
<DeleteIcon />
</IconButton>
}
productListToolbar={
<IconButton
data-test-id="delete-icon"
color="primary"
onClick={() =>
openModal("delete-products", {
ids: listElements,
})
}
>
<DeleteIcon />
</IconButton>
}
isChecked={isSelected}
selected={listElements.length}
toggle={toggle}
toggleAll={toggleAll}
/> />
<ActionDialog <ActionDialog
confirmButtonState={deleteResult.status} confirmButtonState={deleteResult.status}
onClose={closeModal} onClose={closeModal}
@ -303,16 +345,14 @@ export const CategoryDetails: React.FC<CategoryDetailsProps> = ({
/> />
</DialogContentText> </DialogContentText>
</ActionDialog> </ActionDialog>
<ActionDialog <ActionDialog
open={ open={params.action === "delete-categories"}
params.action === "delete-categories" &&
maybe(() => params.ids.length > 0)
}
confirmButtonState={categoryBulkDeleteOpts.status} confirmButtonState={categoryBulkDeleteOpts.status}
onClose={closeModal} onClose={closeModal}
onConfirm={() => onConfirm={() =>
categoryBulkDelete({ categoryBulkDelete({
variables: { ids: params.ids }, variables: { ids: selectedCategoryRowIds },
}).then(() => refetch()) }).then(() => refetch())
} }
title={intl.formatMessage({ title={intl.formatMessage({
@ -327,9 +367,9 @@ export const CategoryDetails: React.FC<CategoryDetailsProps> = ({
id="Pp/7T7" id="Pp/7T7"
defaultMessage="{counter,plural,one{Are you sure you want to delete this category?} other{Are you sure you want to delete {displayQuantity} categories?}}" defaultMessage="{counter,plural,one{Are you sure you want to delete this category?} other{Are you sure you want to delete {displayQuantity} categories?}}"
values={{ values={{
counter: maybe(() => params.ids.length), counter: maybe(() => selectedCategoryRowIds.length),
displayQuantity: ( displayQuantity: (
<strong>{maybe(() => params.ids.length)}</strong> <strong>{maybe(() => selectedCategoryRowIds.length)}</strong>
), ),
}} }}
/> />
@ -341,13 +381,14 @@ export const CategoryDetails: React.FC<CategoryDetailsProps> = ({
/> />
</DialogContentText> </DialogContentText>
</ActionDialog> </ActionDialog>
<ActionDialog <ActionDialog
open={params.action === "delete-products"} open={params.action === "delete-products"}
confirmButtonState={productBulkDeleteOpts.status} confirmButtonState={productBulkDeleteOpts.status}
onClose={closeModal} onClose={closeModal}
onConfirm={() => onConfirm={() =>
productBulkDelete({ productBulkDelete({
variables: { ids: params.ids }, variables: { ids: selectedProductRowIds },
}).then(() => refetch()) }).then(() => refetch())
} }
title={intl.formatMessage({ title={intl.formatMessage({
@ -362,9 +403,9 @@ export const CategoryDetails: React.FC<CategoryDetailsProps> = ({
id="7l5Bh9" id="7l5Bh9"
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?}}"
values={{ values={{
counter: maybe(() => params.ids.length), counter: maybe(() => selectedProductRowIds.length),
displayQuantity: ( displayQuantity: (
<strong>{maybe(() => params.ids.length)}</strong> <strong>{maybe(() => selectedProductRowIds.length)}</strong>
), ),
}} }}
/> />

View file

@ -1,15 +1,13 @@
// @ts-strict-ignore // @ts-strict-ignore
import ActionDialog from "@dashboard/components/ActionDialog"; import ActionDialog from "@dashboard/components/ActionDialog";
import DeleteFilterTabDialog from "@dashboard/components/DeleteFilterTabDialog"; import DeleteFilterTabDialog from "@dashboard/components/DeleteFilterTabDialog";
import SaveFilterTabDialog, { import SaveFilterTabDialog from "@dashboard/components/SaveFilterTabDialog";
SaveFilterTabDialogFormData,
} from "@dashboard/components/SaveFilterTabDialog";
import { import {
CategoryBulkDeleteMutation, CategoryBulkDeleteMutation,
useCategoryBulkDeleteMutation, useCategoryBulkDeleteMutation,
useRootCategoriesQuery, useRootCategoriesQuery,
} from "@dashboard/graphql"; } from "@dashboard/graphql";
import useBulkActions from "@dashboard/hooks/useBulkActions"; import { useFilterPresets } from "@dashboard/hooks/useFilterPresets";
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 { usePaginationReset } from "@dashboard/hooks/usePaginationReset"; import { usePaginationReset } from "@dashboard/hooks/usePaginationReset";
@ -17,6 +15,7 @@ import usePaginator, {
createPaginationState, createPaginationState,
PaginatorContext, PaginatorContext,
} from "@dashboard/hooks/usePaginator"; } from "@dashboard/hooks/usePaginator";
import { useRowSelection } from "@dashboard/hooks/useRowSelection";
import { maybe } from "@dashboard/misc"; import { maybe } from "@dashboard/misc";
import { ListViews } from "@dashboard/types"; import { ListViews } from "@dashboard/types";
import createDialogActionHandlers from "@dashboard/utils/handlers/dialogActionHandlers"; import createDialogActionHandlers from "@dashboard/utils/handlers/dialogActionHandlers";
@ -24,8 +23,8 @@ import createSortHandler from "@dashboard/utils/handlers/sortHandler";
import { mapEdgesToItems } from "@dashboard/utils/maps"; import { mapEdgesToItems } from "@dashboard/utils/maps";
import { getSortParams } from "@dashboard/utils/sort"; import { getSortParams } 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 React from "react"; import React, { useCallback } from "react";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import { CategoryListPage } from "../../components/CategoryListPage/CategoryListPage"; import { CategoryListPage } from "../../components/CategoryListPage/CategoryListPage";
@ -35,14 +34,7 @@ import {
CategoryListUrlFilters, CategoryListUrlFilters,
CategoryListUrlQueryParams, CategoryListUrlQueryParams,
} from "../../urls"; } from "../../urls";
import { import { getActiveFilters, getFilterVariables, storageUtils } from "./filter";
deleteFilterTab,
getActiveFilters,
getFiltersCurrentTab,
getFilterTabs,
getFilterVariables,
saveFilterTab,
} from "./filter";
import { getSortQueryVariables } from "./sort"; import { getSortQueryVariables } from "./sort";
interface CategoryListProps { interface CategoryListProps {
@ -51,18 +43,44 @@ interface CategoryListProps {
export const CategoryList: React.FC<CategoryListProps> = ({ params }) => { export const CategoryList: React.FC<CategoryListProps> = ({ params }) => {
const navigate = useNavigator(); const navigate = useNavigator();
const intl = useIntl();
const { isSelected, listElements, toggle, toggleAll, reset } = useBulkActions(
params.ids,
);
const { updateListSettings, settings } = useListSettings( const { updateListSettings, settings } = useListSettings(
ListViews.CATEGORY_LIST, ListViews.CATEGORY_LIST,
); );
const handleSort = createSortHandler(navigate, categoryListUrl, params);
const {
selectedRowIds,
setSelectedRowIds,
clearRowSelection,
setClearDatagridRowSelectionCallback,
} = useRowSelection(params);
const {
hasPresetsChange,
onPresetChange,
onPresetDelete,
onPresetSave,
onPresetUpdate,
presetIdToDelete,
presets,
selectedPreset,
setPresetIdToDelete,
} = useFilterPresets({
params,
storageUtils,
getUrl: categoryListUrl,
reset: clearRowSelection,
});
const [openModal, closeModal] = createDialogActionHandlers<
CategoryListUrlDialog,
CategoryListUrlQueryParams
>(navigate, categoryListUrl, params);
usePaginationReset(categoryListUrl, params, settings.rowNumber); usePaginationReset(categoryListUrl, params, settings.rowNumber);
const intl = useIntl();
const paginationState = createPaginationState(settings.rowNumber, params); const paginationState = createPaginationState(settings.rowNumber, params);
const queryVariables = React.useMemo( const queryVariables = React.useMemo(
() => ({ () => ({
@ -70,111 +88,98 @@ export const CategoryList: React.FC<CategoryListProps> = ({ params }) => {
filter: getFilterVariables(params), filter: getFilterVariables(params),
sort: getSortQueryVariables(params), sort: getSortQueryVariables(params),
}), }),
[params, settings.rowNumber], [paginationState, params],
); );
const { data, loading, refetch } = useRootCategoriesQuery({ const { data, loading, refetch } = useRootCategoriesQuery({
displayLoader: true, displayLoader: true,
variables: queryVariables, variables: queryVariables,
}); });
const categories = mapEdgesToItems(data?.categories);
const tabs = getFilterTabs(); const paginationValues = usePaginator({
pageInfo: data?.categories?.pageInfo,
const currentTab = getFiltersCurrentTab(params, tabs); paginationState,
queryString: params,
});
const changeFilterField = (filter: CategoryListUrlFilters) => { const changeFilterField = (filter: CategoryListUrlFilters) => {
reset(); clearRowSelection();
navigate( navigate(
categoryListUrl({ categoryListUrl({
...getActiveFilters(params), ...getActiveFilters(params),
...filter, ...filter,
activeTab: undefined, activeTab: !filter.query?.length ? undefined : params.activeTab,
}), }),
); );
}; };
const [openModal, closeModal] = createDialogActionHandlers<
CategoryListUrlDialog,
CategoryListUrlQueryParams
>(navigate, categoryListUrl, params);
const handleTabChange = (tab: number) => {
reset();
navigate(
categoryListUrl({
activeTab: tab.toString(),
...getFilterTabs()[tab - 1].data,
}),
);
};
const handleTabDelete = () => {
deleteFilterTab(currentTab);
reset();
navigate(categoryListUrl());
};
const handleTabSave = (data: SaveFilterTabDialogFormData) => {
saveFilterTab(data.name, getActiveFilters(params));
handleTabChange(tabs.length + 1);
};
const paginationValues = usePaginator({
pageInfo: maybe(() => data.categories.pageInfo),
paginationState,
queryString: params,
});
const handleCategoryBulkDelete = (data: CategoryBulkDeleteMutation) => { const handleCategoryBulkDelete = (data: CategoryBulkDeleteMutation) => {
if (data.categoryBulkDelete.errors.length === 0) { if (data.categoryBulkDelete.errors.length === 0) {
navigate(categoryListUrl(), { replace: true }); navigate(categoryListUrl(), { replace: true });
refetch(); refetch();
reset(); clearRowSelection();
} }
}; };
const handleSetSelectedCategoryIds = useCallback(
(rows: number[], clearSelection: () => void) => {
if (!categories) {
return;
}
const rowsIds = rows.map(row => categories[row].id);
const haveSaveValues = isEqual(rowsIds, selectedRowIds);
if (!haveSaveValues) {
setSelectedRowIds(rowsIds);
}
setClearDatagridRowSelectionCallback(clearSelection);
},
[
categories,
setClearDatagridRowSelectionCallback,
selectedRowIds,
setSelectedRowIds,
],
);
const [categoryBulkDelete, categoryBulkDeleteOpts] = const [categoryBulkDelete, categoryBulkDeleteOpts] =
useCategoryBulkDeleteMutation({ useCategoryBulkDeleteMutation({
onCompleted: handleCategoryBulkDelete, onCompleted: handleCategoryBulkDelete,
}); });
const handleSort = createSortHandler(navigate, categoryListUrl, params);
return ( return (
<PaginatorContext.Provider value={paginationValues}> <PaginatorContext.Provider value={paginationValues}>
<CategoryListPage <CategoryListPage
hasPresetsChanged={hasPresetsChange()}
categories={mapEdgesToItems(data?.categories)} categories={mapEdgesToItems(data?.categories)}
currentTab={currentTab} currentTab={selectedPreset}
initialSearch={params.query || ""} initialSearch={params.query || ""}
onSearchChange={query => changeFilterField({ query })} onSearchChange={query => changeFilterField({ query })}
onAll={() => navigate(categoryListUrl())} onAll={() => navigate(categoryListUrl())}
onTabChange={handleTabChange} onTabChange={onPresetChange}
onTabDelete={() => openModal("delete-search")} onTabDelete={(tabIndex: number) => {
setPresetIdToDelete(tabIndex);
openModal("delete-search");
}}
onTabUpdate={onPresetUpdate}
onTabSave={() => openModal("save-search")} onTabSave={() => openModal("save-search")}
tabs={tabs.map(tab => tab.name)} tabs={presets.map(tab => tab.name)}
settings={settings} settings={settings}
sort={getSortParams(params)} sort={getSortParams(params)}
onSort={handleSort} onSort={handleSort}
disabled={loading} disabled={loading}
onUpdateListSettings={updateListSettings} onUpdateListSettings={(...props) => {
isChecked={isSelected} clearRowSelection();
selected={listElements.length} updateListSettings(...props);
toggle={toggle} }}
toggleAll={toggleAll} selectedCategoriesIds={selectedRowIds}
toolbar={ onSelectCategoriesIds={handleSetSelectedCategoryIds}
<IconButton onCategoriesDelete={() => openModal("delete")}
variant="secondary"
color="primary"
data-test-id="delete-icon"
onClick={() =>
openModal("delete", {
ids: listElements,
})
}
>
<DeleteIcon />
</IconButton>
}
/> />
<ActionDialog <ActionDialog
confirmButtonState={categoryBulkDeleteOpts.status} confirmButtonState={categoryBulkDeleteOpts.status}
onClose={() => onClose={() =>
@ -189,7 +194,7 @@ export const CategoryList: React.FC<CategoryListProps> = ({ params }) => {
onConfirm={() => onConfirm={() =>
categoryBulkDelete({ categoryBulkDelete({
variables: { variables: {
ids: params.ids, ids: selectedRowIds,
}, },
}) })
} }
@ -220,18 +225,20 @@ export const CategoryList: React.FC<CategoryListProps> = ({ params }) => {
/> />
</DialogContentText> </DialogContentText>
</ActionDialog> </ActionDialog>
<SaveFilterTabDialog <SaveFilterTabDialog
open={params.action === "save-search"} open={params.action === "save-search"}
confirmButtonState="default" confirmButtonState="default"
onClose={closeModal} onClose={closeModal}
onSubmit={handleTabSave} onSubmit={onPresetSave}
/> />
<DeleteFilterTabDialog <DeleteFilterTabDialog
open={params.action === "delete-search"} open={params.action === "delete-search"}
confirmButtonState="default" confirmButtonState="default"
onClose={closeModal} onClose={closeModal}
onSubmit={handleTabDelete} onSubmit={onPresetDelete}
tabName={maybe(() => tabs[currentTab - 1].name, "...")} tabName={presets[presetIdToDelete - 1]?.name ?? "..."}
/> />
</PaginatorContext.Provider> </PaginatorContext.Provider>
); );

View file

@ -20,16 +20,9 @@ export function getFilterVariables(
}; };
} }
export const { export const storageUtils = createFilterTabUtils<string>(CATEGORY_FILTERS_KEY);
deleteFilterTab,
getFilterTabs,
saveFilterTab,
} = createFilterTabUtils<CategoryListUrlFilters>(CATEGORY_FILTERS_KEY);
export const { export const { areFiltersApplied, getActiveFilters, getFiltersCurrentTab } =
areFiltersApplied, createFilterUtils<CategoryListUrlQueryParams, CategoryListUrlFilters>(
getActiveFilters, CategoryListUrlFiltersEnum,
getFiltersCurrentTab, );
} = createFilterUtils<CategoryListUrlQueryParams, CategoryListUrlFilters>(
CategoryListUrlFiltersEnum,
);

View file

@ -14,6 +14,7 @@ import DataEditor, {
GridSelection, GridSelection,
HeaderClickedEventArgs, HeaderClickedEventArgs,
Item, Item,
Theme,
} from "@glideapps/glide-data-grid"; } from "@glideapps/glide-data-grid";
import { GetRowThemeCallback } from "@glideapps/glide-data-grid/dist/ts/data-grid/data-grid-render"; import { GetRowThemeCallback } from "@glideapps/glide-data-grid/dist/ts/data-grid/data-grid-render";
import { Card, CardContent, CircularProgress } from "@material-ui/core"; import { Card, CardContent, CircularProgress } from "@material-ui/core";
@ -104,6 +105,7 @@ export interface DatagridProps {
columnSelect?: DataEditorProps["columnSelect"]; columnSelect?: DataEditorProps["columnSelect"];
showEmptyDatagrid?: boolean; showEmptyDatagrid?: boolean;
rowAnchor?: (item: Item) => string; rowAnchor?: (item: Item) => string;
actionButtonPosition?: "left" | "right";
recentlyAddedColumn?: string; // Enables scroll to recently added column recentlyAddedColumn?: string; // Enables scroll to recently added column
} }
@ -135,10 +137,11 @@ export const Datagrid: React.FC<DatagridProps> = ({
rowAnchor, rowAnchor,
hasRowHover = false, hasRowHover = false,
onRowSelectionChange, onRowSelectionChange,
actionButtonPosition = "left",
recentlyAddedColumn, recentlyAddedColumn,
...datagridProps ...datagridProps
}): ReactElement => { }): ReactElement => {
const classes = useStyles(); const classes = useStyles({ actionButtonPosition });
const { themeValues } = useTheme(); const { themeValues } = useTheme();
const datagridTheme = useDatagridTheme(readonly, readonly); const datagridTheme = useDatagridTheme(readonly, readonly);
const editor = useRef<DataEditorRef | null>(null); const editor = useRef<DataEditorRef | null>(null);
@ -266,7 +269,9 @@ export const Datagrid: React.FC<DatagridProps> = ({
const handleRowHover = useCallback( const handleRowHover = useCallback(
(args: GridMouseEventArgs) => { (args: GridMouseEventArgs) => {
if (hasRowHover) { if (hasRowHover) {
setHoverRow(args.kind !== "cell" ? undefined : args.location[1]); // eslint-disable-next-line @typescript-eslint/no-unused-vars
const [_, row] = args.location;
setHoverRow(args.kind !== "cell" ? undefined : row);
} }
// the code below is responsible for adding native <a> element when hovering over rows in the datagrid // the code below is responsible for adding native <a> element when hovering over rows in the datagrid
@ -330,12 +335,11 @@ export const Datagrid: React.FC<DatagridProps> = ({
return undefined; return undefined;
} }
const overrideTheme = { const overrideTheme: Partial<Theme> = {
bgCell: bgCell:
themeValues.colors.background.interactiveNeutralSecondaryHovering, themeValues.colors.background.interactiveNeutralSecondaryHovering,
bgCellMedium: bgCellMedium:
themeValues.colors.background.interactiveNeutralSecondaryHovering, themeValues.colors.background.interactiveNeutralSecondaryHovering,
accentLight: undefined as string | undefined,
}; };
if (readonly) { if (readonly) {
@ -476,11 +480,13 @@ export const Datagrid: React.FC<DatagridProps> = ({
<CardContent classes={{ root: classes.cardContentRoot }}> <CardContent classes={{ root: classes.cardContentRoot }}>
{rowsTotal > 0 || showEmptyDatagrid ? ( {rowsTotal > 0 || showEmptyDatagrid ? (
<> <>
{selection?.rows && selection?.rows.length > 0 && ( {selection?.rows &&
<div className={classes.actionBtnBar}> selection?.rows.length > 0 &&
{selectionActionsComponent} selectionActionsComponent && (
</div> <div className={classes.actionBtnBar}>
)} {selectionActionsComponent}
</div>
)}
<div className={classes.editorContainer}> <div className={classes.editorContainer}>
<Box <Box
backgroundColor="plain" backgroundColor="plain"

View file

@ -10,7 +10,7 @@ interface RowActionsProps {
} }
export const RowActions = ({ menuItems, disabled }: RowActionsProps) => { export const RowActions = ({ menuItems, disabled }: RowActionsProps) => {
const classes = useStyles(); const classes = useStyles({});
const hasSingleMenuItem = menuItems.length === 1; const hasSingleMenuItem = menuItems.length === 1;
const firstMenuItem = menuItems[0]; const firstMenuItem = menuItems[0];

View file

@ -5,7 +5,7 @@ import { useMemo } from "react";
export const cellHeight = 40; export const cellHeight = 40;
const useStyles = makeStyles( const useStyles = makeStyles<{ actionButtonPosition?: "left" | "right" }>(
() => { () => {
const rowActionSelected = { const rowActionSelected = {
background: vars.colors.background.plain, background: vars.colors.background.plain,
@ -16,6 +16,8 @@ const useStyles = makeStyles(
return { return {
actionBtnBar: { actionBtnBar: {
position: "absolute", position: "absolute",
left: props => (props.actionButtonPosition === "left" ? 0 : "auto"),
right: props => (props.actionButtonPosition === "right" ? 0 : "auto"),
zIndex: 1, zIndex: 1,
background: vars.colors.background.plain, background: vars.colors.background.plain,
borderRadius: vars.borderRadius[4], borderRadius: vars.borderRadius[4],

View file

@ -48,6 +48,7 @@ export const FilterPresetsSelect = ({
const intl = useIntl(); const intl = useIntl();
const showUpdateButton = const showUpdateButton =
presetsChanged && savedPresets.length > 0 && activePreset; presetsChanged && savedPresets.length > 0 && activePreset;
const showSaveButton = presetsChanged; const showSaveButton = presetsChanged;
const getLabel = () => { const getLabel = () => {