Add product export (#620)

* Add component backbone

* Make step component generic and typed

* Add step tabs

* Add settings view

* Encapsulate all dialog components in one directory

* Move types to separate file

* Add mutations

* Use gql types

* Add error handling

* Do not keep separate types file

* Allow products to be exported

* Fix types

* Update snapshots

* Update to latest schema

* Add disabled option

* Use wizard hook

* Update type definitions

* Queue export check task

* Fix bug causing jobs to be endless and duplicated

* Fix minor bugs

* Add accordion component

* Allow selection of fields  to be exported

* Add attribute export

* Update snapshots

* Update messages

* Update changelog

* Add missing key

* Add quick peek to accordioin

* Sort imports

* Remove unused files

* Add chiips to attribute selection

* Change menu positioning

* Add product counter

* Add select all option

* Update snapshots

* Update messages

* Remove unused import

* Add chips

* Add test tags

* Update snapshots

* Change number of max chips

* Add accordion tags

* Update messages
This commit is contained in:
Dominik Żegleń 2020-07-30 11:54:16 +02:00 committed by GitHub
parent 5062d35270
commit df5aea6200
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
56 changed files with 2440 additions and 425 deletions

View file

@ -19,6 +19,7 @@ All notable, unreleased changes to this project will be documented in this file.
- Add weight field and fix warehouse country selection - #597 by @dominik-zeglen - Add weight field and fix warehouse country selection - #597 by @dominik-zeglen
- Fix weight based rate update - #604 by @dominik-zeglen - Fix weight based rate update - #604 by @dominik-zeglen
- Add product export - #620 by @dominik-zeglen
## 2.10.0 ## 2.10.0

View file

@ -1806,6 +1806,16 @@
"context": "button", "context": "button",
"string": "Confirm" "string": "Confirm"
}, },
"src_dot_containers_dot_BackgroundTasks_dot_exportFailedTitle": {
"string": "Product Export Failed"
},
"src_dot_containers_dot_BackgroundTasks_dot_exportFinishedText": {
"string": "Product export has finished and was sent to your email address."
},
"src_dot_containers_dot_BackgroundTasks_dot_exportFinishedTitle": {
"context": "csv file exporting has finished, header",
"string": "Exporting CSV finished"
},
"src_dot_containers_dot_BackgroundTasks_dot_invoiceGenerateFinishedText": { "src_dot_containers_dot_BackgroundTasks_dot_invoiceGenerateFinishedText": {
"string": "Requested Invoice was generated. It was added to the top of the invoice list on this view. Enjoy!" "string": "Requested Invoice was generated. It was added to the top of the invoice list on this view. Enjoy!"
}, },
@ -2597,6 +2607,10 @@
"context": "navigation section name", "context": "navigation section name",
"string": "Navigation" "string": "Navigation"
}, },
"src_dot_nextStep": {
"context": "go to next step, button",
"string": "Next"
},
"src_dot_no": { "src_dot_no": {
"string": "No" "string": "No"
}, },
@ -3722,12 +3736,6 @@
"src_dot_productTypes_dot_components_dot_AssignAttributeDialog_dot_902296540": { "src_dot_productTypes_dot_components_dot_AssignAttributeDialog_dot_902296540": {
"string": "Search Attributes" "string": "Search Attributes"
}, },
"src_dot_productTypes_dot_components_dot_ProductTypeAttributeEditDialog_dot_1228425832": {
"string": "Attribute name"
},
"src_dot_productTypes_dot_components_dot_ProductTypeAttributeEditDialog_dot_335542212": {
"string": "Attribute values"
},
"src_dot_productTypes_dot_components_dot_ProductTypeAttributeUnassignDialog_dot_404238501": { "src_dot_productTypes_dot_components_dot_ProductTypeAttributeUnassignDialog_dot_404238501": {
"context": "dialog header", "context": "dialog header",
"string": "Unassign Attribute From Product Type" "string": "Unassign Attribute From Product Type"
@ -3915,6 +3923,145 @@
"context": "product name", "context": "product name",
"string": "Name" "string": "Name"
}, },
"src_dot_products_dot_components_dot_ProductExportDialog_dot_108342258": {
"context": "button",
"string": "Load More"
},
"src_dot_products_dot_components_dot_ProductExportDialog_dot_1459686496": {
"context": "product field",
"string": "Visibility"
},
"src_dot_products_dot_components_dot_ProductExportDialog_dot_1547327218": {
"context": "there are more elements of list that are hidden",
"string": "and {number} more"
},
"src_dot_products_dot_components_dot_ProductExportDialog_dot_1755013298": {
"context": "product field",
"string": "Category"
},
"src_dot_products_dot_components_dot_ProductExportDialog_dot_1890035856": {
"context": "informations about product organization, header",
"string": "Product Organization"
},
"src_dot_products_dot_components_dot_ProductExportDialog_dot_1952810469": {
"context": "product field",
"string": "Type"
},
"src_dot_products_dot_components_dot_ProductExportDialog_dot_2051669917": {
"context": "product field",
"string": "Cost Price"
},
"src_dot_products_dot_components_dot_ProductExportDialog_dot_2119710854": {
"context": "informations about product seo, header",
"string": "SEO Information"
},
"src_dot_products_dot_components_dot_ProductExportDialog_dot_2167661409": {
"context": "export selected products to csv file",
"string": "Selected products ({number})"
},
"src_dot_products_dot_components_dot_ProductExportDialog_dot_222873645": {
"context": "product field",
"string": "Collections"
},
"src_dot_products_dot_components_dot_ProductExportDialog_dot_2318723509": {
"context": "export products to csv file, choice field label",
"string": "Export information for:"
},
"src_dot_products_dot_components_dot_ProductExportDialog_dot_2355065897": {
"context": "export all products to csv file",
"string": "All products ({number})"
},
"src_dot_products_dot_components_dot_ProductExportDialog_dot_2659464408": {
"context": "product export to csv file, header",
"string": "Information exported"
},
"src_dot_products_dot_components_dot_ProductExportDialog_dot_2693217446": {
"context": "export products as csv or spreadsheet file",
"string": "Export as:"
},
"src_dot_products_dot_components_dot_ProductExportDialog_dot_2883720012": {
"context": "export products to csv file, button",
"string": "export products"
},
"src_dot_products_dot_components_dot_ProductExportDialog_dot_2949617129": {
"context": "product field",
"string": "Product Images"
},
"src_dot_products_dot_components_dot_ProductExportDialog_dot_3012202273": {
"context": "export products to csv file, dialog header",
"string": "Export Information"
},
"src_dot_products_dot_components_dot_ProductExportDialog_dot_3365843236": {
"context": "product export to csv file, header",
"string": "Export Settings"
},
"src_dot_products_dot_components_dot_ProductExportDialog_dot_3374163063": {
"context": "product field",
"string": "Description"
},
"src_dot_products_dot_components_dot_ProductExportDialog_dot_3441755345": {
"context": "product field",
"string": "Charge Taxes"
},
"src_dot_products_dot_components_dot_ProductExportDialog_dot_3443345452": {
"context": "selectt all options",
"string": "Select All"
},
"src_dot_products_dot_components_dot_ProductExportDialog_dot_3518309850": {
"context": "export products as spreadsheet",
"string": "Spreadsheet for Excel, Numbers etc."
},
"src_dot_products_dot_components_dot_ProductExportDialog_dot_3544554440": {
"context": "product field",
"string": "Variant Weight"
},
"src_dot_products_dot_components_dot_ProductExportDialog_dot_3599582104": {
"string": "Search Atrtibuttes"
},
"src_dot_products_dot_components_dot_ProductExportDialog_dot_3919525499": {
"context": "informations about product stock, header",
"string": "Inventory Information"
},
"src_dot_products_dot_components_dot_ProductExportDialog_dot_4118932547": {
"context": "export products as csv file",
"string": "Plain CSV file"
},
"src_dot_products_dot_components_dot_ProductExportDialog_dot_4160582036": {
"context": "product field",
"string": "Variant Price"
},
"src_dot_products_dot_components_dot_ProductExportDialog_dot_472026385": {
"context": "select product informations to be exported",
"string": "Information exported:"
},
"src_dot_products_dot_components_dot_ProductExportDialog_dot_524117994": {
"context": "input helper text, search attributes",
"string": "Search by attribute name"
},
"src_dot_products_dot_components_dot_ProductExportDialog_dot_636461959": {
"context": "product field",
"string": "Name"
},
"src_dot_products_dot_components_dot_ProductExportDialog_dot_693960049": {
"context": "product field",
"string": "SKU"
},
"src_dot_products_dot_components_dot_ProductExportDialog_dot_700651641": {
"context": "export filtered products to csv file",
"string": "Current search ({number})"
},
"src_dot_products_dot_components_dot_ProductExportDialog_dot_734825715": {
"context": "informations about product prices etc, header",
"string": "Financial Information"
},
"src_dot_products_dot_components_dot_ProductExportDialog_dot_746695941": {
"context": "product field",
"string": "Weight"
},
"src_dot_products_dot_components_dot_ProductExportDialog_dot_897437458": {
"context": "product field",
"string": "Variant Images"
},
"src_dot_products_dot_components_dot_ProductImageNavigation_dot_3060635772": { "src_dot_products_dot_components_dot_ProductImageNavigation_dot_3060635772": {
"context": "section header", "context": "section header",
"string": "All Photos" "string": "All Photos"
@ -3951,6 +4098,10 @@
"context": "product type", "context": "product type",
"string": "Type" "string": "Type"
}, },
"src_dot_products_dot_components_dot_ProductListPage_dot_2059406063": {
"context": "export products to csv file, button",
"string": "Export Products"
},
"src_dot_products_dot_components_dot_ProductListPage_dot_3550330425": { "src_dot_products_dot_components_dot_ProductListPage_dot_3550330425": {
"string": "Search Products..." "string": "Search Products..."
}, },
@ -4339,6 +4490,10 @@
"context": "dialog content", "context": "dialog content",
"string": "{counter,plural,one{Are you sure you want to delete this product?} other{Are you sure you want to delete {displayQuantity} products?}}" "string": "{counter,plural,one{Are you sure you want to delete this product?} other{Are you sure you want to delete {displayQuantity} products?}}"
}, },
"src_dot_products_dot_views_dot_ProductList_dot_1505423810": {
"context": "waiting for export to end, header",
"string": "Exporting CSV"
},
"src_dot_products_dot_views_dot_ProductList_dot_1547167026": { "src_dot_products_dot_views_dot_ProductList_dot_1547167026": {
"context": "publish product, button", "context": "publish product, button",
"string": "Publish" "string": "Publish"
@ -4367,6 +4522,9 @@
"context": "dialog header", "context": "dialog header",
"string": "Delete Products" "string": "Delete Products"
}, },
"src_dot_products_dot_views_dot_ProductList_dot_44832327": {
"string": "We are currently exporting your requested CSV. As soon as it is available it will be sent to your email address"
},
"src_dot_products_dot_views_dot_ProductUpdate_dot_1177237881": { "src_dot_products_dot_views_dot_ProductUpdate_dot_1177237881": {
"context": "dialog content", "context": "dialog content",
"string": "{counter,plural,one{Are you sure you want to delete this variant?} other{Are you sure you want to delete {displayQuantity} variants?}}" "string": "{counter,plural,one{Are you sure you want to delete this variant?} other{Are you sure you want to delete {displayQuantity} variants?}}"
@ -4430,6 +4588,10 @@
"src_dot_savedChanges": { "src_dot_savedChanges": {
"string": "Saved changes" "string": "Saved changes"
}, },
"src_dot_selectAll": {
"context": "select all options, button",
"string": "Select All"
},
"src_dot_send": { "src_dot_send": {
"context": "button", "context": "button",
"string": "Send" "string": "Send"

View file

@ -2232,6 +2232,108 @@ type Error {
message: String message: String
} }
type ExportError {
field: String
message: String
code: ExportErrorCode!
}
enum ExportErrorCode {
INVALID
NOT_FOUND
REQUIRED
}
type ExportEvent implements Node {
id: ID!
date: DateTime!
type: ExportEventsEnum!
user: User
app: App
message: String!
}
enum ExportEventsEnum {
EXPORT_PENDING
EXPORT_SUCCESS
EXPORT_FAILED
EXPORT_DELETED
EXPORTED_FILE_SENT
EXPORT_FAILED_INFO_SENT
}
type ExportFile implements Node & Job {
id: ID!
user: User
app: App
status: JobStatusEnum!
createdAt: DateTime!
updatedAt: DateTime!
url: String
events: [ExportEvent!]
}
type ExportFileCountableConnection {
pageInfo: PageInfo!
edges: [ExportFileCountableEdge!]!
totalCount: Int
}
type ExportFileCountableEdge {
node: ExportFile!
cursor: String!
}
input ExportFileFilterInput {
createdAt: DateTimeRangeInput
updatedAt: DateTimeRangeInput
status: JobStatusEnum
user: String
app: String
}
enum ExportFileSortField {
STATUS
CREATED_AT
UPDATED_AT
}
input ExportFileSortingInput {
direction: OrderDirection!
field: ExportFileSortField!
}
input ExportInfoInput {
attributes: [ID!]
warehouses: [ID!]
fields: [ProductFieldEnum!]
}
type ExportProducts {
errors: [Error!]! @deprecated(reason: "Use typed errors with error codes. This field will be removed after 2020-07-31.")
exportFile: ExportFile
exportErrors: [ExportError!]!
}
input ExportProductsInput {
scope: ExportScope!
filter: ProductFilterInput
ids: [ID!]
exportInfo: ExportInfoInput
fileType: FileTypesEnum!
}
enum ExportScope {
ALL
IDS
FILTER
}
enum FileTypesEnum {
CSV
XLSX
}
type Fulfillment implements Node & ObjectWithMetadata { type Fulfillment implements Node & ObjectWithMetadata {
id: ID! id: ID!
fulfillmentOrder: Int! fulfillmentOrder: Int!
@ -4657,6 +4759,23 @@ enum ProductErrorCode {
VARIANT_NO_DIGITAL_CONTENT VARIANT_NO_DIGITAL_CONTENT
} }
enum ProductFieldEnum {
NAME
DESCRIPTION
PRODUCT_TYPE
CATEGORY
VISIBLE
PRODUCT_WEIGHT
COLLECTIONS
CHARGE_TAXES
PRODUCT_IMAGES
VARIANT_SKU
VARIANT_PRICE
COST_PRICE
VARIANT_WEIGHT
VARIANT_IMAGES
}
input ProductFilterInput { input ProductFilterInput {
isPublished: Boolean isPublished: Boolean
collections: [ID] collections: [ID]
@ -5446,6 +5565,8 @@ type Query {
first: Int first: Int
last: Int last: Int
): VoucherCountableConnection ): VoucherCountableConnection
exportFile(id: ID!): ExportFile
exportFiles(filter: ExportFileFilterInput, sortBy: ExportFileSortingInput, before: String, after: String, first: Int, last: Int): ExportFileCountableConnection
taxTypes: [TaxType] taxTypes: [TaxType]
checkout(token: UUID): Checkout checkout(token: UUID): Checkout
checkouts( checkouts(

View file

@ -0,0 +1,21 @@
import CardDecorator from "@saleor/storybook/CardDecorator";
import Decorator from "@saleor/storybook/Decorator";
import { storiesOf } from "@storybook/react";
import React from "react";
import Accordion from "./Accordion";
storiesOf("Generics / Accordion", module)
.addDecorator(Decorator)
.addDecorator(CardDecorator)
.add("default", () => <Accordion title="Title">Content</Accordion>)
.add("opened", () => (
<Accordion title="Title" initialExpand={true}>
Content
</Accordion>
))
.add("with quick peek", () => (
<Accordion title="Title" quickPeek="Quick Peek">
Content
</Accordion>
));

View file

@ -0,0 +1,80 @@
import IconButton from "@material-ui/core/IconButton";
import makeStyles from "@material-ui/core/styles/makeStyles";
import Typography from "@material-ui/core/Typography";
import AddIcon from "@material-ui/icons/Add";
import RemoveIcon from "@material-ui/icons/Remove";
import classNames from "classnames";
import React from "react";
import Hr from "../Hr";
const useStyles = makeStyles(
theme => ({
content: {
padding: theme.spacing(3, 0)
},
expandButton: {
position: "relative",
right: theme.spacing(-2),
top: theme.spacing(0.5)
},
root: {
border: `1px solid ${theme.palette.divider}`,
borderRadius: 12,
padding: theme.spacing(0, 3)
},
title: {
display: "flex",
justifyContent: "space-between"
},
titleText: {
padding: theme.spacing(2, 0)
}
}),
{
name: "Accordion"
}
);
export interface AccordionProps {
className?: string;
initialExpand?: boolean;
quickPeek?: React.ReactNode;
title: string;
}
const Accordion: React.FC<AccordionProps> = ({
children,
className,
initialExpand,
quickPeek,
title,
...props
}) => {
const classes = useStyles({});
const [expanded, setExpanded] = React.useState(!!initialExpand);
return (
<div className={classNames(classes.root, className)} {...props}>
<div className={classes.title}>
<Typography className={classes.titleText}>{title}</Typography>
<div className={classes.expandButton}>
<IconButton onClick={() => setExpanded(!expanded)}>
{expanded ? <RemoveIcon /> : <AddIcon />}
</IconButton>
</div>
</div>
{(expanded || !!quickPeek) && (
<>
<Hr />
<div className={classes.content}>
{quickPeek ? (expanded ? children : quickPeek) : children}
</div>
</>
)}
</div>
);
};
Accordion.displayName = "Accordion";
export default Accordion;

View file

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

View file

@ -1,6 +1,10 @@
import ClickAwayListener from "@material-ui/core/ClickAwayListener";
import Grow from "@material-ui/core/Grow";
import IconButton from "@material-ui/core/IconButton"; import IconButton from "@material-ui/core/IconButton";
import Menu from "@material-ui/core/Menu";
import MenuItem from "@material-ui/core/MenuItem"; import MenuItem from "@material-ui/core/MenuItem";
import MenuList from "@material-ui/core/MenuList";
import Paper from "@material-ui/core/Paper";
import Popper from "@material-ui/core/Popper";
import { makeStyles } from "@material-ui/core/styles"; import { makeStyles } from "@material-ui/core/styles";
import MoreVertIcon from "@material-ui/icons/MoreVert"; import MoreVertIcon from "@material-ui/icons/MoreVert";
import React from "react"; import React from "react";
@ -9,6 +13,7 @@ const ITEM_HEIGHT = 48;
export interface CardMenuItem { export interface CardMenuItem {
label: string; label: string;
testId?: string;
onSelect: () => void; onSelect: () => void;
} }
@ -26,34 +31,58 @@ const useStyles = makeStyles(
height: 32, height: 32,
padding: 0, padding: 0,
width: 32 width: 32
},
paper: {
marginTop: theme.spacing(2),
maxHeight: ITEM_HEIGHT * 4.5
} }
}), }),
{ name: "CardMenu" } { name: "CardMenu" }
); );
const CardMenu: React.FC<CardMenuProps> = props => { const CardMenu: React.FC<CardMenuProps> = props => {
const { className, disabled, menuItems } = props; const { className, disabled, menuItems, ...rest } = props;
const classes = useStyles(props); const classes = useStyles(props);
const [anchorEl, setAnchor] = React.useState<HTMLElement | null>(null); const anchorRef = React.useRef<HTMLButtonElement | null>(null);
const [open, setOpen] = React.useState(false);
const handleClick = (event: React.MouseEvent<any>) => { const handleToggle = () => setOpen(prevOpen => !prevOpen);
setAnchor(event.currentTarget);
const handleClose = (event: React.MouseEvent<EventTarget>) => {
if (
anchorRef.current &&
anchorRef.current.contains(event.target as HTMLElement)
) {
return;
}
setOpen(false);
}; };
const handleClose = () => { const handleListKeyDown = (event: React.KeyboardEvent) => {
setAnchor(null); if (event.key === "Tab") {
event.preventDefault();
setOpen(false);
}
}; };
const handleMenuClick = (menuItemIndex: number) => { const prevOpen = React.useRef(open);
menuItems[menuItemIndex].onSelect(); React.useEffect(() => {
handleClose(); if (prevOpen.current === true && open === false) {
}; anchorRef.current!.focus();
}
const open = !!anchorEl; prevOpen.current = open;
}, [open]);
const handleMenuClick = (index: number) => {
menuItems[index].onSelect();
setOpen(false);
};
return ( return (
<div className={className}> <div className={className} {...rest}>
<IconButton <IconButton
aria-label="More" aria-label="More"
aria-owns={open ? "long-menu" : null} aria-owns={open ? "long-menu" : null}
@ -61,31 +90,47 @@ const CardMenu: React.FC<CardMenuProps> = props => {
className={classes.iconButton} className={classes.iconButton}
color="primary" color="primary"
disabled={disabled} disabled={disabled}
onClick={handleClick} ref={anchorRef}
onClick={handleToggle}
> >
<MoreVertIcon /> <MoreVertIcon />
</IconButton> </IconButton>
<Menu <Popper
id="long-menu" placement="bottom-end"
anchorEl={anchorEl}
open={open} open={open}
onClose={handleClose} anchorEl={anchorRef.current}
PaperProps={{ transition
style: {
maxHeight: ITEM_HEIGHT * 4.5
// width: 200
}
}}
> >
{menuItems.map((menuItem, menuItemIndex) => ( {({ TransitionProps, placement }) => (
<MenuItem <Grow
onClick={() => handleMenuClick(menuItemIndex)} {...TransitionProps}
key={menuItem.label} style={{
transformOrigin:
placement === "bottom" ? "right top" : "right bottom"
}}
> >
{menuItem.label} <Paper className={classes.paper}>
</MenuItem> <ClickAwayListener onClickAway={handleClose}>
))} <MenuList
</Menu> autoFocusItem={open}
id="menu-list-grow"
onKeyDown={handleListKeyDown}
>
{menuItems.map((menuItem, menuItemIndex) => (
<MenuItem
onClick={() => handleMenuClick(menuItemIndex)}
key={menuItem.label}
data-test={menuItem.testId}
>
{menuItem.label}
</MenuItem>
))}
</MenuList>
</ClickAwayListener>
</Paper>
</Grow>
)}
</Popper>
</div> </div>
); );
}; };

View file

@ -23,8 +23,8 @@ const useStyles = makeStyles(
color: theme.palette.common.white color: theme.palette.common.white
}, },
root: { root: {
background: fade(theme.palette.secondary.main, 0.8), background: fade(theme.palette.primary.main, 0.8),
borderRadius: 8, borderRadius: 18,
display: "inline-block", display: "inline-block",
marginRight: theme.spacing(2), marginRight: theme.spacing(2),
padding: "6px 12px" padding: "6px 12px"

View file

@ -0,0 +1,84 @@
import { makeStyles } from "@material-ui/core/styles";
import Typography from "@material-ui/core/Typography";
import classNames from "classnames";
import React from "react";
export interface Step<T> {
label: string;
value: T;
}
const useStyles = makeStyles(
theme => ({
label: {
fontSize: 14,
textAlign: "center"
},
root: {
borderBottom: `1px solid ${theme.palette.divider}`,
display: "flex",
justifyContent: "space-between",
marginBottom: theme.spacing(3)
},
tab: {
flex: 1,
paddingBottom: theme.spacing(),
userSelect: "none"
},
tabActive: {
fontWeight: 600
},
tabVisited: {
borderBottom: `3px solid ${theme.palette.primary.main}`,
cursor: "pointer"
}
}),
{
name: "CreatorSteps"
}
);
export interface CreatorStepsProps<T> {
currentStep: T;
steps: Array<Step<T>>;
onStepClick: (step: T) => void;
}
function makeCreatorSteps<T extends string | number>() {
const CreatorSteps: React.FC<CreatorStepsProps<T>> = ({
currentStep,
steps,
onStepClick
}) => {
const classes = useStyles({});
return (
<div className={classes.root}>
{steps.map((step, stepIndex) => {
const visitedStep =
steps.findIndex(step => step.value === currentStep) >= stepIndex;
return (
<div
className={classNames(classes.tab, {
[classes.tabActive]: step.value === currentStep,
[classes.tabVisited]: visitedStep
})}
onClick={visitedStep ? () => onStepClick(step.value) : undefined}
key={step.value}
>
<Typography className={classes.label} variant="caption">
{step.label}
</Typography>
</div>
);
})}
</div>
);
};
CreatorSteps.displayName = "CreatorSteps";
return CreatorSteps;
}
export default makeCreatorSteps;

View file

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

View file

@ -36,8 +36,11 @@ const useStyles = makeStyles(
} }
); );
export interface RadioGroupFieldChoice { export interface RadioGroupFieldChoice<
value: string; T extends string | number = string | number
> {
disabled?: boolean;
value: T;
label: React.ReactNode; label: React.ReactNode;
} }
@ -49,7 +52,7 @@ interface RadioGroupFieldProps {
hint?: string; hint?: string;
label?: string; label?: string;
name?: string; name?: string;
value: string; value: string | number;
onChange: (event: React.ChangeEvent<any>) => void; onChange: (event: React.ChangeEvent<any>) => void;
} }
@ -87,6 +90,7 @@ export const RadioGroupField: React.FC<RadioGroupFieldProps> = props => {
{choices.length > 0 ? ( {choices.length > 0 ? (
choices.map(choice => ( choices.map(choice => (
<FormControlLabel <FormControlLabel
disabled={choice.disabled}
value={choice.value} value={choice.value}
className={classes.radioLabel} className={classes.radioLabel}
control={<Radio color="primary" />} control={<Radio color="primary" />}

View file

@ -9,7 +9,7 @@ export interface IMessage {
autohide?: number; autohide?: number;
expandText?: string; expandText?: string;
title?: string; title?: string;
text: string; text: React.ReactNode;
onUndo?: () => void; onUndo?: () => void;
status?: Status; status?: Status;
} }

View file

@ -1,16 +1,30 @@
import { JobStatusEnum } from "@saleor/types/globalTypes";
import { renderHook } from "@testing-library/react-hooks"; import { renderHook } from "@testing-library/react-hooks";
import { createMockClient } from "mock-apollo-client"; import { createMockClient, RequestHandlerResponse } from "mock-apollo-client";
import { import {
backgroundTasksRefreshTime, backgroundTasksRefreshTime,
useBackgroundTasks useBackgroundTasks
} from "./BackgroundTasksProvider"; } from "./BackgroundTasksProvider";
import { OnCompletedTaskData, Task, TaskData, TaskStatus } from "./types"; import { checkExportFileStatus } from "./queries";
import { Task, TaskData, TaskStatus } from "./types";
import { CheckExportFileStatus } from "./types/CheckExportFileStatus";
jest.useFakeTimers(); jest.useFakeTimers();
function renderBackgroundTasks() { function renderBackgroundTasks() {
const mockClient = createMockClient(); const mockClient = createMockClient();
mockClient.setRequestHandler(checkExportFileStatus, () =>
Promise.resolve<RequestHandlerResponse<CheckExportFileStatus>>({
data: {
exportFile: {
__typename: "ExportFile",
id: "123",
status: JobStatusEnum.SUCCESS
}
}
})
);
const intl = { const intl = {
formatMessage: ({ defaultMessage }) => defaultMessage formatMessage: ({ defaultMessage }) => defaultMessage
}; };
@ -80,33 +94,6 @@ describe("Background task provider", () => {
}); });
}); });
it("can handle task failure", done => {
const handle = jest.fn<Promise<TaskStatus>, []>(
() => new Promise(resolve => resolve(TaskStatus.FAILURE))
);
const onCompleted = jest.fn((data: OnCompletedTaskData) =>
expect(data.status).toBe(TaskStatus.FAILURE)
);
const onError = jest.fn();
const { result } = renderBackgroundTasks();
result.current.queue(Task.CUSTOM, {
handle,
onCompleted,
onError
});
jest.runOnlyPendingTimers();
setImmediate(() => {
expect(handle).toHaveBeenCalledTimes(1);
expect(onCompleted).toHaveBeenCalledTimes(1);
done();
});
});
it("can cancel task", done => { it("can cancel task", done => {
const onCompleted = jest.fn(); const onCompleted = jest.fn();

View file

@ -1,13 +1,18 @@
import { IMessageContext } from "@saleor/components/messages"; import { IMessageContext } from "@saleor/components/messages";
import useNotifier from "@saleor/hooks/useNotifier"; import useNotifier from "@saleor/hooks/useNotifier";
import { checkOrderInvoicesStatus } from "@saleor/orders/queries";
import ApolloClient from "apollo-client"; import ApolloClient from "apollo-client";
import React from "react"; import React from "react";
import { useApolloClient } from "react-apollo"; import { useApolloClient } from "react-apollo";
import { IntlShape, useIntl } from "react-intl"; import { IntlShape, useIntl } from "react-intl";
import BackgroundTasksContext from "./context"; import BackgroundTasksContext from "./context";
import { handleTask, queueCustom, queueInvoiceGenerate } from "./tasks"; import { checkExportFileStatus, checkOrderInvoicesStatus } from "./queries";
import {
handleTask,
queueCustom,
queueExport,
queueInvoiceGenerate
} from "./tasks";
import { QueuedTask, Task, TaskData, TaskStatus } from "./types"; import { QueuedTask, Task, TaskData, TaskStatus } from "./types";
export const backgroundTasksRefreshTime = 15 * 1000; export const backgroundTasksRefreshTime = 15 * 1000;
@ -81,6 +86,22 @@ export function useBackgroundTasks(
intl intl
); );
break; break;
case Task.EXPORT:
queueExport(
idCounter.current,
tasks,
() =>
apolloClient.query({
fetchPolicy: "network-only",
query: checkExportFileStatus,
variables: {
id: data.id
}
}),
notify,
intl
);
break;
} }
return idCounter.current; return idCounter.current;

View file

@ -0,0 +1,27 @@
import { defineMessages } from "react-intl";
export default defineMessages({
exportFailedTitle: {
defaultMessage: "Product Export Failed"
},
exportFinishedText: {
defaultMessage:
"Product export has finished and was sent to your email address."
},
exportFinishedTitle: {
defaultMessage: "Exporting CSV finished",
description: "csv file exporting has finished, header"
},
invoiceGenerateFinishedText: {
defaultMessage:
"Requested Invoice was generated. It was added to the top of the invoice list on this view. Enjoy!"
},
invoiceGenerateFinishedTitle: {
defaultMessage: "Invoice Generated",
description: "invoice generating has finished, header"
},
invoiceGenerationFailedTitle: {
defaultMessage: "Invoice Generation",
description: "dialog header, title"
}
});

View file

@ -0,0 +1,23 @@
import { invoiceFragment } from "@saleor/fragments/orders";
import gql from "graphql-tag";
export const checkExportFileStatus = gql`
query CheckExportFileStatus($id: ID!) {
exportFile(id: $id) {
id
status
}
}
`;
export const checkOrderInvoicesStatus = gql`
${invoiceFragment}
query CheckOrderInvoicesStatus($id: ID!) {
order(id: $id) {
id
invoices {
...InvoiceFragment
}
}
}
`;

View file

@ -1,31 +1,29 @@
import { IMessageContext } from "@saleor/components/messages"; import { IMessageContext } from "@saleor/components/messages";
import { commonMessages } from "@saleor/intl"; import { commonMessages } from "@saleor/intl";
import { CheckOrderInvoicesStatus } from "@saleor/orders/types/CheckOrderInvoicesStatus";
import { JobStatusEnum } from "@saleor/types/globalTypes"; import { JobStatusEnum } from "@saleor/types/globalTypes";
import { ApolloQueryResult } from "apollo-client"; import { ApolloQueryResult } from "apollo-client";
import { defineMessages, IntlShape } from "react-intl"; import { IntlShape } from "react-intl";
import messages from "./messages";
import { import {
InvoiceGenerateParams, InvoiceGenerateParams,
QueuedTask, QueuedTask,
TaskData, TaskData,
TaskStatus TaskStatus
} from "./types"; } from "./types";
import { CheckExportFileStatus } from "./types/CheckExportFileStatus";
import { CheckOrderInvoicesStatus } from "./types/CheckOrderInvoicesStatus";
export const messages = defineMessages({ function getTaskStatus(jobStatus: JobStatusEnum): TaskStatus {
invoiceGenerateFinishedText: { switch (jobStatus) {
defaultMessage: case JobStatusEnum.SUCCESS:
"Requested Invoice was generated. It was added to the top of the invoice list on this view. Enjoy!" return TaskStatus.SUCCESS;
}, case JobStatusEnum.PENDING:
invoiceGenerateFinishedTitle: { return TaskStatus.PENDING;
defaultMessage: "Invoice Generated", default:
description: "invoice generating has finished, header" return TaskStatus.FAILURE;
},
invoiceGenerationFailedTitle: {
defaultMessage: "Invoice Generation",
description: "dialog header, title"
} }
}); }
export async function handleTask(task: QueuedTask): Promise<TaskStatus> { export async function handleTask(task: QueuedTask): Promise<TaskStatus> {
let status = TaskStatus.PENDING; let status = TaskStatus.PENDING;
@ -89,11 +87,7 @@ export function queueInvoiceGenerate(
invoice => invoice.id === generateInvoice.invoiceId invoice => invoice.id === generateInvoice.invoiceId
).status; ).status;
return status === JobStatusEnum.SUCCESS return getTaskStatus(status);
? TaskStatus.SUCCESS
: status === JobStatusEnum.PENDING
? TaskStatus.PENDING
: TaskStatus.FAILURE;
}, },
id, id,
onCompleted: data => onCompleted: data =>
@ -104,6 +98,7 @@ export function queueInvoiceGenerate(
title: intl.formatMessage(messages.invoiceGenerateFinishedTitle) title: intl.formatMessage(messages.invoiceGenerateFinishedTitle)
}) })
: notify({ : notify({
status: "error",
text: intl.formatMessage(commonMessages.somethingWentWrong), text: intl.formatMessage(commonMessages.somethingWentWrong),
title: intl.formatMessage(messages.invoiceGenerationFailedTitle) title: intl.formatMessage(messages.invoiceGenerationFailedTitle)
}), }),
@ -112,3 +107,38 @@ export function queueInvoiceGenerate(
} }
]; ];
} }
export function queueExport(
id: number,
tasks: React.MutableRefObject<QueuedTask[]>,
fetch: () => Promise<ApolloQueryResult<CheckExportFileStatus>>,
notify: IMessageContext,
intl: IntlShape
) {
tasks.current = [
...tasks.current,
{
handle: async () => {
const result = await fetch();
const status = result.data.exportFile.status;
return getTaskStatus(status);
},
id,
onCompleted: data =>
data.status === TaskStatus.SUCCESS
? notify({
status: "success",
text: intl.formatMessage(messages.exportFinishedText),
title: intl.formatMessage(messages.exportFinishedTitle)
})
: notify({
status: "error",
text: intl.formatMessage(commonMessages.somethingWentWrong),
title: intl.formatMessage(messages.exportFailedTitle)
}),
onError: handleError,
status: TaskStatus.PENDING
}
];
}

