Customer list datagrid (#3940)

* Add default columns to customers list view

* Customer list datagrid implementation

* Fix types

* Remove old code

* Migrate some of the files to strict null checks

* Extract messages

* Add changeset

* Drop Tabs

* Fix filter presets typo

* Reuse bulk delete button

* Fix crashing presets

* Enlarge columns

* Extract to a separat function

* Fix race condition

* Fix type post-merge
This commit is contained in:
Michał Droń 2023-07-24 09:49:13 +02:00 committed by GitHub
parent 2491055292
commit a333adbb43
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 563 additions and 423 deletions

View file

@ -0,0 +1,5 @@
---
"saleor-dashboard": minor
---
Introduce datagrid on customer list view

View file

@ -691,9 +691,6 @@
"context": "section header button",
"string": "Manage"
},
"2mRLis": {
"string": "Search Customer"
},
"2ob30/": {
"string": "Success! In a few minutes youll receive a message with instructions on how to reset your password."
},
@ -1474,6 +1471,10 @@
"context": "button",
"string": "Back to homepage"
},
"945a4a": {
"context": "column header",
"string": "Customer e-mail"
},
"94oZR0": {
"context": "deactivate app billing info",
"string": "You will be still billed for the app."
@ -1482,9 +1483,6 @@
"context": "button",
"string": "Go back to dashboard"
},
"97l2MO": {
"string": "Customer Email"
},
"98Nw4g": {
"context": "card subtitle",
"string": "Rendered prices"
@ -2053,6 +2051,10 @@
"context": "no warehouses info",
"string": "There are no warehouses set up for your store. To add stock quantity to the variant please <a>configure a warehouse</a>"
},
"D95l71": {
"context": "tab name",
"string": "All customers"
},
"D9Rg+F": {
"context": "window title",
"string": "Channel details"
@ -2173,9 +2175,6 @@
"E8T3e+": {
"string": "Cannot add and remove group the same time"
},
"E8VDeH": {
"string": "No. of Orders"
},
"E9Dz18": {
"context": "Order summary refunds header",
"string": "Refunds"
@ -2609,9 +2608,6 @@
"context": "shipping method description",
"string": "Description"
},
"Gr1SAu": {
"string": "Customer Name"
},
"GsBRWL": {
"string": "Languages"
},
@ -3371,6 +3367,10 @@
"MSItJD": {
"string": "You are about to leave the Dashboard. Do you want to continue?"
},
"MTGT8E": {
"context": "column header",
"string": "No. of orders"
},
"MTl5o6": {
"context": "new discount label",
"string": "New discount value"
@ -6631,6 +6631,9 @@
"kFkPWB": {
"string": "Number"
},
"kFsTMN": {
"string": "Delete customers"
},
"kIvvax": {
"string": "Search Products..."
},
@ -6709,6 +6712,9 @@
"context": "balance amound missing error message",
"string": "Balance amount is missing"
},
"kdRcqU": {
"string": "Search customers..."
},
"kgVqk1": {
"string": "Category name"
},
@ -7094,6 +7100,10 @@
"context": "window title",
"string": "Create customer"
},
"nZDQbr": {
"context": "column header",
"string": "Customer name"
},
"nayZY0": {
"context": "returned event title",
"string": "Products were returned by"
@ -8392,10 +8402,6 @@
"context": "title",
"string": "Details"
},
"xQK2EC": {
"context": "tab name",
"string": "All Customers"
},
"xRbqcg": {
"context": "option",
"string": "Regular product type"

View file

@ -59,7 +59,7 @@ export const CategoryList: React.FC<CategoryListProps> = ({ params }) => {
} = useRowSelection(params);
const {
hasPresetsChange,
hasPresetsChanged,
onPresetChange,
onPresetDelete,
onPresetSave,
@ -164,7 +164,7 @@ export const CategoryList: React.FC<CategoryListProps> = ({ params }) => {
return (
<PaginatorContext.Provider value={paginationValues}>
<CategoryListPage
hasPresetsChanged={hasPresetsChange()}
hasPresetsChanged={hasPresetsChanged()}
categories={mapEdgesToItems(data?.categories)}
currentTab={selectedPreset}
initialSearch={params.query || ""}

View file

@ -87,7 +87,7 @@ export const CollectionList: React.FC<CollectionListProps> = ({ params }) => {
const {
selectedPreset,
presets,
hasPresetsChange,
hasPresetsChanged,
onPresetChange,
onPresetDelete,
onPresetSave,
@ -219,7 +219,7 @@ export const CollectionList: React.FC<CollectionListProps> = ({ params }) => {
onFilterChange={changeFilters}
selectedCollectionIds={selectedRowIds}
onSelectCollectionIds={handleSetSelectedCollectionIds}
hasPresetsChanged={hasPresetsChange}
hasPresetsChanged={hasPresetsChanged}
onCollectionsDelete={() =>
openModal("remove", {
ids: selectedRowIds,

View file

@ -14,7 +14,7 @@ const useStyles = makeStyles(
interface FilterTabsProps {
children?: React.ReactNode;
currentTab: number;
currentTab: number | undefined;
}
export const FilterTabs: React.FC<FilterTabsProps> = props => {

View file

@ -1,4 +1,3 @@
// @ts-strict-ignore
import packageInfo from "../package.json";
import { SearchVariables } from "./hooks/makeSearch";
import { ListSettings, ListViews, Pagination } from "./types";
@ -7,7 +6,7 @@ export const getAppDefaultUri = () => "/";
export const getAppMountUri = () =>
window?.__SALEOR_CONFIG__?.APP_MOUNT_URI || getAppDefaultUri();
export const getApiUrl = () => window.__SALEOR_CONFIG__.API_URL;
export const SW_INTERVAL = parseInt(process.env.SW_INTERVAL, 10) || 300;
export const SW_INTERVAL = parseInt(process.env.SW_INTERVAL ?? "300", 10);
export const IS_CLOUD_INSTANCE =
window.__SALEOR_CONFIG__.IS_CLOUD_INSTANCE === "true";
@ -80,6 +79,7 @@ export const defaultListSettings: AppListViewSettings = {
},
[ListViews.CUSTOMER_LIST]: {
rowNumber: PAGINATE_BY,
columns: ["name", "email", "orders"],
},
[ListViews.DRAFT_LIST]: {
rowNumber: PAGINATE_BY,

View file

@ -1,199 +0,0 @@
// @ts-strict-ignore
import { useUserPermissions } from "@dashboard/auth/hooks/useUserPermissions";
import Checkbox from "@dashboard/components/Checkbox";
import RequirePermissions, {
hasPermissions,
} from "@dashboard/components/RequirePermissions";
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 {
CustomerListUrlSortField,
customerUrl,
} from "@dashboard/customers/urls";
import { ListCustomersQuery, PermissionEnum } from "@dashboard/graphql";
import { getUserName, renderCollection } from "@dashboard/misc";
import {
ListActions,
ListProps,
RelayToFlat,
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")]: {
colEmail: {},
colName: {},
colOrders: {
width: 200,
},
},
colEmail: {},
colName: {
paddingLeft: 0,
},
colOrders: {
textAlign: "center",
},
tableRow: {
cursor: "pointer",
},
}),
{ name: "CustomerList" },
);
export interface CustomerListProps
extends ListProps,
ListActions,
SortPage<CustomerListUrlSortField> {
customers: RelayToFlat<ListCustomersQuery["customers"]>;
}
const CustomerList: React.FC<CustomerListProps> = props => {
const {
settings,
disabled,
customers,
onUpdateListSettings,
onSort,
toolbar,
toggle,
toggleAll,
selected,
sort,
isChecked,
} = props;
const userPermissions = useUserPermissions();
const numberOfColumns = hasPermissions(userPermissions, [
PermissionEnum.MANAGE_ORDERS,
])
? 4
: 3;
const classes = useStyles(props);
return (
<ResponsiveTable>
<TableHead
colSpan={numberOfColumns}
selected={selected}
disabled={disabled}
items={customers}
toggleAll={toggleAll}
toolbar={toolbar}
>
<TableCellHeader
direction={
sort.sort === CustomerListUrlSortField.name
? getArrowDirection(sort.asc)
: undefined
}
arrowPosition="right"
onClick={() => onSort(CustomerListUrlSortField.name)}
className={classes.colName}
>
<FormattedMessage id="Gr1SAu" defaultMessage="Customer Name" />
</TableCellHeader>
<TableCellHeader
direction={
sort.sort === CustomerListUrlSortField.email
? getArrowDirection(sort.asc)
: undefined
}
onClick={() => onSort(CustomerListUrlSortField.email)}
className={classes.colEmail}
>
<FormattedMessage id="97l2MO" defaultMessage="Customer Email" />
</TableCellHeader>
<RequirePermissions
requiredPermissions={[PermissionEnum.MANAGE_ORDERS]}
>
<TableCellHeader
direction={
sort.sort === CustomerListUrlSortField.orders
? getArrowDirection(sort.asc)
: undefined
}
textAlign="center"
onClick={() => onSort(CustomerListUrlSortField.orders)}
className={classes.colOrders}
>
<FormattedMessage id="E8VDeH" defaultMessage="No. of Orders" />
</TableCellHeader>
</RequirePermissions>
</TableHead>
<TableFooter>
<TableRowLink>
<TablePaginationWithContext
colSpan={numberOfColumns}
settings={settings}
onUpdateListSettings={onUpdateListSettings}
/>
</TableRowLink>
</TableFooter>
<TableBody>
{renderCollection(
customers,
customer => {
const isSelected = customer ? isChecked(customer.id) : false;
return (
<TableRowLink
className={!!customer ? classes.tableRow : undefined}
hover={!!customer}
key={customer ? customer.id : "skeleton"}
selected={isSelected}
href={customer && customerUrl(customer.id)}
>
<TableCell padding="checkbox">
<Checkbox
checked={isSelected}
disabled={disabled}
disableClickPropagation
onChange={() => toggle(customer.id)}
/>
</TableCell>
<TableCell className={classes.colName}>
{getUserName(customer)}
</TableCell>
<TableCell className={classes.colEmail}>
{customer?.email ?? <Skeleton />}
</TableCell>
<RequirePermissions
requiredPermissions={[PermissionEnum.MANAGE_ORDERS]}
>
<TableCell className={classes.colOrders}>
{customer?.orders?.totalCount ?? <Skeleton />}
</TableCell>
</RequirePermissions>
</TableRowLink>
);
},
() => (
<TableRowLink>
<TableCell colSpan={numberOfColumns}>
<FormattedMessage
id="FpIcp9"
defaultMessage="No customers found"
/>
</TableCell>
</TableRowLink>
),
)}
</TableBody>
</ResponsiveTable>
);
};
CustomerList.displayName = "CustomerList";
export default CustomerList;

View file

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

View file

@ -0,0 +1,168 @@
import { useUserPermissions } from "@dashboard/auth/hooks/useUserPermissions";
import { ColumnPicker } from "@dashboard/components/Datagrid/ColumnPicker/ColumnPicker";
import { useColumns } from "@dashboard/components/Datagrid/ColumnPicker/useColumns";
import Datagrid from "@dashboard/components/Datagrid/Datagrid";
import {
DatagridChangeStateContext,
useDatagridChangeState,
} from "@dashboard/components/Datagrid/hooks/useDatagridChange";
import { TablePaginationWithContext } from "@dashboard/components/TablePagination";
import { Customer, Customers } from "@dashboard/customers/types";
import { CustomerListUrlSortField } from "@dashboard/customers/urls";
import { PermissionEnum } from "@dashboard/graphql";
import { ListProps, SortPage } from "@dashboard/types";
import { Item } from "@glideapps/glide-data-grid";
import { Box } from "@saleor/macaw-ui/next";
import React, { useCallback, useMemo } from "react";
import { useIntl } from "react-intl";
import {
createGetCellContent,
customerListStaticColumnsAdapter,
} from "./datagrid";
import { messages } from "./messages";
interface CustomerListDatagridProps
extends ListProps,
SortPage<CustomerListUrlSortField> {
customers: Customers | undefined;
loading: boolean;
hasRowHover?: boolean;
onSelectCustomerIds: (
rowsIndex: number[],
clearSelection: () => void,
) => void;
onRowClick: (id: string) => void;
rowAnchor?: (id: string) => string;
}
export const CustomerListDatagrid = ({
customers,
sort,
loading,
settings,
onUpdateListSettings,
hasRowHover,
onRowClick,
rowAnchor,
disabled,
onSelectCustomerIds,
onSort,
}: CustomerListDatagridProps) => {
const intl = useIntl();
const datagrid = useDatagridChangeState();
const userPermissions = useUserPermissions();
const hasManageOrdersPermission =
userPermissions?.some(perm => perm.code === PermissionEnum.MANAGE_ORDERS) ??
false;
const customerListStaticColumns = useMemo(
() =>
customerListStaticColumnsAdapter(intl, sort, hasManageOrdersPermission),
[intl, sort, hasManageOrdersPermission],
);
const onColumnChange = useCallback(
(picked: string[]) => {
if (onUpdateListSettings) {
onUpdateListSettings("columns", picked.filter(Boolean));
}
},
[onUpdateListSettings],
);
const {
handlers,
visibleColumns,
staticColumns,
selectedColumns,
recentlyAddedColumn,
} = useColumns({
staticColumns: customerListStaticColumns,
selectedColumns: settings?.columns ?? [],
onSave: onColumnChange,
});
const getCellContent = useCallback(
createGetCellContent({
customers,
columns: visibleColumns,
}),
[customers, visibleColumns],
);
const handleRowClick = useCallback(
([_, row]: Item) => {
if (!onRowClick || !customers) {
return;
}
const rowData: Customer = customers[row];
onRowClick(rowData.id);
},
[onRowClick, customers],
);
const handleRowAnchor = useCallback(
([, row]: Item) => {
if (!rowAnchor || !customers) {
return "";
}
const rowData: Customer = customers[row];
return rowAnchor(rowData.id);
},
[rowAnchor, customers],
);
const handleHeaderClick = useCallback(
(col: number) => {
const columnName = visibleColumns[col].id as CustomerListUrlSortField;
onSort(columnName);
},
[visibleColumns, onSort],
);
return (
<DatagridChangeStateContext.Provider value={datagrid}>
<Datagrid
readonly
loading={loading}
rowMarkers="checkbox"
columnSelect="single"
hasRowHover={hasRowHover}
onColumnMoved={handlers.onMove}
onColumnResize={handlers.onResize}
verticalBorder={col => col > 0}
rows={customers?.length ?? 0}
availableColumns={visibleColumns}
emptyText={intl.formatMessage(messages.empty)}
onRowSelectionChange={onSelectCustomerIds}
getCellContent={getCellContent}
getCellError={() => false}
selectionActions={() => null}
menuItems={() => []}
onRowClick={handleRowClick}
onHeaderClicked={handleHeaderClick}
rowAnchor={handleRowAnchor}
recentlyAddedColumn={recentlyAddedColumn}
renderColumnPicker={() => (
<ColumnPicker
staticColumns={staticColumns}
selectedColumns={selectedColumns}
onToggle={handlers.onToggle}
/>
)}
/>
<Box paddingX={6}>
<TablePaginationWithContext
component="div"
settings={settings}
disabled={disabled}
onUpdateListSettings={onUpdateListSettings}
/>
</Box>
</DatagridChangeStateContext.Provider>
);
};

View file

@ -0,0 +1,69 @@
import { readonlyTextCell } from "@dashboard/components/Datagrid/customCells/cells";
import { AvailableColumn } from "@dashboard/components/Datagrid/types";
import { Customers } from "@dashboard/customers/types";
import { CustomerListUrlSortField } from "@dashboard/customers/urls";
import { getUserName } from "@dashboard/misc";
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 customerListStaticColumnsAdapter = (
intl: IntlShape,
sort: Sort<CustomerListUrlSortField>,
includeOrders: boolean,
): AvailableColumn[] =>
[
{
id: "name",
title: intl.formatMessage(columnsMessages.name),
width: 450,
},
{
id: "email",
title: intl.formatMessage(columnsMessages.email),
width: 450,
},
...(includeOrders
? [
{
id: "orders",
title: intl.formatMessage(columnsMessages.orders),
width: 200,
},
]
: []),
].map(column => ({
...column,
icon: getColumnSortDirectionIcon(sort, column.id),
}));
export const createGetCellContent =
({
customers,
columns,
}: {
customers: Customers | undefined;
columns: AvailableColumn[];
}) =>
([column, row]: Item): GridCell => {
const rowData = customers?.[row];
const columnId = columns[column]?.id;
if (!columnId || !rowData) {
return readonlyTextCell("");
}
switch (columnId) {
case "name":
return readonlyTextCell(getUserName(rowData) ?? "");
case "email":
return readonlyTextCell(rowData?.email ?? "");
case "orders":
return readonlyTextCell(rowData?.orders?.totalCount?.toString() ?? "");
default:
return readonlyTextCell("");
}
};

View file

@ -0,0 +1,26 @@
import { defineMessages } from "react-intl";
export const messages = defineMessages({
empty: {
id: "FpIcp9",
defaultMessage: "No customers found",
},
});
export const columnsMessages = defineMessages({
name: {
id: "nZDQbr",
defaultMessage: "Customer name",
description: "column header",
},
email: {
id: "945a4a",
defaultMessage: "Customer e-mail",
description: "column header",
},
orders: {
id: "MTGT8E",
defaultMessage: "No. of orders",
description: "column header",
},
});

View file

@ -1,11 +1,11 @@
// @ts-strict-ignore
import {
filterPageProps,
filterPresetsProps,
listActionsProps,
pageListProps,
searchPageProps,
sortPageProps,
tabPageProps,
} from "@dashboard/fixtures";
import { Meta, StoryObj } from "@storybook/react";
import React from "react";
@ -24,7 +24,7 @@ const props: CustomerListPageProps = {
...pageListProps.default,
...searchPageProps,
...sortPageProps,
...tabPageProps,
...filterPresetsProps,
customers: customerList,
selectedCustomerIds: ["123"],
filterOpts: {
@ -47,9 +47,13 @@ const props: CustomerListPageProps = {
...sortPageProps.sort,
sort: CustomerListUrlSortField.name,
},
loading: false,
hasPresetsChanged: () => false,
onSelectCustomerIds: () => undefined,
onCustomersDelete: () => undefined,
};
const CustomerListPage = props => (
const CustomerListPage = (props: CustomerListPageProps) => (
<MockedUserProvider>
<CustomerListPageComponent {...props} />
</MockedUserProvider>

View file

@ -6,76 +6,69 @@ import {
useExtensions,
} from "@dashboard/apps/hooks/useExtensions";
import { useUserPermissions } from "@dashboard/auth/hooks/useUserPermissions";
import { ListFilters } from "@dashboard/components/AppLayout/ListFilters";
import { TopNav } from "@dashboard/components/AppLayout/TopNav";
import ButtonWithSelect from "@dashboard/components/ButtonWithSelect";
import CardMenu from "@dashboard/components/CardMenu/CardMenu";
import FilterBar from "@dashboard/components/FilterBar";
import { BulkDeleteButton } from "@dashboard/components/BulkDeleteButton";
import { ButtonWithDropdown } from "@dashboard/components/ButtonWithDropdown";
import { FilterPresetsSelect } from "@dashboard/components/FilterPresetsSelect";
import { Customers } from "@dashboard/customers/types";
import {
customerAddUrl,
CustomerListUrlSortField,
customerUrl,
} from "@dashboard/customers/urls";
import { ListCustomersQuery } from "@dashboard/graphql";
import useNavigator from "@dashboard/hooks/useNavigator";
import { sectionNames } from "@dashboard/intl";
import {
FilterPageProps,
ListActions,
FilterPagePropsWithPresets,
PageListProps,
RelayToFlat,
SortPage,
TabPageProps,
} from "@dashboard/types";
import { Card } from "@material-ui/core";
import { makeStyles } from "@saleor/macaw-ui";
import React from "react";
import { Box, Button, ChevronRightIcon } from "@saleor/macaw-ui/next";
import React, { useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import CustomerList from "../CustomerList/CustomerList";
import { CustomerListDatagrid } from "../CustomerListDatagrid/CustomerListDatagrid";
import {
createFilterStructure,
CustomerFilterKeys,
CustomerListFilterOpts,
} from "./filters";
const useStyles = makeStyles(
theme => ({
settings: {
marginRight: theme.spacing(2),
},
}),
{ name: "CustomerListPage" },
);
export interface CustomerListPageProps
extends PageListProps,
ListActions,
FilterPageProps<CustomerFilterKeys, CustomerListFilterOpts>,
SortPage<CustomerListUrlSortField>,
TabPageProps {
customers: RelayToFlat<ListCustomersQuery["customers"]>;
FilterPagePropsWithPresets<CustomerFilterKeys, CustomerListFilterOpts>,
SortPage<CustomerListUrlSortField> {
customers: Customers | undefined;
selectedCustomerIds: string[];
loading: boolean;
onSelectCustomerIds: (rows: number[], clearSelection: () => void) => void;
onCustomersDelete: () => void;
}
const CustomerListPage: React.FC<CustomerListPageProps> = ({
currentTab,
selectedFilterPreset,
filterOpts,
initialSearch,
onAll,
onFilterPresetsAll,
onFilterChange,
onFilterPresetDelete,
onFilterPresetUpdate,
onSearchChange,
onTabChange,
onTabDelete,
onTabSave,
tabs,
onFilterPresetChange,
onFilterPresetPresetSave,
filterPresets,
selectedCustomerIds,
hasPresetsChanged,
onCustomersDelete,
...customerListProps
}) => {
const intl = useIntl();
const classes = useStyles({});
const navigate = useNavigator();
const userPermissions = useUserPermissions();
const structure = createFilterStructure(intl, filterOpts, userPermissions);
const [isFilterPresetOpen, setFilterPresetOpen] = useState(false);
const { CUSTOMER_OVERVIEW_CREATE, CUSTOMER_OVERVIEW_MORE_ACTIONS } =
useExtensions(extensionMountPoints.CUSTOMER_LIST);
@ -87,49 +80,100 @@ const CustomerListPage: React.FC<CustomerListPageProps> = ({
return (
<>
<TopNav title={intl.formatMessage(sectionNames.customers)}>
{extensionMenuItems.length > 0 && (
<CardMenu
className={classes.settings}
menuItems={extensionMenuItems}
/>
)}
<ButtonWithSelect
onClick={() => navigate(customerAddUrl)}
options={extensionCreateButtonItems}
data-test-id="create-customer"
<TopNav
title={intl.formatMessage(sectionNames.customers)}
withoutBorder
isAlignToRight={false}
>
<Box
__flex={1}
display="flex"
justifyContent="space-between"
alignItems="center"
>
<FormattedMessage
id="QLVddq"
defaultMessage="Create customer"
description="button"
/>
</ButtonWithSelect>
<Box display="flex">
<Box marginX={5} display="flex" alignItems="center">
<ChevronRightIcon />
</Box>
<FilterPresetsSelect
presetsChanged={hasPresetsChanged()}
onSelect={onFilterPresetChange}
onRemove={onFilterPresetDelete}
onUpdate={onFilterPresetUpdate}
savedPresets={filterPresets}
activePreset={selectedFilterPreset}
onSelectAll={onFilterPresetsAll}
onSave={onFilterPresetPresetSave}
isOpen={isFilterPresetOpen}
onOpenChange={setFilterPresetOpen}
selectAllLabel={intl.formatMessage({
id: "D95l71",
defaultMessage: "All customers",
description: "tab name",
})}
/>
</Box>
<Box display="flex" alignItems="center" gap={2}>
{extensionMenuItems.length > 0 && (
<TopNav.Menu items={extensionMenuItems} />
)}
{extensionCreateButtonItems.length > 0 ? (
<ButtonWithDropdown
options={extensionCreateButtonItems}
data-test-id="create-customer"
onClick={() => navigate(customerAddUrl)}
>
<FormattedMessage
id="QLVddq"
defaultMessage="Create customer"
description="button"
/>
</ButtonWithDropdown>
) : (
<Button
data-test-id="create-customer"
onClick={() => navigate(customerAddUrl)}
>
<FormattedMessage
id="QLVddq"
defaultMessage="Create customer"
description="button"
/>
</Button>
)}
</Box>
</Box>
</TopNav>
<Card>
<FilterBar
allTabLabel={intl.formatMessage({
id: "xQK2EC",
defaultMessage: "All Customers",
description: "tab name",
})}
currentTab={currentTab}
<Box>
<ListFilters
filterStructure={structure}
initialSearch={initialSearch}
searchPlaceholder={intl.formatMessage({
id: "2mRLis",
defaultMessage: "Search Customer",
id: "kdRcqU",
defaultMessage: "Search customers...",
})}
tabs={tabs}
onAll={onAll}
onFilterChange={onFilterChange}
onSearchChange={onSearchChange}
onTabChange={onTabChange}
onTabDelete={onTabDelete}
onTabSave={onTabSave}
actions={
<Box display="flex" gap={4}>
{selectedCustomerIds.length > 0 && (
<BulkDeleteButton onClick={onCustomersDelete}>
<FormattedMessage
defaultMessage="Delete customers"
id="kFsTMN"
/>
</BulkDeleteButton>
)}
</Box>
}
/>
<CustomerList {...customerListProps} />
</Card>
<CustomerListDatagrid
{...customerListProps}
hasRowHover={!isFilterPresetOpen}
rowAnchor={customerUrl}
onRowClick={id => navigate(customerUrl(id))}
/>
</Box>
</>
);
};

View file

@ -54,5 +54,7 @@ export function createFilterStructure(
active: opts.numberOfOrders.active,
permissions: [PermissionEnum.MANAGE_ORDERS],
},
].filter(filter => hasPermissions(userPermissions, filter.permissions ?? []));
].filter(filter =>
hasPermissions(userPermissions ?? [], filter.permissions ?? []),
);
}

View file

@ -1,11 +1,10 @@
// @ts-strict-ignore
import {
CustomerAddressesQuery,
CustomerDetailsQuery,
ListCustomersQuery,
PaymentChargeStatusEnum,
} from "@dashboard/graphql";
import { RelayToFlat } from "@dashboard/types";
import { Customers } from "./types";
export const customers = [
{
@ -682,7 +681,7 @@ export const customers = [
},
];
export const customerList: RelayToFlat<ListCustomersQuery["customers"]> = [
export const customerList: Customers = [
{
__typename: "User",
email: "Curtis.bailey@example.com",
@ -975,20 +974,20 @@ export const customer: CustomerDetailsQuery["user"] &
__typename: "Address",
city: "West Feliciamouth",
cityArea: "Montana",
companyName: null,
companyName: "",
country: {
__typename: "CountryDisplay",
code: "JA",
country: "Japan",
},
countryArea: null,
countryArea: "",
firstName: "Timmy",
id: "33855",
lastName: "Macejkovic",
phone: "+41 460-907-9374",
postalCode: "15926",
streetAddress1: "0238 Cremin Freeway",
streetAddress2: null,
streetAddress2: "",
},
],
dateJoined: "2017-05-07T09:37:30.124154+00:00",

View file

@ -1,3 +1,6 @@
import { ListCustomersQuery } from "@dashboard/graphql";
import { RelayToFlat } from "@dashboard/types";
export interface AddressTypeInput {
city: string;
cityArea?: string;
@ -29,3 +32,8 @@ export interface AddressType {
streetAddress1: string;
streetAddress2?: string;
}
export type Customers = RelayToFlat<
NonNullable<ListCustomersQuery["customers"]>
>;
export type Customer = Customers[number];

View file

@ -1,15 +1,12 @@
// @ts-strict-ignore
import ActionDialog from "@dashboard/components/ActionDialog";
import DeleteFilterTabDialog from "@dashboard/components/DeleteFilterTabDialog";
import SaveFilterTabDialog, {
SaveFilterTabDialogFormData,
} from "@dashboard/components/SaveFilterTabDialog";
import SaveFilterTabDialog from "@dashboard/components/SaveFilterTabDialog";
import { WindowTitle } from "@dashboard/components/WindowTitle";
import {
useBulkRemoveCustomersMutation,
useListCustomersQuery,
} from "@dashboard/graphql";
import useBulkActions from "@dashboard/hooks/useBulkActions";
import { useFilterPresets } from "@dashboard/hooks/useFilterPresets";
import useListSettings from "@dashboard/hooks/useListSettings";
import useNavigator from "@dashboard/hooks/useNavigator";
import useNotifier from "@dashboard/hooks/useNotifier";
@ -18,8 +15,8 @@ import usePaginator, {
createPaginationState,
PaginatorContext,
} from "@dashboard/hooks/usePaginator";
import { useRowSelection } from "@dashboard/hooks/useRowSelection";
import { commonMessages, sectionNames } from "@dashboard/intl";
import { maybe } from "@dashboard/misc";
import { ListViews } from "@dashboard/types";
import createDialogActionHandlers from "@dashboard/utils/handlers/dialogActionHandlers";
import createFilterHandlers from "@dashboard/utils/handlers/filterHandlers";
@ -27,8 +24,8 @@ import createSortHandler from "@dashboard/utils/handlers/sortHandler";
import { mapEdgesToItems } from "@dashboard/utils/maps";
import { getSortParams } from "@dashboard/utils/sort";
import { DialogContentText } from "@material-ui/core";
import { DeleteIcon, IconButton } from "@saleor/macaw-ui";
import React from "react";
import isEqual from "lodash/isEqual";
import React, { useCallback } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import CustomerListPage from "../../components/CustomerListPage";
@ -38,14 +35,11 @@ import {
CustomerListUrlQueryParams,
} from "../../urls";
import {
deleteFilterTab,
getActiveFilters,
getFilterOpts,
getFilterQueryParam,
getFiltersCurrentTab,
getFilterTabs,
getFilterVariables,
saveFilterTab,
getPresetNameToDelete,
storageUtils,
} from "./filters";
import { getSortQueryVariables } from "./sort";
@ -56,16 +50,36 @@ interface CustomerListProps {
export const CustomerList: React.FC<CustomerListProps> = ({ params }) => {
const navigate = useNavigator();
const notify = useNotifier();
const { isSelected, listElements, reset, toggle, toggleAll } = useBulkActions(
params.ids,
);
const intl = useIntl();
const { updateListSettings, settings } = useListSettings(
ListViews.CUSTOMER_LIST,
);
usePaginationReset(customerListUrl, params, settings.rowNumber);
const intl = useIntl();
const {
clearRowSelection,
selectedRowIds,
setClearDatagridRowSelectionCallback,
setSelectedRowIds,
} = useRowSelection(params);
const {
selectedPreset,
presets,
hasPresetsChanged,
onPresetChange,
onPresetDelete,
onPresetSave,
onPresetUpdate,
setPresetIdToDelete,
presetIdToDelete,
} = useFilterPresets({
params,
reset: clearRowSelection,
getUrl: customerListUrl,
storageUtils,
});
const paginationState = createPaginationState(settings.rowNumber, params);
const queryVariables = React.useMemo(
@ -80,18 +94,16 @@ export const CustomerList: React.FC<CustomerListProps> = ({ params }) => {
displayLoader: true,
variables: queryVariables,
});
const tabs = getFilterTabs();
const currentTab = getFiltersCurrentTab(params, tabs);
const customers = mapEdgesToItems(data?.customers);
const [changeFilters, resetFilters, handleSearchChange] =
createFilterHandlers({
cleanupFn: reset,
cleanupFn: clearRowSelection,
createUrl: customerListUrl,
getFilterQueryParam,
navigate,
params,
keepActiveTab: true,
});
const [openModal, closeModal] = createDialogActionHandlers<
@ -99,29 +111,8 @@ export const CustomerList: React.FC<CustomerListProps> = ({ params }) => {
CustomerListUrlQueryParams
>(navigate, customerListUrl, params);
const handleTabChange = (tab: number) => {
reset();
navigate(
customerListUrl({
activeTab: tab.toString(),
...getFilterTabs()[tab - 1].data,
}),
);
};
const handleTabDelete = () => {
deleteFilterTab(currentTab);
reset();
navigate(customerListUrl());
};
const handleTabSave = (data: SaveFilterTabDialogFormData) => {
saveFilterTab(data.name, getActiveFilters(params));
handleTabChange(tabs.length + 1);
};
const paginationValues = usePaginator({
pageInfo: maybe(() => data.customers.pageInfo),
pageInfo: data?.customers?.pageInfo,
paginationState,
queryString: params,
});
@ -129,13 +120,13 @@ export const CustomerList: React.FC<CustomerListProps> = ({ params }) => {
const [bulkRemoveCustomers, bulkRemoveCustomersOpts] =
useBulkRemoveCustomersMutation({
onCompleted: data => {
if (data.customerBulkDelete.errors.length === 0) {
if (data.customerBulkDelete?.errors.length === 0) {
notify({
status: "success",
text: intl.formatMessage(commonMessages.savedChanges),
});
reset();
refetch();
clearRowSelection();
closeModal();
}
},
@ -143,53 +134,67 @@ export const CustomerList: React.FC<CustomerListProps> = ({ params }) => {
const handleSort = createSortHandler(navigate, customerListUrl, params);
const handleSetSelectedCustomerIds = useCallback(
(rows: number[], clearSelection: () => void) => {
if (!customers) {
return;
}
const rowsIds = rows.map(row => customers[row].id);
const haveSaveValues = isEqual(rowsIds, selectedRowIds);
if (!haveSaveValues) {
setSelectedRowIds(rowsIds);
}
setClearDatagridRowSelectionCallback(clearSelection);
},
[
customers,
selectedRowIds,
setClearDatagridRowSelectionCallback,
setSelectedRowIds,
],
);
return (
<PaginatorContext.Provider value={paginationValues}>
<WindowTitle title={intl.formatMessage(sectionNames.customers)} />
<CustomerListPage
currentTab={currentTab}
selectedFilterPreset={selectedPreset}
filterOpts={getFilterOpts(params)}
initialSearch={params.query || ""}
onSearchChange={handleSearchChange}
onFilterChange={changeFilters}
onAll={resetFilters}
onTabChange={handleTabChange}
onTabDelete={() => openModal("delete-search")}
onTabSave={() => openModal("save-search")}
tabs={tabs.map(tab => tab.name)}
customers={mapEdgesToItems(data?.customers)}
onFilterPresetsAll={resetFilters}
onFilterPresetChange={onPresetChange}
onFilterPresetDelete={(id: number) => {
setPresetIdToDelete(id);
openModal("delete-search");
}}
onFilterPresetPresetSave={() => openModal("save-search")}
onFilterPresetUpdate={onPresetUpdate}
filterPresets={presets.map(preset => preset.name)}
customers={customers}
settings={settings}
disabled={loading}
loading={loading}
onUpdateListSettings={updateListSettings}
onSort={handleSort}
toolbar={
<IconButton
variant="secondary"
color="primary"
onClick={() =>
openModal("remove", {
ids: listElements,
})
}
>
<DeleteIcon />
</IconButton>
}
isChecked={isSelected}
selected={listElements.length}
selectedCustomerIds={listElements}
selectedCustomerIds={selectedRowIds}
onSelectCustomerIds={handleSetSelectedCustomerIds}
sort={getSortParams(params)}
toggle={toggle}
toggleAll={toggleAll}
hasPresetsChanged={hasPresetsChanged}
onCustomersDelete={() => openModal("remove", { ids: selectedRowIds })}
/>
<ActionDialog
open={params.action === "remove" && maybe(() => params.ids.length > 0)}
open={params.action === "remove" && selectedRowIds?.length > 0}
onClose={closeModal}
confirmButtonState={bulkRemoveCustomersOpts.status}
onConfirm={() =>
bulkRemoveCustomers({
variables: {
ids: params.ids,
ids: selectedRowIds,
},
})
}
@ -205,10 +210,8 @@ export const CustomerList: React.FC<CustomerListProps> = ({ params }) => {
id="N2SbNc"
defaultMessage="{counter,plural,one{Are you sure you want to delete this customer?} other{Are you sure you want to delete {displayQuantity} customers?}}"
values={{
counter: maybe(() => params.ids.length),
displayQuantity: (
<strong>{maybe(() => params.ids.length)}</strong>
),
counter: selectedRowIds?.length,
displayQuantity: <strong>{selectedRowIds?.length}</strong>,
}}
/>
</DialogContentText>
@ -217,14 +220,14 @@ export const CustomerList: React.FC<CustomerListProps> = ({ params }) => {
open={params.action === "save-search"}
confirmButtonState="default"
onClose={closeModal}
onSubmit={handleTabSave}
onSubmit={onPresetSave}
/>
<DeleteFilterTabDialog
open={params.action === "delete-search"}
confirmButtonState="default"
onClose={closeModal}
onSubmit={handleTabDelete}
tabName={maybe(() => tabs[currentTab - 1].name, "...")}
onSubmit={onPresetDelete}
tabName={getPresetNameToDelete(presets, presetIdToDelete)}
/>
</PaginatorContext.Provider>
);

View file

@ -1,15 +1,14 @@
// @ts-strict-ignore
import { FilterElement } from "@dashboard/components/Filter";
import {
CustomerFilterKeys,
CustomerListFilterOpts,
} from "@dashboard/customers/components/CustomerListPage";
import { CustomerFilterInput } from "@dashboard/graphql";
import { maybe } from "@dashboard/misc";
import {
createFilterTabUtils,
createFilterUtils,
GetFilterTabsOutput,
getGteLteVariables,
getMinMaxQueryParam,
} from "../../../utils/filters";
@ -26,29 +25,23 @@ export function getFilterOpts(
): CustomerListFilterOpts {
return {
joined: {
active: maybe(
() =>
[params.joinedFrom, params.joinedTo].some(
field => field !== undefined,
),
false,
),
active:
[params.joinedFrom, params.joinedTo].some(
field => field !== undefined,
) ?? false,
value: {
max: maybe(() => params.joinedTo, ""),
min: maybe(() => params.joinedFrom, ""),
max: params.joinedTo ?? "",
min: params.joinedFrom ?? "",
},
},
numberOfOrders: {
active: maybe(
() =>
[params.numberOfOrdersFrom, params.numberOfOrdersTo].some(
field => field !== undefined,
),
false,
),
active:
[params.numberOfOrdersFrom, params.numberOfOrdersTo].some(
field => field !== undefined,
) ?? false,
value: {
max: maybe(() => params.numberOfOrdersTo, ""),
min: maybe(() => params.numberOfOrdersFrom, ""),
max: params.numberOfOrdersTo ?? "",
min: params.numberOfOrdersFrom ?? "",
},
},
};
@ -63,8 +56,12 @@ export function getFilterVariables(
lte: params.joinedTo,
}),
numberOfOrders: getGteLteVariables({
gte: parseInt(params.numberOfOrdersFrom, 10),
lte: parseInt(params.numberOfOrdersTo, 10),
gte: params?.numberOfOrdersFrom
? parseInt(params.numberOfOrdersFrom, 10)
: null,
lte: params?.numberOfOrdersTo
? parseInt(params.numberOfOrdersTo, 10)
: null,
}),
search: params.query,
};
@ -92,10 +89,20 @@ export function getFilterQueryParam(
}
}
export const { deleteFilterTab, getFilterTabs, saveFilterTab } =
createFilterTabUtils<CustomerListUrlFilters>(CUSTOMER_FILTERS_KEY);
export const storageUtils = createFilterTabUtils<string>(CUSTOMER_FILTERS_KEY);
export const { areFiltersApplied, getActiveFilters, getFiltersCurrentTab } =
createFilterUtils<CustomerListUrlQueryParams, CustomerListUrlFilters>(
CustomerListUrlFiltersEnum,
);
export const getPresetNameToDelete = (
presets: GetFilterTabsOutput<string>,
presetIdToDelete: number | null,
): string => {
const presetIndex = presetIdToDelete ? presetIdToDelete - 1 : 0;
const preset = presets?.[presetIndex];
const tabName = preset?.name ?? "...";
return tabName;
};

View file

@ -96,7 +96,7 @@ export const SaleList: React.FC<SaleListProps> = ({ params }) => {
} = useRowSelection(params);
const {
hasPresetsChange,
hasPresetsChanged,
onPresetChange,
onPresetDelete,
onPresetSave,
@ -215,7 +215,7 @@ export const SaleList: React.FC<SaleListProps> = ({ params }) => {
onFilterPresetsAll={resetFilters}
filterPresets={presets.map(preset => preset.name)}
selectedFilterPreset={selectedPreset}
hasPresetsChanged={hasPresetsChange}
hasPresetsChanged={hasPresetsChanged}
onSalesDelete={() => openModal("remove")}
selectedSaleIds={selectedRowIds}
sales={sales}

View file

@ -313,8 +313,8 @@ export const filterPresetsProps: FilterPresetsProps = {
onFilterPresetDelete: () => undefined,
onFilterPresetPresetSave: () => undefined,
onFilterPresetUpdate: () => undefined,
filterPresets: ["Tab X"],
hasPresetsChanged: () => false,
filterPresets: ["Tab X"],
};
export const paginatorContextValues: PaginatorContextValues = {

View file

@ -25,7 +25,7 @@ const GiftCardsListHeader: React.FC = () => {
} = useGiftCardListDialogs();
const {
hasPresetsChange,
hasPresetsChanged,
selectedPreset,
presets,
onPresetUpdate,
@ -57,7 +57,7 @@ const GiftCardsListHeader: React.FC = () => {
</Box>
<FilterPresetsSelect
presetsChanged={hasPresetsChange()}
presetsChanged={hasPresetsChanged()}
onSelect={onPresetChange}
onRemove={(id: number) => {
setPresetIdToDelete(id);

View file

@ -18,7 +18,7 @@ export interface UseFilterPresets {
onPresetDelete: () => void;
onPresetSave: (data: SaveFilterTabDialogFormData) => void;
onPresetUpdate: (tabName: string) => void;
hasPresetsChange: () => boolean;
hasPresetsChanged: () => boolean;
}
export const useFilterPresets = <
@ -106,7 +106,7 @@ export const useFilterPresets = <
onPresetChange(presets.findIndex(tab => tab.name === tabName) + 1);
};
const hasPresetsChange = () => {
const hasPresetsChanged = () => {
const { parsedQs } = prepareQs(location.search);
if (!selectedPreset) {
@ -131,6 +131,6 @@ export const useFilterPresets = <
onPresetDelete,
onPresetSave,
onPresetUpdate,
hasPresetsChange,
hasPresetsChanged,
};
};

View file

@ -17,10 +17,10 @@ export const useRowSelection = (
const clearDatagridRowSelectionCallback = useRef<(() => void) | null>(null);
const clearRowSelection = () => {
setSelectedRowIds([]);
if (clearDatagridRowSelectionCallback.current) {
clearDatagridRowSelectionCallback.current();
}
setSelectedRowIds([]);
};
const setClearDatagridRowSelectionCallback = (callback: () => void) => {

View file

@ -125,7 +125,7 @@ export const OrderDraftList: React.FC<OrderDraftListProps> = ({ params }) => {
const {
selectedPreset,
presets,
hasPresetsChange,
hasPresetsChanged,
onPresetChange,
onPresetDelete,
onPresetSave,
@ -221,7 +221,7 @@ export const OrderDraftList: React.FC<OrderDraftListProps> = ({ params }) => {
onSort={handleSort}
sort={getSortParams(params)}
currencySymbol={channel?.currencyCode}
hasPresetsChanged={hasPresetsChange}
hasPresetsChanged={hasPresetsChanged}
onDraftOrdersDelete={() =>
openModal("remove", {
ids: selectedRowIds,

View file

@ -54,7 +54,7 @@ export const OrderList: React.FC<OrderListProps> = ({ params }) => {
);
const {
hasPresetsChange,
hasPresetsChanged,
onPresetChange,
onPresetDelete,
onPresetSave,
@ -163,7 +163,7 @@ export const OrderList: React.FC<OrderListProps> = ({ params }) => {
onAll={resetFilters}
onSettingsOpen={() => navigate(orderSettingsPath)}
params={params}
hasPresetsChanged={hasPresetsChange()}
hasPresetsChanged={hasPresetsChanged()}
/>
<SaveFilterTabDialog
open={params.action === "save-search"}

View file

@ -191,7 +191,7 @@ export const ProductList: React.FC<ProductListProps> = ({ params }) => {
} = useRowSelection(params);
const {
hasPresetsChange,
hasPresetsChanged,
onPresetChange,
onPresetDelete,
onPresetSave,
@ -432,7 +432,7 @@ export const ProductList: React.FC<ProductListProps> = ({ params }) => {
}}
onProductsDelete={() => openModal("delete")}
onTabChange={onPresetChange}
hasPresetsChanged={hasPresetsChange()}
hasPresetsChanged={hasPresetsChanged()}
initialSearch={params.query || ""}
tabs={presets.map(tab => tab.name)}
onExport={() => openModal("export")}

View file

@ -134,7 +134,7 @@ export interface FilterPresetsProps {
}
export interface TabPageProps {
currentTab: number;
currentTab: number | undefined;
tabs: string[];
onAll: () => void;
onTabChange: (tab: number) => void;

View file

@ -21,7 +21,7 @@
"@locale/*": ["locale/*"],
"@dashboard/*": ["src/*"],
"@test/*": ["testUtils/*"]
},
}
},
"exclude": ["node_modules", "cypress"]
}