View file

@ -1,5 +1,6 @@
export enum Task { export enum Task {
CUSTOM, CUSTOM,
EXPORT,
INVOICE_GENERATE INVOICE_GENERATE
} }
export enum TaskStatus { export enum TaskStatus {

View file

@ -0,0 +1,23 @@
/* tslint:disable */
/* eslint-disable */
// This file was automatically generated and should not be edited.
import { JobStatusEnum } from "./../../../types/globalTypes";
// ====================================================
// GraphQL query operation: CheckExportFileStatus
// ====================================================
export interface CheckExportFileStatus_exportFile {
__typename: "ExportFile";
id: string;
status: JobStatusEnum;
}
export interface CheckExportFileStatus {
exportFile: CheckExportFileStatus_exportFile | null;
}
export interface CheckExportFileStatusVariables {
id: string;
}

View file

@ -2,7 +2,7 @@
/* eslint-disable */ /* eslint-disable */
// This file was automatically generated and should not be edited. // This file was automatically generated and should not be edited.
import { JobStatusEnum } from "./../../types/globalTypes"; import { JobStatusEnum } from "./../../../types/globalTypes";
// ==================================================== // ====================================================
// GraphQL query operation: CheckOrderInvoicesStatus // GraphQL query operation: CheckOrderInvoicesStatus

View file

@ -120,3 +120,10 @@ export const appErrorFragment = gql`
permissions permissions
} }
`; `;
export const exportErrorFragment = gql`
fragment ExportErrorFragment on ExportError {
code
field
}
`;

View file

@ -241,3 +241,11 @@ export const fragmentVariant = gql`
} }
} }
`; `;
export const exportFileFragment = gql`
fragment ExportFileFragment on ExportFile {
id
status
url
}
`;

View file

@ -0,0 +1,15 @@
/* tslint:disable */
/* eslint-disable */
// This file was automatically generated and should not be edited.
import { ExportErrorCode } from "./../../types/globalTypes";
// ====================================================
// GraphQL fragment: ExportErrorFragment
// ====================================================
export interface ExportErrorFragment {
__typename: "ExportError";
code: ExportErrorCode;
field: string | null;
}

View file

@ -0,0 +1,16 @@
/* tslint:disable */
/* eslint-disable */
// This file was automatically generated and should not be edited.
import { JobStatusEnum } from "./../../types/globalTypes";
// ====================================================
// GraphQL fragment: ExportFileFragment
// ====================================================
export interface ExportFileFragment {
__typename: "ExportFile";
id: string;
status: JobStatusEnum;
url: string | null;
}

View file

@ -132,6 +132,10 @@ export const buttonMessages = defineMessages({
defaultMessage: "Manage", defaultMessage: "Manage",
description: "button" description: "button"
}, },
nextStep: {
defaultMessage: "Next",
description: "go to next step, button"
},
ok: { ok: {
defaultMessage: "OK", defaultMessage: "OK",
description: "button" description: "button"
@ -144,6 +148,10 @@ export const buttonMessages = defineMessages({
defaultMessage: "Save", defaultMessage: "Save",
description: "button" description: "button"
}, },
selectAll: {
defaultMessage: "Select All",
description: "select all options, button"
},
send: { send: {
defaultMessage: "Send", defaultMessage: "Send",
description: "button" description: "button"

View file

@ -1,8 +1,5 @@
import { fragmentAddress } from "@saleor/fragments/address"; import { fragmentAddress } from "@saleor/fragments/address";
import { import { fragmentOrderDetails } from "@saleor/fragments/orders";
fragmentOrderDetails,
invoiceFragment
} from "@saleor/fragments/orders";
import makeQuery from "@saleor/hooks/makeQuery"; import makeQuery from "@saleor/hooks/makeQuery";
import makeTopLevelSearch from "@saleor/hooks/makeTopLevelSearch"; import makeTopLevelSearch from "@saleor/hooks/makeTopLevelSearch";
import gql from "graphql-tag"; import gql from "graphql-tag";
@ -231,15 +228,3 @@ export const useOrderFulfillData = makeQuery<
OrderFulfillData, OrderFulfillData,
OrderFulfillDataVariables OrderFulfillDataVariables
>(orderFulfillData); >(orderFulfillData);
export const checkOrderInvoicesStatus = gql`
${invoiceFragment}
query CheckOrderInvoicesStatus($id: ID!) {
order(id: $id) {
id
invoices {
...InvoiceFragment
}
}
}
`;

View file

@ -1,4 +1,4 @@
import { messages } from "@saleor/containers/BackgroundTasks/tasks"; import messages from "@saleor/containers/BackgroundTasks/messages";
import useNavigator from "@saleor/hooks/useNavigator"; import useNavigator from "@saleor/hooks/useNavigator";
import useNotifier from "@saleor/hooks/useNotifier"; import useNotifier from "@saleor/hooks/useNotifier";
import createDialogActionHandlers from "@saleor/utils/handlers/dialogActionHandlers"; import createDialogActionHandlers from "@saleor/utils/handlers/dialogActionHandlers";

View file

@ -30,7 +30,7 @@ import React from "react";
import InfiniteScroll from "react-infinite-scroller"; import InfiniteScroll from "react-infinite-scroller";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import { SearchAttributes_productType_availableAttributes_edges_node } from "../../hooks/useAvailableAttributeSearch/types/SearchAttributes"; import { SearchAvailableAttributes_productType_availableAttributes_edges_node } from "../../hooks/useAvailableAttributeSearch/types/SearchAvailableAttributes";
const useStyles = makeStyles( const useStyles = makeStyles(
theme => ({ theme => ({
@ -63,7 +63,7 @@ export interface AssignAttributeDialogProps extends FetchMoreProps {
confirmButtonState: ConfirmButtonTransitionState; confirmButtonState: ConfirmButtonTransitionState;
errors: string[]; errors: string[];
open: boolean; open: boolean;
attributes: SearchAttributes_productType_availableAttributes_edges_node[]; attributes: SearchAvailableAttributes_productType_availableAttributes_edges_node[];
selected: string[]; selected: string[];
onClose: () => void; onClose: () => void;
onFetch: (query: string) => void; onFetch: (query: string) => void;

View file

@ -1,111 +0,0 @@
import Button from "@material-ui/core/Button";
import Dialog from "@material-ui/core/Dialog";
import DialogActions from "@material-ui/core/DialogActions";
import DialogContent from "@material-ui/core/DialogContent";
import DialogTitle from "@material-ui/core/DialogTitle";
import TextField from "@material-ui/core/TextField";
import Form from "@saleor/components/Form";
import { FormSpacer } from "@saleor/components/FormSpacer";
import ListField from "@saleor/components/ListField";
import { buttonMessages } from "@saleor/intl";
import { UserError } from "@saleor/types";
import { getFieldError } from "@saleor/utils/errors";
import React from "react";
import { FormattedMessage, useIntl } from "react-intl";
export interface FormData {
name: string;
values: Array<{
label: string;
value: string;
}>;
}
export interface ProductTypeAttributeEditDialogProps {
disabled: boolean;
errors: UserError[];
name: string;
opened: boolean;
title: string;
values: Array<{
label: string;
value: string;
}>;
onClose: () => void;
onConfirm: (data: FormData) => void;
}
const ProductTypeAttributeEditDialog: React.FC<ProductTypeAttributeEditDialogProps> = ({
disabled,
errors,
name,
opened,
title,
values,
onClose,
onConfirm
}) => {
const intl = useIntl();
const initialForm: FormData = {
name: name || "",
values: values || []
};
return (
<Dialog onClose={onClose} open={opened}>
<Form initial={initialForm} onSubmit={onConfirm}>
{({ change, data }) => (
<>
<DialogTitle>{title}</DialogTitle>
<DialogContent>
<TextField
disabled={disabled}
error={!!getFieldError(errors, "name")}
fullWidth
label={intl.formatMessage({
defaultMessage: "Attribute name"
})}
helperText={getFieldError(errors, "name")?.message}
name="name"
value={data.name}
onChange={change}
/>
<FormSpacer />
<ListField
autoComplete="off"
disabled={disabled}
error={
!!getFieldError(errors, "values") ||
!!getFieldError(errors, "addValues") ||
!!getFieldError(errors, "removeValues")
}
fullWidth
name="values"
label={intl.formatMessage({
defaultMessage: "Attribute values"
})}
helperText={
getFieldError(errors, "values") ||
getFieldError(errors, "addValues") ||
getFieldError(errors, "removeValues")
}
values={data.values}
onChange={change}
/>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>
<FormattedMessage {...buttonMessages.back} />
</Button>
<Button color="primary" variant="contained" type="submit">
<FormattedMessage {...buttonMessages.confirm} />
</Button>
</DialogActions>
</>
)}
</Form>
</Dialog>
);
};
ProductTypeAttributeEditDialog.displayName = "ProductTypeAttributeEditDialog";
export default ProductTypeAttributeEditDialog;

View file

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

View file

@ -3,13 +3,13 @@ import makeSearch from "@saleor/hooks/makeSearch";
import gql from "graphql-tag"; import gql from "graphql-tag";
import { import {
SearchAttributes, SearchAvailableAttributes,
SearchAttributesVariables SearchAvailableAttributesVariables
} from "./types/SearchAttributes"; } from "./types/SearchAvailableAttributes";
export const searchAttributes = gql` export const searchAttributes = gql`
${pageInfoFragment} ${pageInfoFragment}
query SearchAttributes( query SearchAvailableAttributes(
$id: ID! $id: ID!
$after: String $after: String
$first: Int! $first: Int!
@ -37,35 +37,36 @@ export const searchAttributes = gql`
} }
`; `;
export default makeSearch<SearchAttributes, SearchAttributesVariables>( export default makeSearch<
searchAttributes, SearchAvailableAttributes,
result => SearchAvailableAttributesVariables
result.loadMore( >(searchAttributes, result =>
(prev, next) => { result.loadMore(
if ( (prev, next) => {
prev.productType.availableAttributes.pageInfo.endCursor === if (
next.productType.availableAttributes.pageInfo.endCursor prev.productType.availableAttributes.pageInfo.endCursor ===
) { next.productType.availableAttributes.pageInfo.endCursor
return prev; ) {
} return prev;
return {
...prev,
productType: {
...prev.productType,
availableAttributes: {
...prev.productType.availableAttributes,
edges: [
...prev.productType.availableAttributes.edges,
...next.productType.availableAttributes.edges
],
pageInfo: next.productType.availableAttributes.pageInfo
}
}
};
},
{
after: result.data.productType.availableAttributes.pageInfo.endCursor
} }
)
return {
...prev,
productType: {
...prev.productType,
availableAttributes: {
...prev.productType.availableAttributes,
edges: [
...prev.productType.availableAttributes.edges,
...next.productType.availableAttributes.edges
],
pageInfo: next.productType.availableAttributes.pageInfo
}
}
};
},
{
after: result.data.productType.availableAttributes.pageInfo.endCursor
}
)
); );

View file

@ -1,50 +0,0 @@
/* tslint:disable */
/* eslint-disable */
// This file was automatically generated and should not be edited.
// ====================================================
// GraphQL query operation: SearchAttributes
// ====================================================
export interface SearchAttributes_productType_availableAttributes_edges_node {
__typename: "Attribute";
id: string;
name: string | null;
slug: string | null;
}
export interface SearchAttributes_productType_availableAttributes_edges {
__typename: "AttributeCountableEdge";
node: SearchAttributes_productType_availableAttributes_edges_node;
}
export interface SearchAttributes_productType_availableAttributes_pageInfo {
__typename: "PageInfo";
endCursor: string | null;
hasNextPage: boolean;
hasPreviousPage: boolean;
startCursor: string | null;
}
export interface SearchAttributes_productType_availableAttributes {
__typename: "AttributeCountableConnection";
edges: SearchAttributes_productType_availableAttributes_edges[];
pageInfo: SearchAttributes_productType_availableAttributes_pageInfo;
}
export interface SearchAttributes_productType {
__typename: "ProductType";
id: string;
availableAttributes: SearchAttributes_productType_availableAttributes | null;
}
export interface SearchAttributes {
productType: SearchAttributes_productType | null;
}
export interface SearchAttributesVariables {
id: string;
after?: string | null;
first: number;
query: string;
}

View file

@ -0,0 +1,50 @@
/* tslint:disable */
/* eslint-disable */
// This file was automatically generated and should not be edited.
// ====================================================
// GraphQL query operation: SearchAvailableAttributes
// ====================================================
export interface SearchAvailableAttributes_productType_availableAttributes_edges_node {
__typename: "Attribute";
id: string;
name: string | null;
slug: string | null;
}
export interface SearchAvailableAttributes_productType_availableAttributes_edges {
__typename: "AttributeCountableEdge";
node: SearchAvailableAttributes_productType_availableAttributes_edges_node;
}
export interface SearchAvailableAttributes_productType_availableAttributes_pageInfo {
__typename: "PageInfo";
endCursor: string | null;
hasNextPage: boolean;
hasPreviousPage: boolean;
startCursor: string | null;
}
export interface SearchAvailableAttributes_productType_availableAttributes {
__typename: "AttributeCountableConnection";
edges: SearchAvailableAttributes_productType_availableAttributes_edges[];
pageInfo: SearchAvailableAttributes_productType_availableAttributes_pageInfo;
}
export interface SearchAvailableAttributes_productType {
__typename: "ProductType";
id: string;
availableAttributes: SearchAvailableAttributes_productType_availableAttributes | null;
}
export interface SearchAvailableAttributes {
productType: SearchAvailableAttributes_productType | null;
}
export interface SearchAvailableAttributesVariables {
id: string;
after?: string | null;
first: number;
query: string;
}

View file

@ -0,0 +1,53 @@
import Decorator from "@saleor/storybook/Decorator";
import {
ExportErrorCode,
ExportProductsInput
} from "@saleor/types/globalTypes";
import { storiesOf } from "@storybook/react";
import React from "react";
import { attributes } from "../../../attributes/fixtures";
import ProductExportDialog, {
ProductExportDialogProps
} from "./ProductExportDialog";
const props: ProductExportDialogProps = {
attributes: attributes.map(attr => ({
__typename: "Attribute",
id: attr.id,
name: attr.name
})),
confirmButtonState: "default",
errors: [],
hasMore: true,
loading: true,
onClose: () => undefined,
onFetch: () => undefined,
onFetchMore: () => undefined,
onSubmit: () => undefined,
open: true,
productQuantity: {
all: 100,
filter: 32
},
selectedProducts: 18
};
storiesOf("Views / Products / Export / Export settings", module)
.addDecorator(Decorator)
.add("interactive", () => <ProductExportDialog {...props} />)
.add("no products selected", () => (
<ProductExportDialog {...props} selectedProducts={0} />
))
.add("errors", () => (
<ProductExportDialog
{...props}
errors={(["fileType", "scope", null] as Array<
keyof ExportProductsInput | null
>).map(field => ({
__typename: "ExportError",
code: ExportErrorCode.INVALID,
field
}))}
/>
));

View file

@ -0,0 +1,227 @@
import Button from "@material-ui/core/Button";
import Dialog from "@material-ui/core/Dialog";
import DialogActions from "@material-ui/core/DialogActions";
import DialogContent from "@material-ui/core/DialogContent";
import DialogTitle from "@material-ui/core/DialogTitle";
import Typography from "@material-ui/core/Typography";
import ConfirmButton, {
ConfirmButtonTransitionState
} from "@saleor/components/ConfirmButton";
import makeCreatorSteps, { Step } from "@saleor/components/CreatorSteps";
import Form from "@saleor/components/Form";
import { MultiAutocompleteChoiceType } from "@saleor/components/MultiAutocompleteSelectField";
import { ExportErrorFragment } from "@saleor/fragments/types/ExportErrorFragment";
import { FormChange } from "@saleor/hooks/useForm";
import useModalDialogErrors from "@saleor/hooks/useModalDialogErrors";
import useWizard from "@saleor/hooks/useWizard";
import { buttonMessages } from "@saleor/intl";
import { SearchAttributes_search_edges_node } from "@saleor/searches/types/SearchAttributes";
import { DialogProps, FetchMoreProps } from "@saleor/types";
import {
ExportProductsInput,
ExportScope,
FileTypesEnum
} from "@saleor/types/globalTypes";
import getExportErrorMessage from "@saleor/utils/errors/export";
import { toggle } from "@saleor/utils/lists";
import { mapNodeToChoice } from "@saleor/utils/maps";
import React from "react";
import { FormattedMessage, useIntl } from "react-intl";
import ProductExportDialogInfo, {
attributeNamePrefix
} from "./ProductExportDialogInfo";
import ProductExportDialogSettings, {
ProductQuantity
} from "./ProductExportDialogSettings";
export enum ProductExportStep {
INFO,
SETTINGS
}
function useSteps(): Array<Step<ProductExportStep>> {
const intl = useIntl();
return [
{
label: intl.formatMessage({
defaultMessage: "Information exported",
description: "product export to csv file, header"
}),
value: ProductExportStep.INFO
},
{
label: intl.formatMessage({
defaultMessage: "Export Settings",
description: "product export to csv file, header"
}),
value: ProductExportStep.SETTINGS
}
];
}
const initialForm: ExportProductsInput = {
exportInfo: {
attributes: [],
fields: []
},
fileType: FileTypesEnum.CSV,
scope: ExportScope.ALL
};
const ProductExportSteps = makeCreatorSteps<ProductExportStep>();
export interface ProductExportDialogProps extends DialogProps, FetchMoreProps {
attributes: SearchAttributes_search_edges_node[];
confirmButtonState: ConfirmButtonTransitionState;
errors: ExportErrorFragment[];
productQuantity: ProductQuantity;
selectedProducts: number;
onFetch: (query: string) => void;
onSubmit: (data: ExportProductsInput) => void;
}
const ProductExportDialog: React.FC<ProductExportDialogProps> = ({
attributes,
confirmButtonState,
errors,
productQuantity,
onClose,
onSubmit,
open,
selectedProducts,
...fetchMoreProps
}) => {
const [step, { next, prev, set: setStep }] = useWizard(
ProductExportStep.INFO,
[ProductExportStep.INFO, ProductExportStep.SETTINGS]
);
const steps = useSteps();
const dialogErrors = useModalDialogErrors(errors, open);
const notFormErrors = dialogErrors.filter(err => !err.field);
const intl = useIntl();
const [selectedAttributes, setSelectedAttributes] = React.useState<
MultiAutocompleteChoiceType[]
>([]);
const attributeChoices = mapNodeToChoice(attributes);
return (
<Dialog onClose={onClose} open={open} maxWidth="sm" fullWidth>
<Form initial={initialForm} onSubmit={onSubmit}>
{({ change, data }) => {
const handleAttributeSelect: FormChange = event => {
const id = event.target.name.substr(attributeNamePrefix.length);
change({
target: {
name: "exportInfo",
value: {
...data.exportInfo,
attributes: toggle(
id,
data.exportInfo.attributes,
(a, b) => a === b
)
}
}
});
const choice = attributeChoices.find(choice => choice.value === id);
setSelectedAttributes(
toggle(choice, selectedAttributes, (a, b) => a.value === b.value)
);
};
return (
<>
<DialogTitle>
<FormattedMessage
defaultMessage="Export Information"
description="export products to csv file, dialog header"
/>
</DialogTitle>
<DialogContent>
<ProductExportSteps
currentStep={step}
steps={steps}
onStepClick={setStep}
/>
{step === ProductExportStep.INFO && (
<ProductExportDialogInfo
attributes={attributeChoices}
data={data}
selectedAttributes={selectedAttributes}
onAttrtibuteSelect={handleAttributeSelect}
onChange={change}
{...fetchMoreProps}
/>
)}
{step === ProductExportStep.SETTINGS && (
<ProductExportDialogSettings
data={data}
errors={dialogErrors}
productQuantity={productQuantity}
selectedProducts={selectedProducts}
onChange={change}
/>
)}
</DialogContent>
{notFormErrors.length > 0 && (
<DialogContent>
{notFormErrors.map(err => (
<Typography color="error" key={err.field + err.code}>
{getExportErrorMessage(err, intl)}
</Typography>
))}
</DialogContent>
)}
<DialogActions>
{step === ProductExportStep.INFO && (
<Button onClick={onClose} data-test="cancel">
<FormattedMessage {...buttonMessages.cancel} />
</Button>
)}
{step === ProductExportStep.SETTINGS && (
<Button onClick={prev} data-test="back">
<FormattedMessage {...buttonMessages.back} />
</Button>
)}
{step === ProductExportStep.INFO && (
<Button
color="primary"
variant="contained"
onClick={next}
data-test="next"
>
<FormattedMessage {...buttonMessages.nextStep} />
</Button>
)}
{step === ProductExportStep.SETTINGS && (
<ConfirmButton
transitionState={confirmButtonState}
variant="contained"
type="submit"
data-test="submit"
>
<FormattedMessage
defaultMessage="export products"
description="export products to csv file, button"
/>
</ConfirmButton>
)}
</DialogActions>
</>
);
}}
</Form>
</Dialog>
);
};
ProductExportDialog.displayName = "ProductExportDialog";
export default ProductExportDialog;

View file

@ -0,0 +1,448 @@
import Button from "@material-ui/core/Button";
import Checkbox from "@material-ui/core/Checkbox";
import CircularProgress from "@material-ui/core/CircularProgress";
import FormControlLabel from "@material-ui/core/FormControlLabel";
import makeStyles from "@material-ui/core/styles/makeStyles";
import TextField from "@material-ui/core/TextField";
import Typography from "@material-ui/core/Typography";
import Accordion, { AccordionProps } from "@saleor/components/Accordion";
import Chip from "@saleor/components/Chip";
import Hr from "@saleor/components/Hr";
import { MultiAutocompleteChoiceType } from "@saleor/components/MultiAutocompleteSelectField";
import { ChangeEvent } from "@saleor/hooks/useForm";
import useSearchQuery from "@saleor/hooks/useSearchQuery";
import { sectionNames } from "@saleor/intl";
import { FetchMoreProps } from "@saleor/types";
import {
ExportProductsInput,
ProductFieldEnum
} from "@saleor/types/globalTypes";
import { toggle } from "@saleor/utils/lists";
import React from "react";
import { useIntl } from "react-intl";
import { FormattedMessage } from "react-intl";
export const attributeNamePrefix = "attribute-";
const maxChips = 4;
const useStyles = makeStyles(
theme => ({
accordion: {
marginBottom: theme.spacing(2)
},
checkbox: {
position: "relative",
right: -theme.spacing(1.5)
},
chip: {
marginBottom: theme.spacing(1),
marginRight: theme.spacing()
},
dialogLabel: {
marginBottom: theme.spacing(2)
},
hr: {
marginBottom: theme.spacing(3),
marginTop: theme.spacing(3)
},
label: {
"&&": {
overflow: "visible"
},
justifyContent: "space-between",
margin: theme.spacing(0),
width: "100%"
},
loadMoreContainer: {
display: "flex",
justifyContent: "center",
marginTop: theme.spacing(2)
},
moreLabel: {
display: "inline-block",
marginBottom: theme.spacing()
},
quickPeekContainer: {
marginBottom: theme.spacing(-1)
}
}),
{
name: "ProductExportDialogInfo"
}
);
const Option: React.FC<{
checked: boolean;
name: string;
onChange: (event: ChangeEvent) => void;
}> = ({ checked, children, name, onChange }) => {
const classes = useStyles({});
return (
<FormControlLabel
color="primary"
control={
<Checkbox
className={classes.checkbox}
checked={checked}
color="primary"
name={name}
onChange={onChange}
/>
}
className={classes.label}
label={children}
labelPlacement="start"
></FormControlLabel>
);
};
const FieldAccordion: React.FC<AccordionProps & {
data: ExportProductsInput;
fields: ProductFieldEnum[];
onChange: (event: ChangeEvent) => void;
onToggleAll: (field: ProductFieldEnum[], setTo: boolean) => void;
}> = ({ data, fields, onChange, onToggleAll, ...props }) => {
const classes = useStyles({});
const intl = useIntl();
const fieldNames: Record<ProductFieldEnum, string> = {
[ProductFieldEnum.CATEGORY]: intl.formatMessage({
defaultMessage: "Category",
description: "product field"
}),
[ProductFieldEnum.CHARGE_TAXES]: intl.formatMessage({
defaultMessage: "Charge Taxes",
description: "product field"
}),
[ProductFieldEnum.COLLECTIONS]: intl.formatMessage({
defaultMessage: "Collections",
description: "product field"
}),
[ProductFieldEnum.COST_PRICE]: intl.formatMessage({
defaultMessage: "Cost Price",
description: "product field"
}),
[ProductFieldEnum.DESCRIPTION]: intl.formatMessage({
defaultMessage: "Description",
description: "product field"
}),
[ProductFieldEnum.NAME]: intl.formatMessage({
defaultMessage: "Name",
description: "product field"
}),
[ProductFieldEnum.PRODUCT_IMAGES]: intl.formatMessage({
defaultMessage: "Product Images",
description: "product field"
}),
[ProductFieldEnum.PRODUCT_TYPE]: intl.formatMessage({
defaultMessage: "Type",
description: "product field"
}),
[ProductFieldEnum.PRODUCT_WEIGHT]: intl.formatMessage({
defaultMessage: "Weight",
description: "product field"
}),
[ProductFieldEnum.VARIANT_IMAGES]: intl.formatMessage({
defaultMessage: "Variant Images",
description: "product field"
}),
[ProductFieldEnum.VARIANT_PRICE]: intl.formatMessage({
defaultMessage: "Variant Price",
description: "product field"
}),
[ProductFieldEnum.VARIANT_SKU]: intl.formatMessage({
defaultMessage: "SKU",
description: "product field"
}),
[ProductFieldEnum.VARIANT_WEIGHT]: intl.formatMessage({
defaultMessage: "Variant Weight",
description: "product field"
}),
[ProductFieldEnum.VISIBLE]: intl.formatMessage({
defaultMessage: "Visibility",
description: "product field"
})
};
const selectedAll = fields.every(field =>
data.exportInfo.fields.includes(field)
);
const selectedFields = data.exportInfo.fields.filter(field =>
fields.includes(field)
);
return (
<Accordion
quickPeek={
selectedFields.length > 0 && (
<div className={classes.quickPeekContainer}>
{selectedFields.slice(0, maxChips).map(field => (
<Chip
className={classes.chip}
label={fieldNames[field]}
onClose={() =>
onChange({
target: {
name: field,
value: false
}
})
}
/>
))}
{selectedFields.length > maxChips && (
<Typography className={classes.moreLabel} variant="caption">
<FormattedMessage
defaultMessage="and {number} more"
description="there are more elements of list that are hidden"
values={{
number: selectedFields.length - maxChips
}}
/>
</Typography>
)}
</div>
)
}
{...props}
>
<Option
checked={selectedAll}
name="all"
onChange={() => onToggleAll(fields, !selectedAll)}
>
<FormattedMessage
defaultMessage="Select All"
description="selectt all options"
/>
</Option>
{fields.map(field => (
<Option
checked={data.exportInfo.fields.includes(field)}
name={field}
onChange={onChange}
key={field}
>
{fieldNames[field]}
</Option>
))}
</Accordion>
);
};
export interface ProductExportDialogInfoProps extends FetchMoreProps {
attributes: MultiAutocompleteChoiceType[];
data: ExportProductsInput;
selectedAttributes: MultiAutocompleteChoiceType[];
onAttrtibuteSelect: (event: ChangeEvent) => void;
onChange: (event: ChangeEvent) => void;
onFetch: (query: string) => void;
}
const ProductExportDialogInfo: React.FC<ProductExportDialogInfoProps> = ({
attributes,
data,
hasMore,
selectedAttributes,
loading,
onAttrtibuteSelect,
onChange,
onFetch,
onFetchMore
}) => {
const classes = useStyles({});
const intl = useIntl();
const [query, onQueryChange] = useSearchQuery(onFetch);
const handleFieldChange = (event: ChangeEvent) =>
onChange({
target: {
name: "exportInfo",
value: {
...data.exportInfo,
fields: toggle(
event.target.name,
data.exportInfo.fields,
(a, b) => a === b
)
}
}
});
const handleToggleAllFields = (fields: ProductFieldEnum[], setTo: boolean) =>
onChange({
target: {
name: "exportInfo",
value: {
...data.exportInfo,
fields: setTo
? [
...data.exportInfo.fields,
...fields.filter(
field => !data.exportInfo.fields.includes(field)
)
]
: data.exportInfo.fields.filter(field => !fields.includes(field))
}
}
});
return (
<>
<Typography className={classes.dialogLabel}>
<FormattedMessage
defaultMessage="Information exported:"
description="select product informations to be exported"
/>
</Typography>
<FieldAccordion
className={classes.accordion}
title={intl.formatMessage({
defaultMessage: "Product Organization",
description: "informations about product organization, header"
})}
data={data}
fields={[
ProductFieldEnum.CATEGORY,
ProductFieldEnum.COLLECTIONS,
ProductFieldEnum.PRODUCT_TYPE
]}
onChange={handleFieldChange}
onToggleAll={handleToggleAllFields}
data-test="organization"
/>
<Accordion
className={classes.accordion}
title={intl.formatMessage(sectionNames.attributes)}
quickPeek={
selectedAttributes.length > 0 && (
<div className={classes.quickPeekContainer}>
{selectedAttributes.slice(0, maxChips).map(attribute => (
<Chip
className={classes.chip}
label={attribute.label}
onClose={() =>
onAttrtibuteSelect({
target: {
name: attributeNamePrefix + attribute.value,
value: undefined
}
})
}
/>
))}
{selectedAttributes.length > maxChips && (
<Typography className={classes.moreLabel} variant="caption">
<FormattedMessage
defaultMessage="and {number} more"
description="there are more elements of list that are hidden"
values={{
number: selectedAttributes.length - maxChips
}}
/>
</Typography>
)}
</div>
)
}
data-test="attributes"
>
<TextField
name="query"
value={query}
onChange={onQueryChange}
label={intl.formatMessage({
defaultMessage: "Search Atrtibuttes"
})}
placeholder={intl.formatMessage({
defaultMessage: "Search by attribute name",
description: "input helper text, search attributes"
})}
fullWidth
InputProps={{
autoComplete: "off",
endAdornment: loading && <CircularProgress size={16} />
}}
/>
<Hr className={classes.hr} />
{attributes.map(attribute => (
<Option
checked={data.exportInfo.attributes.includes(attribute.value)}
name={attributeNamePrefix + attribute.value}
onChange={onAttrtibuteSelect}
key={attribute.value}
>
{attribute.label}
</Option>
))}
{(hasMore || loading) && (
<div className={classes.loadMoreContainer}>
{hasMore && !loading && (
<Button color="primary" onClick={onFetchMore}>
<FormattedMessage
defaultMessage="Load More"
description="button"
/>
</Button>
)}
{loading && <CircularProgress size={32} />}
</div>
)}
</Accordion>
<FieldAccordion
className={classes.accordion}
title={intl.formatMessage({
defaultMessage: "Financial Information",
description: "informations about product prices etc, header"
})}
data={data}
fields={[
ProductFieldEnum.CHARGE_TAXES,
ProductFieldEnum.COST_PRICE,
ProductFieldEnum.VARIANT_PRICE,
ProductFieldEnum.VISIBLE
]}
onChange={handleFieldChange}
onToggleAll={handleToggleAllFields}
data-test="financial"
/>
<FieldAccordion
className={classes.accordion}
title={intl.formatMessage({
defaultMessage: "Inventory Information",
description: "informations about product stock, header"
})}
data={data}
fields={[
ProductFieldEnum.PRODUCT_WEIGHT,
ProductFieldEnum.VARIANT_SKU,
ProductFieldEnum.VARIANT_WEIGHT
]}
onChange={handleFieldChange}
onToggleAll={handleToggleAllFields}
data-test="inventory"
/>
<FieldAccordion
title={intl.formatMessage({
defaultMessage: "SEO Information",
description: "informations about product seo, header"
})}
data={data}
fields={[
ProductFieldEnum.DESCRIPTION,
ProductFieldEnum.NAME,
ProductFieldEnum.PRODUCT_IMAGES,
ProductFieldEnum.VARIANT_IMAGES
]}
onChange={handleFieldChange}
onToggleAll={handleToggleAllFields}
data-test="seo"
/>
</>
);
};
ProductExportDialogInfo.displayName = "ProductExportDialogInfo";
export default ProductExportDialogInfo;

View file

@ -0,0 +1,144 @@
import makeStyles from "@material-ui/core/styles/makeStyles";
import Hr from "@saleor/components/Hr";
import RadioGroupField, {
RadioGroupFieldChoice
} from "@saleor/components/RadioGroupField";
import { ExportErrorFragment } from "@saleor/fragments/types/ExportErrorFragment";
import { ChangeEvent } from "@saleor/hooks/useForm";
import {
ExportProductsInput,
ExportScope,
FileTypesEnum
} from "@saleor/types/globalTypes";
import { getFormErrors } from "@saleor/utils/errors";
import getExportErrorMessage from "@saleor/utils/errors/export";
import React from "react";
import { useIntl } from "react-intl";
const useStyles = makeStyles(
theme => ({
hr: {
marginBottom: theme.spacing(3),
marginTop: theme.spacing(3)
}
}),
{
name: "ProductExportDialogSettings"
}
);
export type ProductQuantity = Record<"all" | "filter", number>;
export interface ProductExportDialogSettingsProps {
data: ExportProductsInput;
errors: ExportErrorFragment[];
productQuantity: ProductQuantity;
selectedProducts: number;
onChange: (event: ChangeEvent) => void;
}
const formFields: Array<keyof ExportProductsInput> = ["fileType", "scope"];
const ProductExportDialogSettings: React.FC<ProductExportDialogSettingsProps> = ({
data,
errors,
onChange,
productQuantity,
selectedProducts
}) => {
const classes = useStyles({});
const intl = useIntl();
const formErrors = getFormErrors(formFields, errors);
const productsToExportChoices: Array<RadioGroupFieldChoice<ExportScope>> = [
{
label: intl.formatMessage(
{
defaultMessage: "All products ({number})",
description: "export all products to csv file"
},
{
number: productQuantity.all || "..."
}
),
value: ExportScope.ALL
},
{
disabled: selectedProducts === 0,
label: intl.formatMessage(
{
defaultMessage: "Selected products ({number})",
description: "export selected products to csv file"
},
{
number: selectedProducts
}
),
value: ExportScope.IDS
},
{
label: intl.formatMessage(
{
defaultMessage: "Current search ({number})",
description: "export filtered products to csv file"
},
{
number: productQuantity.filter || "..."
}
),
value: ExportScope.FILTER
}
];
const productExportTypeChoices: Array<RadioGroupFieldChoice<
FileTypesEnum
>> = [
{
label: intl.formatMessage({
defaultMessage: "Spreadsheet for Excel, Numbers etc.",
description: "export products as spreadsheet"
}),
value: FileTypesEnum.XLSX
},
{
label: intl.formatMessage({
defaultMessage: "Plain CSV file",
description: "export products as csv file"
}),
value: FileTypesEnum.CSV
}
];
return (
<>
<RadioGroupField
choices={productsToExportChoices}
error={!!formErrors.scope}
hint={getExportErrorMessage(formErrors.scope, intl)}
label={intl.formatMessage({
defaultMessage: "Export information for:",
description: "export products to csv file, choice field label"
})}
name={"scope" as keyof ExportProductsInput}
onChange={onChange}
value={data.scope}
/>
<Hr className={classes.hr} />
<RadioGroupField
choices={productExportTypeChoices}
error={!!formErrors.fileType}
hint={getExportErrorMessage(formErrors.fileType, intl)}
label={intl.formatMessage({
defaultMessage: "Export as:",
description: "export products as csv or spreadsheet file"
})}
name={"fileType" as keyof ExportProductsInput}
onChange={onChange}
value={data.fileType}
/>
</>
);
};
ProductExportDialogSettings.displayName = "ProductExportDialogSettings";
export default ProductExportDialogSettings;

View file

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

View file

@ -1,6 +1,7 @@
import Button from "@material-ui/core/Button"; import Button from "@material-ui/core/Button";
import Card from "@material-ui/core/Card"; import Card from "@material-ui/core/Card";
import makeStyles from "@material-ui/core/styles/makeStyles"; import makeStyles from "@material-ui/core/styles/makeStyles";
import CardMenu from "@saleor/components/CardMenu";
import ColumnPicker, { import ColumnPicker, {
ColumnPickerChoice ColumnPickerChoice
} from "@saleor/components/ColumnPicker"; } from "@saleor/components/ColumnPicker";
@ -44,12 +45,13 @@ export interface ProductListPageProps
gridAttributes: GridAttributes_grid_edges_node[]; gridAttributes: GridAttributes_grid_edges_node[];
totalGridAttributes: number; totalGridAttributes: number;
products: ProductList_products_edges_node[]; products: ProductList_products_edges_node[];
onExport: () => void;
} }
const useStyles = makeStyles( const useStyles = makeStyles(
theme => ({ theme => ({
columnPicker: { columnPicker: {
marginRight: theme.spacing(3) margin: theme.spacing(0, 3)
} }
}), }),
{ name: "ProductListPage" } { name: "ProductListPage" }
@ -71,6 +73,7 @@ export const ProductListPage: React.FC<ProductListPageProps> = props => {
totalGridAttributes, totalGridAttributes,
onAdd, onAdd,
onAll, onAll,
onExport,
onFetchMore, onFetchMore,
onFilterChange, onFilterChange,
onSearchChange, onSearchChange,
@ -119,6 +122,19 @@ export const ProductListPage: React.FC<ProductListPageProps> = props => {
return ( return (
<Container> <Container>
<PageHeader title={intl.formatMessage(sectionNames.products)}> <PageHeader title={intl.formatMessage(sectionNames.products)}>
<CardMenu
menuItems={[
{
label: intl.formatMessage({
defaultMessage: "Export Products",
description: "export products to csv file, button"
}),
onSelect: onExport,
testId: "export"
}
]}
data-test="menu"
/>
<ColumnPicker <ColumnPicker
className={classes.columnPicker} className={classes.columnPicker}
columns={columns} columns={columns}

View file

@ -1,16 +1,12 @@
import { makeStyles } from "@material-ui/core/styles"; import makeCreatorSteps, { Step } from "@saleor/components/CreatorSteps";
import Typography from "@material-ui/core/Typography";
import classNames from "classnames";
import React from "react"; import React from "react";
import { IntlShape, useIntl } from "react-intl"; import { useIntl } from "react-intl";
import { ProductVariantCreatorStep } from "./types"; import { ProductVariantCreatorStep } from "./types";
interface Step { function useSteps(): Array<Step<ProductVariantCreatorStep>> {
label: string; const intl = useIntl();
value: ProductVariantCreatorStep;
}
function getSteps(intl: IntlShape): Step[] {
return [ return [
{ {
label: intl.formatMessage({ label: intl.formatMessage({
@ -36,69 +32,27 @@ function getSteps(intl: IntlShape): Step[] {
]; ];
} }
const useStyles = makeStyles( const ProductVariantCreatorSteps = makeCreatorSteps<
theme => ({ ProductVariantCreatorStep
label: { >();
fontSize: 14,
textAlign: "center"
},
root: {
borderBottom: `1px solid ${theme.palette.divider}`,
display: "flex",
justifyContent: "space-between",
marginBottom: theme.spacing(3)
},
tab: {
flex: 1,
paddingBottom: theme.spacing(),
userSelect: "none"
},
tabActive: {
fontWeight: 600
},
tabVisited: {
borderBottom: `3px solid ${theme.palette.primary.main}`,
cursor: "pointer"
}
}),
{
name: "ProductVariantCreatorTabs"
}
);
export interface ProductVariantCreatorTabsProps { export interface ProductVariantCreatorTabsProps {
step: ProductVariantCreatorStep; step: ProductVariantCreatorStep;
onStepClick: (step: ProductVariantCreatorStep) => void; onStepClick: (step: ProductVariantCreatorStep) => void;
} }
const ProductVariantCreatorTabs: React.FC<ProductVariantCreatorTabsProps> = props => { const ProductVariantCreatorTabs: React.FC<ProductVariantCreatorTabsProps> = ({
const { step: currentStep, onStepClick } = props; step: currentStep,
const classes = useStyles(props); onStepClick
const intl = useIntl(); }) => {
const steps = getSteps(intl); const steps = useSteps();
return ( return (
<div className={classes.root}> <ProductVariantCreatorSteps
{steps.map((step, stepIndex) => { currentStep={currentStep}
const visitedStep = steps={steps}
steps.findIndex(step => step.value === currentStep) >= stepIndex; onStepClick={onStepClick}
/>
return (
<div
className={classNames(classes.tab, {
[classes.tabActive]: step.value === currentStep,
[classes.tabVisited]: visitedStep
})}
onClick={visitedStep ? () => onStepClick(step.value) : undefined}
key={step.value}
>
<Typography className={classes.label} variant="caption">
{step.label}
</Typography>
</div>
);
})}
</div>
); );
}; };

View file

@ -1,10 +1,12 @@
import { import {
bulkProductErrorFragment, bulkProductErrorFragment,
bulkStockErrorFragment, bulkStockErrorFragment,
exportErrorFragment,
productErrorFragment, productErrorFragment,
stockErrorFragment stockErrorFragment
} from "@saleor/fragments/errors"; } from "@saleor/fragments/errors";
import { import {
exportFileFragment,
fragmentVariant, fragmentVariant,
productFragmentDetails productFragmentDetails
} from "@saleor/fragments/products"; } from "@saleor/fragments/products";
@ -22,6 +24,7 @@ import {
} from "./types/productBulkPublish"; } from "./types/productBulkPublish";
import { ProductCreate, ProductCreateVariables } from "./types/ProductCreate"; import { ProductCreate, ProductCreateVariables } from "./types/ProductCreate";
import { ProductDelete, ProductDeleteVariables } from "./types/ProductDelete"; import { ProductDelete, ProductDeleteVariables } from "./types/ProductDelete";
import { ProductExport, ProductExportVariables } from "./types/ProductExport";
import { import {
ProductImageCreate, ProductImageCreate,
ProductImageCreateVariables ProductImageCreateVariables
@ -567,3 +570,22 @@ export const TypedProductVariantBulkDeleteMutation = TypedMutation<
ProductVariantBulkDelete, ProductVariantBulkDelete,
ProductVariantBulkDeleteVariables ProductVariantBulkDeleteVariables
>(ProductVariantBulkDeleteMutation); >(ProductVariantBulkDeleteMutation);
export const productExportMutation = gql`
${exportFileFragment}
${exportErrorFragment}
mutation ProductExport($input: ExportProductsInput!) {
exportProducts(input: $input) {
exportFile {
...ExportFileFragment
}
errors: exportErrors {
...ExportErrorFragment
}
}
}
`;
export const useProductExport = makeMutation<
ProductExport,
ProductExportVariables
>(productExportMutation);

View file

@ -11,6 +11,7 @@ import makeQuery from "@saleor/hooks/makeQuery";
import gql from "graphql-tag"; import gql from "graphql-tag";
import { TypedQuery } from "../queries"; import { TypedQuery } from "../queries";
import { CountAllProducts } from "./types/CountAllProducts";
import { import {
CreateMultipleVariantsData, CreateMultipleVariantsData,
CreateMultipleVariantsDataVariables CreateMultipleVariantsDataVariables
@ -145,6 +146,7 @@ const productListQuery = gql`
startCursor startCursor
endCursor endCursor
} }
totalCount
} }
} }
`; `;
@ -153,6 +155,17 @@ export const TypedProductListQuery = TypedQuery<
ProductListVariables ProductListVariables
>(productListQuery); >(productListQuery);
const countAllProductsQuery = gql`
query CountAllProducts {
products {
totalCount
}
}
`;
export const useCountAllProducts = makeQuery<CountAllProducts, null>(
countAllProductsQuery
);
const productDetailsQuery = gql` const productDetailsQuery = gql`
${productFragmentDetails} ${productFragmentDetails}
query ProductDetails($id: ID!) { query ProductDetails($id: ID!) {

View file

@ -0,0 +1,16 @@
/* tslint:disable */
/* eslint-disable */
// This file was automatically generated and should not be edited.
// ====================================================
// GraphQL query operation: CountAllProducts
// ====================================================
export interface CountAllProducts_products {
__typename: "ProductCountableConnection";
totalCount: number | null;
}
export interface CountAllProducts {
products: CountAllProducts_products | null;
}

View file

@ -0,0 +1,36 @@
/* tslint:disable */
/* eslint-disable */
// This file was automatically generated and should not be edited.
import { ExportProductsInput, JobStatusEnum, ExportErrorCode } from "./../../types/globalTypes";
// ====================================================
// GraphQL mutation operation: ProductExport
// ====================================================
export interface ProductExport_exportProducts_exportFile {
__typename: "ExportFile";
id: string;
status: JobStatusEnum;
url: string | null;
}
export interface ProductExport_exportProducts_errors {
__typename: "ExportError";
code: ExportErrorCode;
field: string | null;
}
export interface ProductExport_exportProducts {
__typename: "ExportProducts";
exportFile: ProductExport_exportProducts_exportFile | null;
errors: ProductExport_exportProducts_errors[];
}
export interface ProductExport {
exportProducts: ProductExport_exportProducts | null;
}
export interface ProductExportVariables {
input: ExportProductsInput;
}

View file

@ -99,6 +99,7 @@ export interface ProductList_products {
__typename: "ProductCountableConnection"; __typename: "ProductCountableConnection";
edges: ProductList_products_edges[]; edges: ProductList_products_edges[];
pageInfo: ProductList_products_pageInfo; pageInfo: ProductList_products_pageInfo;
totalCount: number | null;
} }
export interface ProductList { export interface ProductList {

View file

@ -23,6 +23,7 @@ export type ProductListUrlDialog =
| "publish" | "publish"
| "unpublish" | "unpublish"
| "delete" | "delete"
| "export"
| TabActionDialog; | TabActionDialog;
export enum ProductListUrlFiltersEnum { export enum ProductListUrlFiltersEnum {
priceFrom = "priceFrom", priceFrom = "priceFrom",

View file

@ -13,6 +13,8 @@ import {
defaultListSettings, defaultListSettings,
ProductListColumns ProductListColumns
} from "@saleor/config"; } from "@saleor/config";
import { Task } from "@saleor/containers/BackgroundTasks/types";
import useBackgroundTask from "@saleor/hooks/useBackgroundTask";
import useBulkActions from "@saleor/hooks/useBulkActions"; import useBulkActions from "@saleor/hooks/useBulkActions";
import useListSettings from "@saleor/hooks/useListSettings"; import useListSettings from "@saleor/hooks/useListSettings";
import useNavigator from "@saleor/hooks/useNavigator"; import useNavigator from "@saleor/hooks/useNavigator";
@ -23,11 +25,13 @@ import usePaginator, {
import useShop from "@saleor/hooks/useShop"; import useShop from "@saleor/hooks/useShop";
import { commonMessages } from "@saleor/intl"; import { commonMessages } from "@saleor/intl";
import { maybe } from "@saleor/misc"; import { maybe } from "@saleor/misc";
import ProductExportDialog from "@saleor/products/components/ProductExportDialog";
import { import {
getAttributeIdFromColumnValue, getAttributeIdFromColumnValue,
isAttributeColumnValue isAttributeColumnValue
} from "@saleor/products/components/ProductListPage/utils"; } from "@saleor/products/components/ProductListPage/utils";
import { ProductListVariables } from "@saleor/products/types/ProductList"; import { ProductListVariables } from "@saleor/products/types/ProductList";
import useAttributeSearch from "@saleor/searches/useAttributeSearch";
import useCategorySearch from "@saleor/searches/useCategorySearch"; import useCategorySearch from "@saleor/searches/useCategorySearch";
import useCollectionSearch from "@saleor/searches/useCollectionSearch"; import useCollectionSearch from "@saleor/searches/useCollectionSearch";
import useProductTypeSearch from "@saleor/searches/useProductTypeSearch"; import useProductTypeSearch from "@saleor/searches/useProductTypeSearch";
@ -41,11 +45,13 @@ import { FormattedMessage, useIntl } from "react-intl";
import ProductListPage from "../../components/ProductListPage"; import ProductListPage from "../../components/ProductListPage";
import { import {
TypedProductBulkDeleteMutation, TypedProductBulkDeleteMutation,
TypedProductBulkPublishMutation TypedProductBulkPublishMutation,
useProductExport
} from "../../mutations"; } from "../../mutations";
import { import {
AvailableInGridAttributesQuery, AvailableInGridAttributesQuery,
TypedProductListQuery, TypedProductListQuery,
useCountAllProducts,
useInitialProductFilterDataQuery useInitialProductFilterDataQuery
} from "../../queries"; } from "../../queries";
import { productBulkDelete } from "../../types/productBulkDelete"; import { productBulkDelete } from "../../types/productBulkDelete";
@ -78,6 +84,7 @@ export const ProductList: React.FC<ProductListProps> = ({ params }) => {
const navigate = useNavigator(); const navigate = useNavigator();
const notify = useNotifier(); const notify = useNotifier();
const paginate = usePaginator(); const paginate = usePaginator();
const { queue } = useBackgroundTask();
const shop = useShop(); const shop = useShop();
const { isSelected, listElements, reset, toggle, toggleAll } = useBulkActions( const { isSelected, listElements, reset, toggle, toggleAll } = useBulkActions(
params.ids params.ids
@ -111,6 +118,12 @@ export const ProductList: React.FC<ProductListProps> = ({ params }) => {
first: 5 first: 5
} }
}); });
const searchAttributes = useAttributeSearch({
variables: {
...DEFAULT_INITIAL_SEARCH_DATA,
first: 10
}
});
React.useEffect( React.useEffect(
() => () =>
@ -137,6 +150,29 @@ export const ProductList: React.FC<ProductListProps> = ({ params }) => {
ProductListUrlDialog, ProductListUrlDialog,
ProductListUrlQueryParams ProductListUrlQueryParams
>(navigate, productListUrl, params); >(navigate, productListUrl, params);
const countAllProducts = useCountAllProducts({});
const [exportProducts, exportProductsOpts] = useProductExport({
onCompleted: data => {
if (data.exportProducts.errors.length === 0) {
notify({
text: intl.formatMessage({
defaultMessage:
"We are currently exporting your requested CSV. As soon as it is available it will be sent to your email address"
}),
title: intl.formatMessage({
defaultMessage: "Exporting CSV",
description: "waiting for export to end, header"
})
});
queue(Task.EXPORT, {
id: data.exportProducts.exportFile.id
});
closeModal();
reset();
}
}
});
const [ const [
changeFilters, changeFilters,
@ -398,6 +434,7 @@ export const ProductList: React.FC<ProductListProps> = ({ params }) => {
onTabChange={handleTabChange} onTabChange={handleTabChange}
initialSearch={params.query || ""} initialSearch={params.query || ""}
tabs={getFilterTabs().map(tab => tab.name)} tabs={getFilterTabs().map(tab => tab.name)}
onExport={() => openModal("export")}
/> />
<ActionDialog <ActionDialog
open={params.action === "delete"} open={params.action === "delete"}
@ -493,6 +530,40 @@ export const ProductList: React.FC<ProductListProps> = ({ params }) => {
/> />
</DialogContentText> </DialogContentText>
</ActionDialog> </ActionDialog>
<ProductExportDialog
attributes={(
searchAttributes.result.data?.search.edges || []
).map(edge => edge.node)}
hasMore={
searchAttributes.result.data?.search.pageInfo
.hasNextPage
}
loading={searchAttributes.result.loading}
onFetch={searchAttributes.search}
onFetchMore={searchAttributes.loadMore}
open={params.action === "export"}
confirmButtonState={exportProductsOpts.status}
errors={
exportProductsOpts.data?.exportProducts.errors || []
}
productQuantity={{
all: countAllProducts.data?.products.totalCount,
filter: data?.products.totalCount
}}
selectedProducts={listElements.length}
onClose={closeModal}
onSubmit={data =>
exportProducts({
variables: {
input: {
...data,
filter,
ids: listElements
}
}
})
}
/>
<SaveFilterTabDialog <SaveFilterTabDialog
open={params.action === "save-search"} open={params.action === "save-search"}
confirmButtonState="default" confirmButtonState="default"

View file

@ -0,0 +1,42 @@
/* tslint:disable */
/* eslint-disable */
// This file was automatically generated and should not be edited.
// ====================================================
// GraphQL query operation: SearchAttributes
// ====================================================
export interface SearchAttributes_search_edges_node {
__typename: "Attribute";
id: string;
name: string | null;
}
export interface SearchAttributes_search_edges {
__typename: "AttributeCountableEdge";
node: SearchAttributes_search_edges_node;
}
export interface SearchAttributes_search_pageInfo {
__typename: "PageInfo";
endCursor: string | null;
hasNextPage: boolean;
hasPreviousPage: boolean;
startCursor: string | null;
}
export interface SearchAttributes_search {
__typename: "AttributeCountableConnection";
edges: SearchAttributes_search_edges[];
pageInfo: SearchAttributes_search_pageInfo;
}
export interface SearchAttributes {
search: SearchAttributes_search | null;
}
export interface SearchAttributesVariables {
after?: string | null;
first: number;
query: string;
}

View file

@ -0,0 +1,33 @@
import { pageInfoFragment } from "@saleor/fragments/pageInfo";
import makeTopLevelSearch from "@saleor/hooks/makeTopLevelSearch";
import gql from "graphql-tag";
import {
SearchAttributes,
SearchAttributesVariables
} from "./types/SearchAttributes";
export const searchAttributes = gql`
${pageInfoFragment}
query SearchAttributes($after: String, $first: Int!, $query: String!) {
search: attributes(
after: $after
first: $first
filter: { search: $query }
) {
edges {
node {
id
name
}
}
pageInfo {
...PageInfoFragment
}
}
}
`;
export default makeTopLevelSearch<SearchAttributes, SearchAttributesVariables>(
searchAttributes
);

View file

@ -48,6 +48,184 @@ exports[`Storyshots Discounts / Select countries default 1`] = `
/> />
`; `;
exports[`Storyshots Generics / Accordion default 1`] = `
<div
class="MuiPaper-root-id MuiPaper-elevation0-id MuiCard-root-id MuiPaper-rounded-id"
style="margin:auto;overflow:visible;position:relative;width:400px"
>
<div
class="MuiCardContent-root-id"
>
<div
style="padding:24px"
>
<div
class="Accordion-root-id"
>
<div
class="Accordion-title-id"
>
<div
class="MuiTypography-root-id Accordion-titleText-id MuiTypography-body1-id"
>
Title
</div>
<div
class="Accordion-expandButton-id"
>
<button
class="MuiButtonBase-root-id MuiIconButton-root-id"
tabindex="0"
type="button"
>
<span
class="MuiIconButton-label-id"
>
<svg
aria-hidden="true"
class="MuiSvgIcon-root-id"
focusable="false"
role="presentation"
viewBox="0 0 24 24"
>
<path
d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"
/>
</svg>
</span>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
`;
exports[`Storyshots Generics / Accordion opened 1`] = `
<div
class="MuiPaper-root-id MuiPaper-elevation0-id MuiCard-root-id MuiPaper-rounded-id"
style="margin:auto;overflow:visible;position:relative;width:400px"
>
<div
class="MuiCardContent-root-id"
>
<div
style="padding:24px"
>
<div
class="Accordion-root-id"
>
<div
class="Accordion-title-id"
>
<div
class="MuiTypography-root-id Accordion-titleText-id MuiTypography-body1-id"
>
Title
</div>
<div
class="Accordion-expandButton-id"
>
<button
class="MuiButtonBase-root-id MuiIconButton-root-id"
tabindex="0"
type="button"
>
<span
class="MuiIconButton-label-id"
>
<svg
aria-hidden="true"
class="MuiSvgIcon-root-id"
focusable="false"
role="presentation"
viewBox="0 0 24 24"
>
<path
d="M19 13H5v-2h14v2z"
/>
</svg>
</span>
</button>
</div>
</div>
<hr
class="Hr-root-id"
/>
<div
class="Accordion-content-id"
>
Content
</div>
</div>
</div>
</div>
</div>
`;
exports[`Storyshots Generics / Accordion with quick peek 1`] = `
<div
class="MuiPaper-root-id MuiPaper-elevation0-id MuiCard-root-id MuiPaper-rounded-id"
style="margin:auto;overflow:visible;position:relative;width:400px"
>
<div
class="MuiCardContent-root-id"
>
<div
style="padding:24px"
>
<div
class="Accordion-root-id"
>
<div
class="Accordion-title-id"
>
<div
class="MuiTypography-root-id Accordion-titleText-id MuiTypography-body1-id"
>
Title
</div>
<div
class="Accordion-expandButton-id"
>
<button
class="MuiButtonBase-root-id MuiIconButton-root-id"
tabindex="0"
type="button"
>
<span
class="MuiIconButton-label-id"
>
<svg
aria-hidden="true"
class="MuiSvgIcon-root-id"
focusable="false"
role="presentation"
viewBox="0 0 24 24"
>
<path
d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"
/>
</svg>
</span>
</button>
</div>
</div>
<hr
class="Hr-root-id"
/>
<div
class="Accordion-content-id"
>
Quick Peek
</div>
</div>
</div>
</div>
</div>
`;
exports[`Storyshots Generics / Account Permission Groups Widget default 1`] = ` exports[`Storyshots Generics / Account Permission Groups Widget default 1`] = `
<div <div
style="padding:24px" style="padding:24px"
@ -113818,31 +113996,31 @@ exports[`Storyshots Views / Products / Create multiple variants interactive 1`]
class="Container-root-id" class="Container-root-id"
> >
<div <div
class="ProductVariantCreatorTabs-root-id" class="CreatorSteps-root-id"
> >
<div <div
class="ProductVariantCreatorTabs-tab-id ProductVariantCreatorTabs-tabActive-id ProductVariantCreatorTabs-tabVisited-id" class="CreatorSteps-tab-id CreatorSteps-tabActive-id CreatorSteps-tabVisited-id"
> >
<div <div
class="MuiTypography-root-id ProductVariantCreatorTabs-label-id MuiTypography-caption-id" class="MuiTypography-root-id CreatorSteps-label-id MuiTypography-caption-id"
> >
Select Values Select Values
</div> </div>
</div> </div>
<div <div
class="ProductVariantCreatorTabs-tab-id" class="CreatorSteps-tab-id"
> >
<div <div
class="MuiTypography-root-id ProductVariantCreatorTabs-label-id MuiTypography-caption-id" class="MuiTypography-root-id CreatorSteps-label-id MuiTypography-caption-id"
> >
Prices and SKU Prices and SKU
</div> </div>
</div> </div>
<div <div
class="ProductVariantCreatorTabs-tab-id" class="CreatorSteps-tab-id"
> >
<div <div
class="MuiTypography-root-id ProductVariantCreatorTabs-label-id MuiTypography-caption-id" class="MuiTypography-root-id CreatorSteps-label-id MuiTypography-caption-id"
> >
Summary Summary
</div> </div>
@ -119378,6 +119556,24 @@ exports[`Storyshots Views / Products / Create product variant with errors 1`] =
</div> </div>
`; `;
exports[`Storyshots Views / Products / Export / Export settings errors 1`] = `
<div
style="padding:24px"
/>
`;
exports[`Storyshots Views / Products / Export / Export settings interactive 1`] = `
<div
style="padding:24px"
/>
`;
exports[`Storyshots Views / Products / Export / Export settings no products selected 1`] = `
<div
style="padding:24px"
/>
`;
exports[`Storyshots Views / Products / Product edit form errors 1`] = ` exports[`Storyshots Views / Products / Product edit form errors 1`] = `
<div <div
style="padding:24px" style="padding:24px"
@ -133368,6 +133564,33 @@ exports[`Storyshots Views / Products / Product list default 1`] = `
<div <div
class="PageHeader-root-id" class="PageHeader-root-id"
> >
<div
data-test="menu"
>
<button
aria-haspopup="true"
aria-label="More"
class="MuiButtonBase-root-id MuiIconButton-root-id CardMenu-iconButton-id MuiIconButton-colorPrimary-id"
tabindex="0"
type="button"
>
<span
class="MuiIconButton-label-id"
>
<svg
aria-hidden="true"
class="MuiSvgIcon-root-id"
focusable="false"
role="presentation"
viewBox="0 0 24 24"
>
<path
d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"
/>
</svg>
</span>
</button>
</div>
<div <div
class="ProductListPage-columnPicker-id" class="ProductListPage-columnPicker-id"
> >
@ -135289,6 +135512,33 @@ exports[`Storyshots Views / Products / Product list loading 1`] = `
<div <div
class="PageHeader-root-id" class="PageHeader-root-id"
> >
<div
data-test="menu"
>
<button
aria-haspopup="true"
aria-label="More"
class="MuiButtonBase-root-id MuiIconButton-root-id CardMenu-iconButton-id MuiIconButton-colorPrimary-id"
tabindex="0"
type="button"
>
<span
class="MuiIconButton-label-id"
>
<svg
aria-hidden="true"
class="MuiSvgIcon-root-id"
focusable="false"
role="presentation"
viewBox="0 0 24 24"
>
<path
d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"
/>
</svg>
</span>
</button>
</div>
<div <div
class="ProductListPage-columnPicker-id" class="ProductListPage-columnPicker-id"
> >
@ -135788,6 +136038,33 @@ exports[`Storyshots Views / Products / Product list no data 1`] = `
<div <div
class="PageHeader-root-id" class="PageHeader-root-id"
> >
<div
data-test="menu"
>
<button
aria-haspopup="true"
aria-label="More"
class="MuiButtonBase-root-id MuiIconButton-root-id CardMenu-iconButton-id MuiIconButton-colorPrimary-id"
tabindex="0"
type="button"
>
<span
class="MuiIconButton-label-id"
>
<svg
aria-hidden="true"
class="MuiSvgIcon-root-id"
focusable="false"
role="presentation"
viewBox="0 0 24 24"
>
<path
d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"
/>
</svg>
</span>
</button>
</div>
<div <div
class="ProductListPage-columnPicker-id" class="ProductListPage-columnPicker-id"
> >
@ -136183,6 +136460,33 @@ exports[`Storyshots Views / Products / Product list not published 1`] = `
<div <div
class="PageHeader-root-id" class="PageHeader-root-id"
> >
<div
data-test="menu"
>
<button
aria-haspopup="true"
aria-label="More"
class="MuiButtonBase-root-id MuiIconButton-root-id CardMenu-iconButton-id MuiIconButton-colorPrimary-id"
tabindex="0"
type="button"
>
<span
class="MuiIconButton-label-id"
>
<svg
aria-hidden="true"
class="MuiSvgIcon-root-id"
focusable="false"
role="presentation"
viewBox="0 0 24 24"
>
<path
d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"
/>
</svg>
</span>
</button>
</div>
<div <div
class="ProductListPage-columnPicker-id" class="ProductListPage-columnPicker-id"
> >
@ -136964,6 +137268,33 @@ exports[`Storyshots Views / Products / Product list published 1`] = `
<div <div
class="PageHeader-root-id" class="PageHeader-root-id"
> >
<div
data-test="menu"
>
<button
aria-haspopup="true"
aria-label="More"
class="MuiButtonBase-root-id MuiIconButton-root-id CardMenu-iconButton-id MuiIconButton-colorPrimary-id"
tabindex="0"
type="button"
>
<span
class="MuiIconButton-label-id"
>
<svg
aria-hidden="true"
class="MuiSvgIcon-root-id"
focusable="false"
role="presentation"
viewBox="0 0 24 24"
>
<path
d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"
/>
</svg>
</span>
</button>
</div>
<div <div
class="ProductListPage-columnPicker-id" class="ProductListPage-columnPicker-id"
> >

View file

@ -1,38 +0,0 @@
import { formError } from "@saleor/storybook/misc";
import { storiesOf } from "@storybook/react";
import React from "react";
import ProductTypeAttributeEditDialog, {
ProductTypeAttributeEditDialogProps
} from "../../../productTypes/components/ProductTypeAttributeEditDialog";
import { attributes } from "../../../productTypes/fixtures";
import Decorator from "../../Decorator";
const attribute = attributes[0];
const props: ProductTypeAttributeEditDialogProps = {
disabled: false,
errors: [],
name: attribute.name,
onClose: () => undefined,
onConfirm: () => undefined,
opened: true,
title: "Add Attribute",
values: attribute.values.map(value => ({
label: value.name,
value: value.id
}))
};
storiesOf("Product types / Edit attribute", module)
.addDecorator(Decorator)
.add("default", () => <ProductTypeAttributeEditDialog {...props} />)
.add("loading", () => (
<ProductTypeAttributeEditDialog {...props} disabled={true} />
))
.add("form errors", () => (
<ProductTypeAttributeEditDialog
{...props}
errors={["name", "values"].map(field => formError(field))}
/>
));

View file

@ -39,6 +39,7 @@ const props: ProductListPageProps = {
defaultSettings: defaultListSettings[ListViews.PRODUCT_LIST], defaultSettings: defaultListSettings[ListViews.PRODUCT_LIST],
filterOpts: productListFilterOpts, filterOpts: productListFilterOpts,
gridAttributes: attributes, gridAttributes: attributes,
onExport: () => undefined,
products, products,
settings: { settings: {
...pageListProps.default.settings, ...pageListProps.default.settings,

View file

@ -401,6 +401,23 @@ export enum DiscountValueTypeEnum {
PERCENTAGE = "PERCENTAGE", PERCENTAGE = "PERCENTAGE",
} }
export enum ExportErrorCode {
INVALID = "INVALID",
NOT_FOUND = "NOT_FOUND",
REQUIRED = "REQUIRED",
}
export enum ExportScope {
ALL = "ALL",
FILTER = "FILTER",
IDS = "IDS",
}
export enum FileTypesEnum {
CSV = "CSV",
XLSX = "XLSX",
}
export enum FulfillmentStatus { export enum FulfillmentStatus {
CANCELED = "CANCELED", CANCELED = "CANCELED",
FULFILLED = "FULFILLED", FULFILLED = "FULFILLED",
@ -668,6 +685,23 @@ export enum ProductErrorCode {
VARIANT_NO_DIGITAL_CONTENT = "VARIANT_NO_DIGITAL_CONTENT", VARIANT_NO_DIGITAL_CONTENT = "VARIANT_NO_DIGITAL_CONTENT",
} }
export enum ProductFieldEnum {
CATEGORY = "CATEGORY",
CHARGE_TAXES = "CHARGE_TAXES",
COLLECTIONS = "COLLECTIONS",
COST_PRICE = "COST_PRICE",
DESCRIPTION = "DESCRIPTION",
NAME = "NAME",
PRODUCT_IMAGES = "PRODUCT_IMAGES",
PRODUCT_TYPE = "PRODUCT_TYPE",
PRODUCT_WEIGHT = "PRODUCT_WEIGHT",
VARIANT_IMAGES = "VARIANT_IMAGES",
VARIANT_PRICE = "VARIANT_PRICE",
VARIANT_SKU = "VARIANT_SKU",
VARIANT_WEIGHT = "VARIANT_WEIGHT",
VISIBLE = "VISIBLE",
}
export enum ProductOrderField { export enum ProductOrderField {
DATE = "DATE", DATE = "DATE",
MINIMAL_PRICE = "MINIMAL_PRICE", MINIMAL_PRICE = "MINIMAL_PRICE",
@ -1079,6 +1113,20 @@ export interface DraftOrderInput {
customerNote?: string | null; customerNote?: string | null;
} }
export interface ExportInfoInput {
attributes?: string[] | null;
warehouses?: string[] | null;
fields?: ProductFieldEnum[] | null;
}
export interface ExportProductsInput {
scope: ExportScope;
filter?: ProductFilterInput | null;
ids?: string[] | null;
exportInfo?: ExportInfoInput | null;
fileType: FileTypesEnum;
}
export interface FulfillmentCancelInput { export interface FulfillmentCancelInput {
warehouseId: string; warehouseId: string;
} }

View file

@ -0,0 +1,24 @@
import { ExportErrorFragment } from "@saleor/fragments/types/ExportErrorFragment";
import { commonMessages } from "@saleor/intl";
import { ExportErrorCode } from "@saleor/types/globalTypes";
import { IntlShape } from "react-intl";
import commonErrorMessages from "./common";
function getExportErrorMessage(
err: Omit<ExportErrorFragment, "__typename"> | undefined,
intl: IntlShape
): string {
if (err) {
switch (err.code) {
case ExportErrorCode.REQUIRED:
return intl.formatMessage(commonMessages.requiredField);
default:
return intl.formatMessage(commonErrorMessages.unknownError);
}
}
return undefined;
}
export default getExportErrorMessage;

View file

@ -1,6 +1,7 @@
import { MultiAutocompleteChoiceType } from "@saleor/components/MultiAutocompleteSelectField"; import { MultiAutocompleteChoiceType } from "@saleor/components/MultiAutocompleteSelectField";
import { ShopInfo_shop_countries } from "@saleor/components/Shop/types/ShopInfo"; import { ShopInfo_shop_countries } from "@saleor/components/Shop/types/ShopInfo";
import { SingleAutocompleteChoiceType } from "@saleor/components/SingleAutocompleteSelectField"; import { SingleAutocompleteChoiceType } from "@saleor/components/SingleAutocompleteSelectField";
import { Node } from "@saleor/types";
export function mapCountriesToChoices( export function mapCountriesToChoices(
countries: ShopInfo_shop_countries[] countries: ShopInfo_shop_countries[]
@ -10,3 +11,12 @@ export function mapCountriesToChoices(
value: country.code value: country.code
})); }));
} }
export function mapNodeToChoice(
nodes: Array<Node & Record<"name", string>>
): Array<SingleAutocompleteChoiceType | MultiAutocompleteChoiceType> {
return nodes.map(node => ({
label: node.name,
value: node.id
}));
}

View file

@ -1,6 +1,5 @@
import clone from "lodash-es/clone";
import { IFilter } from "@saleor/components/Filter"; import { IFilter } from "@saleor/components/Filter";
import clone from "lodash-es/clone";
export function getExistingKeys(o: object): string[] { export function getExistingKeys(o: object): string[] {
return Object.keys(o).filter(key => o[key] !== undefined && o[key] !== null); return Object.keys(o).filter(key => o[key] !== undefined && o[key] !== null);