diff --git a/CHANGELOG.md b/CHANGELOG.md index 28a010b95..2a3e0c321 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 - Fix weight based rate update - #604 by @dominik-zeglen +- Add product export - #620 by @dominik-zeglen ## 2.10.0 diff --git a/locale/defaultMessages.json b/locale/defaultMessages.json index 2a20074d3..c0e35e3b7 100644 --- a/locale/defaultMessages.json +++ b/locale/defaultMessages.json @@ -1806,6 +1806,16 @@ "context": "button", "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": { "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", "string": "Navigation" }, + "src_dot_nextStep": { + "context": "go to next step, button", + "string": "Next" + }, "src_dot_no": { "string": "No" }, @@ -3722,12 +3736,6 @@ "src_dot_productTypes_dot_components_dot_AssignAttributeDialog_dot_902296540": { "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": { "context": "dialog header", "string": "Unassign Attribute From Product Type" @@ -3915,6 +3923,145 @@ "context": "product 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": { "context": "section header", "string": "All Photos" @@ -3951,6 +4098,10 @@ "context": "product 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": { "string": "Search Products..." }, @@ -4339,6 +4490,10 @@ "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?}}" }, + "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": { "context": "publish product, button", "string": "Publish" @@ -4367,6 +4522,9 @@ "context": "dialog header", "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": { "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?}}" @@ -4430,6 +4588,10 @@ "src_dot_savedChanges": { "string": "Saved changes" }, + "src_dot_selectAll": { + "context": "select all options, button", + "string": "Select All" + }, "src_dot_send": { "context": "button", "string": "Send" diff --git a/schema.graphql b/schema.graphql index 4a3aa7ce0..b016539da 100644 --- a/schema.graphql +++ b/schema.graphql @@ -2232,6 +2232,108 @@ type Error { 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 { id: ID! fulfillmentOrder: Int! @@ -4657,6 +4759,23 @@ enum ProductErrorCode { 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 { isPublished: Boolean collections: [ID] @@ -5446,6 +5565,8 @@ type Query { first: Int last: Int ): VoucherCountableConnection + exportFile(id: ID!): ExportFile + exportFiles(filter: ExportFileFilterInput, sortBy: ExportFileSortingInput, before: String, after: String, first: Int, last: Int): ExportFileCountableConnection taxTypes: [TaxType] checkout(token: UUID): Checkout checkouts( diff --git a/src/components/Accordion/Accordion.stories.tsx b/src/components/Accordion/Accordion.stories.tsx new file mode 100644 index 000000000..752a160de --- /dev/null +++ b/src/components/Accordion/Accordion.stories.tsx @@ -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", () => Content) + .add("opened", () => ( + + Content + + )) + .add("with quick peek", () => ( + + Content + + )); diff --git a/src/components/Accordion/Accordion.tsx b/src/components/Accordion/Accordion.tsx new file mode 100644 index 000000000..59843aeae --- /dev/null +++ b/src/components/Accordion/Accordion.tsx @@ -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 = ({ + children, + className, + initialExpand, + quickPeek, + title, + ...props +}) => { + const classes = useStyles({}); + const [expanded, setExpanded] = React.useState(!!initialExpand); + + return ( +
+
+ {title} +
+ setExpanded(!expanded)}> + {expanded ? : } + +
+
+ {(expanded || !!quickPeek) && ( + <> +
+
+ {quickPeek ? (expanded ? children : quickPeek) : children} +
+ + )} +
+ ); +}; + +Accordion.displayName = "Accordion"; +export default Accordion; diff --git a/src/components/Accordion/index.ts b/src/components/Accordion/index.ts new file mode 100644 index 000000000..0f4e089a0 --- /dev/null +++ b/src/components/Accordion/index.ts @@ -0,0 +1,2 @@ +export { default } from "./Accordion"; +export * from "./Accordion"; diff --git a/src/components/CardMenu/CardMenu.tsx b/src/components/CardMenu/CardMenu.tsx index aa88a61ac..a6e8d67a8 100644 --- a/src/components/CardMenu/CardMenu.tsx +++ b/src/components/CardMenu/CardMenu.tsx @@ -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 Menu from "@material-ui/core/Menu"; 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 MoreVertIcon from "@material-ui/icons/MoreVert"; import React from "react"; @@ -9,6 +13,7 @@ const ITEM_HEIGHT = 48; export interface CardMenuItem { label: string; + testId?: string; onSelect: () => void; } @@ -26,34 +31,58 @@ const useStyles = makeStyles( height: 32, padding: 0, width: 32 + }, + paper: { + marginTop: theme.spacing(2), + maxHeight: ITEM_HEIGHT * 4.5 } }), { name: "CardMenu" } ); const CardMenu: React.FC = props => { - const { className, disabled, menuItems } = props; + const { className, disabled, menuItems, ...rest } = props; const classes = useStyles(props); - const [anchorEl, setAnchor] = React.useState(null); + const anchorRef = React.useRef(null); + const [open, setOpen] = React.useState(false); - const handleClick = (event: React.MouseEvent) => { - setAnchor(event.currentTarget); + const handleToggle = () => setOpen(prevOpen => !prevOpen); + + const handleClose = (event: React.MouseEvent) => { + if ( + anchorRef.current && + anchorRef.current.contains(event.target as HTMLElement) + ) { + return; + } + + setOpen(false); }; - const handleClose = () => { - setAnchor(null); + const handleListKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Tab") { + event.preventDefault(); + setOpen(false); + } }; - const handleMenuClick = (menuItemIndex: number) => { - menuItems[menuItemIndex].onSelect(); - handleClose(); - }; + const prevOpen = React.useRef(open); + React.useEffect(() => { + 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 ( -
+
= props => { className={classes.iconButton} color="primary" disabled={disabled} - onClick={handleClick} + ref={anchorRef} + onClick={handleToggle} > - - {menuItems.map((menuItem, menuItemIndex) => ( - handleMenuClick(menuItemIndex)} - key={menuItem.label} + {({ TransitionProps, placement }) => ( + - {menuItem.label} - - ))} - + + + + {menuItems.map((menuItem, menuItemIndex) => ( + handleMenuClick(menuItemIndex)} + key={menuItem.label} + data-test={menuItem.testId} + > + {menuItem.label} + + ))} + + + + + )} +
); }; diff --git a/src/components/Chip/Chip.tsx b/src/components/Chip/Chip.tsx index 53a56bcdf..5198817da 100644 --- a/src/components/Chip/Chip.tsx +++ b/src/components/Chip/Chip.tsx @@ -23,8 +23,8 @@ const useStyles = makeStyles( color: theme.palette.common.white }, root: { - background: fade(theme.palette.secondary.main, 0.8), - borderRadius: 8, + background: fade(theme.palette.primary.main, 0.8), + borderRadius: 18, display: "inline-block", marginRight: theme.spacing(2), padding: "6px 12px" diff --git a/src/components/CreatorSteps/CreatorSteps.tsx b/src/components/CreatorSteps/CreatorSteps.tsx new file mode 100644 index 000000000..d7be7c4a3 --- /dev/null +++ b/src/components/CreatorSteps/CreatorSteps.tsx @@ -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 { + 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 { + currentStep: T; + steps: Array>; + onStepClick: (step: T) => void; +} + +function makeCreatorSteps() { + const CreatorSteps: React.FC> = ({ + currentStep, + steps, + onStepClick + }) => { + const classes = useStyles({}); + + return ( +
+ {steps.map((step, stepIndex) => { + const visitedStep = + steps.findIndex(step => step.value === currentStep) >= stepIndex; + + return ( +
onStepClick(step.value) : undefined} + key={step.value} + > + + {step.label} + +
+ ); + })} +
+ ); + }; + CreatorSteps.displayName = "CreatorSteps"; + + return CreatorSteps; +} + +export default makeCreatorSteps; diff --git a/src/components/CreatorSteps/index.ts b/src/components/CreatorSteps/index.ts new file mode 100644 index 000000000..5ab26400a --- /dev/null +++ b/src/components/CreatorSteps/index.ts @@ -0,0 +1,2 @@ +export * from "./CreatorSteps"; +export { default } from "./CreatorSteps"; diff --git a/src/components/RadioGroupField/RadioGroupField.tsx b/src/components/RadioGroupField/RadioGroupField.tsx index e44c9352e..2ef01e5a7 100644 --- a/src/components/RadioGroupField/RadioGroupField.tsx +++ b/src/components/RadioGroupField/RadioGroupField.tsx @@ -36,8 +36,11 @@ const useStyles = makeStyles( } ); -export interface RadioGroupFieldChoice { - value: string; +export interface RadioGroupFieldChoice< + T extends string | number = string | number +> { + disabled?: boolean; + value: T; label: React.ReactNode; } @@ -49,7 +52,7 @@ interface RadioGroupFieldProps { hint?: string; label?: string; name?: string; - value: string; + value: string | number; onChange: (event: React.ChangeEvent) => void; } @@ -87,6 +90,7 @@ export const RadioGroupField: React.FC = props => { {choices.length > 0 ? ( choices.map(choice => ( } diff --git a/src/components/messages/index.ts b/src/components/messages/index.ts index 264a4fa35..f2e574ae3 100644 --- a/src/components/messages/index.ts +++ b/src/components/messages/index.ts @@ -9,7 +9,7 @@ export interface IMessage { autohide?: number; expandText?: string; title?: string; - text: string; + text: React.ReactNode; onUndo?: () => void; status?: Status; } diff --git a/src/containers/BackgroundTasks/BackgroundTasksProvider.test.tsx b/src/containers/BackgroundTasks/BackgroundTasksProvider.test.tsx index 0151816e0..8c5e46fa3 100644 --- a/src/containers/BackgroundTasks/BackgroundTasksProvider.test.tsx +++ b/src/containers/BackgroundTasks/BackgroundTasksProvider.test.tsx @@ -1,16 +1,30 @@ +import { JobStatusEnum } from "@saleor/types/globalTypes"; import { renderHook } from "@testing-library/react-hooks"; -import { createMockClient } from "mock-apollo-client"; +import { createMockClient, RequestHandlerResponse } from "mock-apollo-client"; import { backgroundTasksRefreshTime, useBackgroundTasks } 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(); function renderBackgroundTasks() { const mockClient = createMockClient(); + mockClient.setRequestHandler(checkExportFileStatus, () => + Promise.resolve>({ + data: { + exportFile: { + __typename: "ExportFile", + id: "123", + status: JobStatusEnum.SUCCESS + } + } + }) + ); const intl = { formatMessage: ({ defaultMessage }) => defaultMessage }; @@ -80,33 +94,6 @@ describe("Background task provider", () => { }); }); - it("can handle task failure", done => { - const handle = jest.fn, []>( - () => 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 => { const onCompleted = jest.fn(); diff --git a/src/containers/BackgroundTasks/BackgroundTasksProvider.tsx b/src/containers/BackgroundTasks/BackgroundTasksProvider.tsx index 600addac1..b9ec40fe2 100644 --- a/src/containers/BackgroundTasks/BackgroundTasksProvider.tsx +++ b/src/containers/BackgroundTasks/BackgroundTasksProvider.tsx @@ -1,13 +1,18 @@ import { IMessageContext } from "@saleor/components/messages"; import useNotifier from "@saleor/hooks/useNotifier"; -import { checkOrderInvoicesStatus } from "@saleor/orders/queries"; import ApolloClient from "apollo-client"; import React from "react"; import { useApolloClient } from "react-apollo"; import { IntlShape, useIntl } from "react-intl"; 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"; export const backgroundTasksRefreshTime = 15 * 1000; @@ -81,6 +86,22 @@ export function useBackgroundTasks( intl ); 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; diff --git a/src/containers/BackgroundTasks/messages.ts b/src/containers/BackgroundTasks/messages.ts new file mode 100644 index 000000000..6b7ab069c --- /dev/null +++ b/src/containers/BackgroundTasks/messages.ts @@ -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" + } +}); diff --git a/src/containers/BackgroundTasks/queries.ts b/src/containers/BackgroundTasks/queries.ts new file mode 100644 index 000000000..c34f04d04 --- /dev/null +++ b/src/containers/BackgroundTasks/queries.ts @@ -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 + } + } + } +`; diff --git a/src/containers/BackgroundTasks/tasks.ts b/src/containers/BackgroundTasks/tasks.ts index a1adcdf27..fb506ffd7 100644 --- a/src/containers/BackgroundTasks/tasks.ts +++ b/src/containers/BackgroundTasks/tasks.ts @@ -1,31 +1,29 @@ import { IMessageContext } from "@saleor/components/messages"; import { commonMessages } from "@saleor/intl"; -import { CheckOrderInvoicesStatus } from "@saleor/orders/types/CheckOrderInvoicesStatus"; import { JobStatusEnum } from "@saleor/types/globalTypes"; import { ApolloQueryResult } from "apollo-client"; -import { defineMessages, IntlShape } from "react-intl"; +import { IntlShape } from "react-intl"; +import messages from "./messages"; import { InvoiceGenerateParams, QueuedTask, TaskData, TaskStatus } from "./types"; +import { CheckExportFileStatus } from "./types/CheckExportFileStatus"; +import { CheckOrderInvoicesStatus } from "./types/CheckOrderInvoicesStatus"; -export const messages = defineMessages({ - 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" +function getTaskStatus(jobStatus: JobStatusEnum): TaskStatus { + switch (jobStatus) { + case JobStatusEnum.SUCCESS: + return TaskStatus.SUCCESS; + case JobStatusEnum.PENDING: + return TaskStatus.PENDING; + default: + return TaskStatus.FAILURE; } -}); +} export async function handleTask(task: QueuedTask): Promise { let status = TaskStatus.PENDING; @@ -89,11 +87,7 @@ export function queueInvoiceGenerate( invoice => invoice.id === generateInvoice.invoiceId ).status; - return status === JobStatusEnum.SUCCESS - ? TaskStatus.SUCCESS - : status === JobStatusEnum.PENDING - ? TaskStatus.PENDING - : TaskStatus.FAILURE; + return getTaskStatus(status); }, id, onCompleted: data => @@ -104,6 +98,7 @@ export function queueInvoiceGenerate( title: intl.formatMessage(messages.invoiceGenerateFinishedTitle) }) : notify({ + status: "error", text: intl.formatMessage(commonMessages.somethingWentWrong), title: intl.formatMessage(messages.invoiceGenerationFailedTitle) }), @@ -112,3 +107,38 @@ export function queueInvoiceGenerate( } ]; } + +export function queueExport( + id: number, + tasks: React.MutableRefObject, + fetch: () => Promise>, + 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 + } + ]; +} diff --git a/src/containers/BackgroundTasks/types.ts b/src/containers/BackgroundTasks/types.ts index b927a0554..269fd3791 100644 --- a/src/containers/BackgroundTasks/types.ts +++ b/src/containers/BackgroundTasks/types.ts @@ -1,5 +1,6 @@ export enum Task { CUSTOM, + EXPORT, INVOICE_GENERATE } export enum TaskStatus { diff --git a/src/containers/BackgroundTasks/types/CheckExportFileStatus.ts b/src/containers/BackgroundTasks/types/CheckExportFileStatus.ts new file mode 100644 index 000000000..210e7e474 --- /dev/null +++ b/src/containers/BackgroundTasks/types/CheckExportFileStatus.ts @@ -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; +} diff --git a/src/orders/types/CheckOrderInvoicesStatus.ts b/src/containers/BackgroundTasks/types/CheckOrderInvoicesStatus.ts similarity index 92% rename from src/orders/types/CheckOrderInvoicesStatus.ts rename to src/containers/BackgroundTasks/types/CheckOrderInvoicesStatus.ts index 85dc7962e..848f69b04 100644 --- a/src/orders/types/CheckOrderInvoicesStatus.ts +++ b/src/containers/BackgroundTasks/types/CheckOrderInvoicesStatus.ts @@ -2,7 +2,7 @@ /* eslint-disable */ // This file was automatically generated and should not be edited. -import { JobStatusEnum } from "./../../types/globalTypes"; +import { JobStatusEnum } from "./../../../types/globalTypes"; // ==================================================== // GraphQL query operation: CheckOrderInvoicesStatus diff --git a/src/fragments/errors.ts b/src/fragments/errors.ts index 0d5eb4896..fda532572 100644 --- a/src/fragments/errors.ts +++ b/src/fragments/errors.ts @@ -120,3 +120,10 @@ export const appErrorFragment = gql` permissions } `; + +export const exportErrorFragment = gql` + fragment ExportErrorFragment on ExportError { + code + field + } +`; diff --git a/src/fragments/products.ts b/src/fragments/products.ts index 9cd61d0d7..234384eba 100644 --- a/src/fragments/products.ts +++ b/src/fragments/products.ts @@ -241,3 +241,11 @@ export const fragmentVariant = gql` } } `; + +export const exportFileFragment = gql` + fragment ExportFileFragment on ExportFile { + id + status + url + } +`; diff --git a/src/fragments/types/ExportErrorFragment.ts b/src/fragments/types/ExportErrorFragment.ts new file mode 100644 index 000000000..7b421283b --- /dev/null +++ b/src/fragments/types/ExportErrorFragment.ts @@ -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; +} diff --git a/src/fragments/types/ExportFileFragment.ts b/src/fragments/types/ExportFileFragment.ts new file mode 100644 index 000000000..ebc4ae1d6 --- /dev/null +++ b/src/fragments/types/ExportFileFragment.ts @@ -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; +} diff --git a/src/intl.ts b/src/intl.ts index 3dc004777..1b8042ec8 100644 --- a/src/intl.ts +++ b/src/intl.ts @@ -132,6 +132,10 @@ export const buttonMessages = defineMessages({ defaultMessage: "Manage", description: "button" }, + nextStep: { + defaultMessage: "Next", + description: "go to next step, button" + }, ok: { defaultMessage: "OK", description: "button" @@ -144,6 +148,10 @@ export const buttonMessages = defineMessages({ defaultMessage: "Save", description: "button" }, + selectAll: { + defaultMessage: "Select All", + description: "select all options, button" + }, send: { defaultMessage: "Send", description: "button" diff --git a/src/orders/queries.ts b/src/orders/queries.ts index a0d227c23..334c15538 100644 --- a/src/orders/queries.ts +++ b/src/orders/queries.ts @@ -1,8 +1,5 @@ import { fragmentAddress } from "@saleor/fragments/address"; -import { - fragmentOrderDetails, - invoiceFragment -} from "@saleor/fragments/orders"; +import { fragmentOrderDetails } from "@saleor/fragments/orders"; import makeQuery from "@saleor/hooks/makeQuery"; import makeTopLevelSearch from "@saleor/hooks/makeTopLevelSearch"; import gql from "graphql-tag"; @@ -231,15 +228,3 @@ export const useOrderFulfillData = makeQuery< OrderFulfillData, OrderFulfillDataVariables >(orderFulfillData); - -export const checkOrderInvoicesStatus = gql` - ${invoiceFragment} - query CheckOrderInvoicesStatus($id: ID!) { - order(id: $id) { - id - invoices { - ...InvoiceFragment - } - } - } -`; diff --git a/src/orders/views/OrderDetails/OrderDetailsMessages.tsx b/src/orders/views/OrderDetails/OrderDetailsMessages.tsx index 56b3112b8..f3ff65e5e 100644 --- a/src/orders/views/OrderDetails/OrderDetailsMessages.tsx +++ b/src/orders/views/OrderDetails/OrderDetailsMessages.tsx @@ -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 useNotifier from "@saleor/hooks/useNotifier"; import createDialogActionHandlers from "@saleor/utils/handlers/dialogActionHandlers"; diff --git a/src/productTypes/components/AssignAttributeDialog/AssignAttributeDialog.tsx b/src/productTypes/components/AssignAttributeDialog/AssignAttributeDialog.tsx index 4d73b6f2a..32040d27b 100644 --- a/src/productTypes/components/AssignAttributeDialog/AssignAttributeDialog.tsx +++ b/src/productTypes/components/AssignAttributeDialog/AssignAttributeDialog.tsx @@ -30,7 +30,7 @@ import React from "react"; import InfiniteScroll from "react-infinite-scroller"; 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( theme => ({ @@ -63,7 +63,7 @@ export interface AssignAttributeDialogProps extends FetchMoreProps { confirmButtonState: ConfirmButtonTransitionState; errors: string[]; open: boolean; - attributes: SearchAttributes_productType_availableAttributes_edges_node[]; + attributes: SearchAvailableAttributes_productType_availableAttributes_edges_node[]; selected: string[]; onClose: () => void; onFetch: (query: string) => void; diff --git a/src/productTypes/components/ProductTypeAttributeEditDialog/ProductTypeAttributeEditDialog.tsx b/src/productTypes/components/ProductTypeAttributeEditDialog/ProductTypeAttributeEditDialog.tsx deleted file mode 100644 index ab82300e1..000000000 --- a/src/productTypes/components/ProductTypeAttributeEditDialog/ProductTypeAttributeEditDialog.tsx +++ /dev/null @@ -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 = ({ - disabled, - errors, - name, - opened, - title, - values, - onClose, - onConfirm -}) => { - const intl = useIntl(); - - const initialForm: FormData = { - name: name || "", - values: values || [] - }; - return ( - -
- {({ change, data }) => ( - <> - {title} - - - - - - - - - - - )} -
-
- ); -}; -ProductTypeAttributeEditDialog.displayName = "ProductTypeAttributeEditDialog"; -export default ProductTypeAttributeEditDialog; diff --git a/src/productTypes/components/ProductTypeAttributeEditDialog/index.ts b/src/productTypes/components/ProductTypeAttributeEditDialog/index.ts deleted file mode 100644 index 6344b380f..000000000 --- a/src/productTypes/components/ProductTypeAttributeEditDialog/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default } from "./ProductTypeAttributeEditDialog"; -export * from "./ProductTypeAttributeEditDialog"; diff --git a/src/productTypes/hooks/useAvailableAttributeSearch/index.tsx b/src/productTypes/hooks/useAvailableAttributeSearch/index.tsx index 0aa5830da..bbc26d5e8 100644 --- a/src/productTypes/hooks/useAvailableAttributeSearch/index.tsx +++ b/src/productTypes/hooks/useAvailableAttributeSearch/index.tsx @@ -3,13 +3,13 @@ import makeSearch from "@saleor/hooks/makeSearch"; import gql from "graphql-tag"; import { - SearchAttributes, - SearchAttributesVariables -} from "./types/SearchAttributes"; + SearchAvailableAttributes, + SearchAvailableAttributesVariables +} from "./types/SearchAvailableAttributes"; export const searchAttributes = gql` ${pageInfoFragment} - query SearchAttributes( + query SearchAvailableAttributes( $id: ID! $after: String $first: Int! @@ -37,35 +37,36 @@ export const searchAttributes = gql` } `; -export default makeSearch( - searchAttributes, - result => - result.loadMore( - (prev, next) => { - if ( - prev.productType.availableAttributes.pageInfo.endCursor === - next.productType.availableAttributes.pageInfo.endCursor - ) { - 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 +export default makeSearch< + SearchAvailableAttributes, + SearchAvailableAttributesVariables +>(searchAttributes, result => + result.loadMore( + (prev, next) => { + if ( + prev.productType.availableAttributes.pageInfo.endCursor === + next.productType.availableAttributes.pageInfo.endCursor + ) { + 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 + } + ) ); diff --git a/src/productTypes/hooks/useAvailableAttributeSearch/types/SearchAttributes.ts b/src/productTypes/hooks/useAvailableAttributeSearch/types/SearchAttributes.ts deleted file mode 100644 index b45411fa9..000000000 --- a/src/productTypes/hooks/useAvailableAttributeSearch/types/SearchAttributes.ts +++ /dev/null @@ -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; -} diff --git a/src/productTypes/hooks/useAvailableAttributeSearch/types/SearchAvailableAttributes.ts b/src/productTypes/hooks/useAvailableAttributeSearch/types/SearchAvailableAttributes.ts new file mode 100644 index 000000000..9956975e7 --- /dev/null +++ b/src/productTypes/hooks/useAvailableAttributeSearch/types/SearchAvailableAttributes.ts @@ -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; +} diff --git a/src/products/components/ProductExportDialog/ProductExportDialog.stories.tsx b/src/products/components/ProductExportDialog/ProductExportDialog.stories.tsx new file mode 100644 index 000000000..734452efd --- /dev/null +++ b/src/products/components/ProductExportDialog/ProductExportDialog.stories.tsx @@ -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", () => ) + .add("no products selected", () => ( + + )) + .add("errors", () => ( + ).map(field => ({ + __typename: "ExportError", + code: ExportErrorCode.INVALID, + field + }))} + /> + )); diff --git a/src/products/components/ProductExportDialog/ProductExportDialog.tsx b/src/products/components/ProductExportDialog/ProductExportDialog.tsx new file mode 100644 index 000000000..32a1d218b --- /dev/null +++ b/src/products/components/ProductExportDialog/ProductExportDialog.tsx @@ -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> { + 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(); + +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 = ({ + 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 ( + +
+ {({ 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 ( + <> + + + + + + {step === ProductExportStep.INFO && ( + + )} + {step === ProductExportStep.SETTINGS && ( + + )} + + + {notFormErrors.length > 0 && ( + + {notFormErrors.map(err => ( + + {getExportErrorMessage(err, intl)} + + ))} + + )} + + + {step === ProductExportStep.INFO && ( + + )} + {step === ProductExportStep.SETTINGS && ( + + )} + {step === ProductExportStep.INFO && ( + + )} + {step === ProductExportStep.SETTINGS && ( + + + + )} + + + ); + }} +
+
+ ); +}; + +ProductExportDialog.displayName = "ProductExportDialog"; +export default ProductExportDialog; diff --git a/src/products/components/ProductExportDialog/ProductExportDialogInfo.tsx b/src/products/components/ProductExportDialog/ProductExportDialogInfo.tsx new file mode 100644 index 000000000..991137265 --- /dev/null +++ b/src/products/components/ProductExportDialog/ProductExportDialogInfo.tsx @@ -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 ( + + } + className={classes.label} + label={children} + labelPlacement="start" + > + ); +}; + +const FieldAccordion: React.FC void; + onToggleAll: (field: ProductFieldEnum[], setTo: boolean) => void; +}> = ({ data, fields, onChange, onToggleAll, ...props }) => { + const classes = useStyles({}); + const intl = useIntl(); + + const fieldNames: Record = { + [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 ( + 0 && ( +
+ {selectedFields.slice(0, maxChips).map(field => ( + + onChange({ + target: { + name: field, + value: false + } + }) + } + /> + ))} + {selectedFields.length > maxChips && ( + + + + )} +
+ ) + } + {...props} + > + + {fields.map(field => ( + + ))} +
+ ); +}; + +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 = ({ + 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 ( + <> + + + + + 0 && ( +
+ {selectedAttributes.slice(0, maxChips).map(attribute => ( + + onAttrtibuteSelect({ + target: { + name: attributeNamePrefix + attribute.value, + value: undefined + } + }) + } + /> + ))} + {selectedAttributes.length > maxChips && ( + + + + )} +
+ ) + } + data-test="attributes" + > + + }} + /> +
+ {attributes.map(attribute => ( + + ))} + {(hasMore || loading) && ( +
+ {hasMore && !loading && ( + + )} + {loading && } +
+ )} +
+ + + + + ); +}; + +ProductExportDialogInfo.displayName = "ProductExportDialogInfo"; +export default ProductExportDialogInfo; diff --git a/src/products/components/ProductExportDialog/ProductExportDialogSettings.tsx b/src/products/components/ProductExportDialog/ProductExportDialogSettings.tsx new file mode 100644 index 000000000..040b0dbc3 --- /dev/null +++ b/src/products/components/ProductExportDialog/ProductExportDialogSettings.tsx @@ -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 = ["fileType", "scope"]; + +const ProductExportDialogSettings: React.FC = ({ + data, + errors, + onChange, + productQuantity, + selectedProducts +}) => { + const classes = useStyles({}); + const intl = useIntl(); + + const formErrors = getFormErrors(formFields, errors); + + const productsToExportChoices: Array> = [ + { + 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> = [ + { + 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 ( + <> + +
+ + + ); +}; + +ProductExportDialogSettings.displayName = "ProductExportDialogSettings"; +export default ProductExportDialogSettings; diff --git a/src/products/components/ProductExportDialog/index.ts b/src/products/components/ProductExportDialog/index.ts new file mode 100644 index 000000000..ea211ddcc --- /dev/null +++ b/src/products/components/ProductExportDialog/index.ts @@ -0,0 +1,2 @@ +export * from "./ProductExportDialog"; +export { default } from "./ProductExportDialog"; diff --git a/src/products/components/ProductListPage/ProductListPage.tsx b/src/products/components/ProductListPage/ProductListPage.tsx index 6652fc6bd..8bc6b452c 100644 --- a/src/products/components/ProductListPage/ProductListPage.tsx +++ b/src/products/components/ProductListPage/ProductListPage.tsx @@ -1,6 +1,7 @@ import Button from "@material-ui/core/Button"; import Card from "@material-ui/core/Card"; import makeStyles from "@material-ui/core/styles/makeStyles"; +import CardMenu from "@saleor/components/CardMenu"; import ColumnPicker, { ColumnPickerChoice } from "@saleor/components/ColumnPicker"; @@ -44,12 +45,13 @@ export interface ProductListPageProps gridAttributes: GridAttributes_grid_edges_node[]; totalGridAttributes: number; products: ProductList_products_edges_node[]; + onExport: () => void; } const useStyles = makeStyles( theme => ({ columnPicker: { - marginRight: theme.spacing(3) + margin: theme.spacing(0, 3) } }), { name: "ProductListPage" } @@ -71,6 +73,7 @@ export const ProductListPage: React.FC = props => { totalGridAttributes, onAdd, onAll, + onExport, onFetchMore, onFilterChange, onSearchChange, @@ -119,6 +122,19 @@ export const ProductListPage: React.FC = props => { return ( + > { + const intl = useIntl(); + return [ { label: intl.formatMessage({ @@ -36,69 +32,27 @@ function getSteps(intl: IntlShape): Step[] { ]; } -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: "ProductVariantCreatorTabs" - } -); +const ProductVariantCreatorSteps = makeCreatorSteps< + ProductVariantCreatorStep +>(); export interface ProductVariantCreatorTabsProps { step: ProductVariantCreatorStep; onStepClick: (step: ProductVariantCreatorStep) => void; } -const ProductVariantCreatorTabs: React.FC = props => { - const { step: currentStep, onStepClick } = props; - const classes = useStyles(props); - const intl = useIntl(); - const steps = getSteps(intl); +const ProductVariantCreatorTabs: React.FC = ({ + step: currentStep, + onStepClick +}) => { + const steps = useSteps(); return ( -
- {steps.map((step, stepIndex) => { - const visitedStep = - steps.findIndex(step => step.value === currentStep) >= stepIndex; - - return ( -
onStepClick(step.value) : undefined} - key={step.value} - > - - {step.label} - -
- ); - })} -
+ ); }; diff --git a/src/products/mutations.ts b/src/products/mutations.ts index 255d8d1c7..7a8927b9e 100644 --- a/src/products/mutations.ts +++ b/src/products/mutations.ts @@ -1,10 +1,12 @@ import { bulkProductErrorFragment, bulkStockErrorFragment, + exportErrorFragment, productErrorFragment, stockErrorFragment } from "@saleor/fragments/errors"; import { + exportFileFragment, fragmentVariant, productFragmentDetails } from "@saleor/fragments/products"; @@ -22,6 +24,7 @@ import { } from "./types/productBulkPublish"; import { ProductCreate, ProductCreateVariables } from "./types/ProductCreate"; import { ProductDelete, ProductDeleteVariables } from "./types/ProductDelete"; +import { ProductExport, ProductExportVariables } from "./types/ProductExport"; import { ProductImageCreate, ProductImageCreateVariables @@ -567,3 +570,22 @@ export const TypedProductVariantBulkDeleteMutation = TypedMutation< ProductVariantBulkDelete, ProductVariantBulkDeleteVariables >(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); diff --git a/src/products/queries.ts b/src/products/queries.ts index 017fd0bf7..80d5d8a1f 100644 --- a/src/products/queries.ts +++ b/src/products/queries.ts @@ -11,6 +11,7 @@ import makeQuery from "@saleor/hooks/makeQuery"; import gql from "graphql-tag"; import { TypedQuery } from "../queries"; +import { CountAllProducts } from "./types/CountAllProducts"; import { CreateMultipleVariantsData, CreateMultipleVariantsDataVariables @@ -145,6 +146,7 @@ const productListQuery = gql` startCursor endCursor } + totalCount } } `; @@ -153,6 +155,17 @@ export const TypedProductListQuery = TypedQuery< ProductListVariables >(productListQuery); +const countAllProductsQuery = gql` + query CountAllProducts { + products { + totalCount + } + } +`; +export const useCountAllProducts = makeQuery( + countAllProductsQuery +); + const productDetailsQuery = gql` ${productFragmentDetails} query ProductDetails($id: ID!) { diff --git a/src/products/types/CountAllProducts.ts b/src/products/types/CountAllProducts.ts new file mode 100644 index 000000000..1962d82e3 --- /dev/null +++ b/src/products/types/CountAllProducts.ts @@ -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; +} diff --git a/src/products/types/ProductExport.ts b/src/products/types/ProductExport.ts new file mode 100644 index 000000000..fcc17aad1 --- /dev/null +++ b/src/products/types/ProductExport.ts @@ -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; +} diff --git a/src/products/types/ProductList.ts b/src/products/types/ProductList.ts index 78ec749fb..c96e1001a 100644 --- a/src/products/types/ProductList.ts +++ b/src/products/types/ProductList.ts @@ -99,6 +99,7 @@ export interface ProductList_products { __typename: "ProductCountableConnection"; edges: ProductList_products_edges[]; pageInfo: ProductList_products_pageInfo; + totalCount: number | null; } export interface ProductList { diff --git a/src/products/urls.ts b/src/products/urls.ts index 19bc641fc..859229d35 100644 --- a/src/products/urls.ts +++ b/src/products/urls.ts @@ -23,6 +23,7 @@ export type ProductListUrlDialog = | "publish" | "unpublish" | "delete" + | "export" | TabActionDialog; export enum ProductListUrlFiltersEnum { priceFrom = "priceFrom", diff --git a/src/products/views/ProductList/ProductList.tsx b/src/products/views/ProductList/ProductList.tsx index e2ab7af14..8e9089ed3 100644 --- a/src/products/views/ProductList/ProductList.tsx +++ b/src/products/views/ProductList/ProductList.tsx @@ -13,6 +13,8 @@ import { defaultListSettings, ProductListColumns } from "@saleor/config"; +import { Task } from "@saleor/containers/BackgroundTasks/types"; +import useBackgroundTask from "@saleor/hooks/useBackgroundTask"; import useBulkActions from "@saleor/hooks/useBulkActions"; import useListSettings from "@saleor/hooks/useListSettings"; import useNavigator from "@saleor/hooks/useNavigator"; @@ -23,11 +25,13 @@ import usePaginator, { import useShop from "@saleor/hooks/useShop"; import { commonMessages } from "@saleor/intl"; import { maybe } from "@saleor/misc"; +import ProductExportDialog from "@saleor/products/components/ProductExportDialog"; import { getAttributeIdFromColumnValue, isAttributeColumnValue } from "@saleor/products/components/ProductListPage/utils"; import { ProductListVariables } from "@saleor/products/types/ProductList"; +import useAttributeSearch from "@saleor/searches/useAttributeSearch"; import useCategorySearch from "@saleor/searches/useCategorySearch"; import useCollectionSearch from "@saleor/searches/useCollectionSearch"; import useProductTypeSearch from "@saleor/searches/useProductTypeSearch"; @@ -41,11 +45,13 @@ import { FormattedMessage, useIntl } from "react-intl"; import ProductListPage from "../../components/ProductListPage"; import { TypedProductBulkDeleteMutation, - TypedProductBulkPublishMutation + TypedProductBulkPublishMutation, + useProductExport } from "../../mutations"; import { AvailableInGridAttributesQuery, TypedProductListQuery, + useCountAllProducts, useInitialProductFilterDataQuery } from "../../queries"; import { productBulkDelete } from "../../types/productBulkDelete"; @@ -78,6 +84,7 @@ export const ProductList: React.FC = ({ params }) => { const navigate = useNavigator(); const notify = useNotifier(); const paginate = usePaginator(); + const { queue } = useBackgroundTask(); const shop = useShop(); const { isSelected, listElements, reset, toggle, toggleAll } = useBulkActions( params.ids @@ -111,6 +118,12 @@ export const ProductList: React.FC = ({ params }) => { first: 5 } }); + const searchAttributes = useAttributeSearch({ + variables: { + ...DEFAULT_INITIAL_SEARCH_DATA, + first: 10 + } + }); React.useEffect( () => @@ -137,6 +150,29 @@ export const ProductList: React.FC = ({ params }) => { ProductListUrlDialog, ProductListUrlQueryParams >(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 [ changeFilters, @@ -398,6 +434,7 @@ export const ProductList: React.FC = ({ params }) => { onTabChange={handleTabChange} initialSearch={params.query || ""} tabs={getFilterTabs().map(tab => tab.name)} + onExport={() => openModal("export")} /> = ({ params }) => { /> + 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 + } + } + }) + } + /> ( + searchAttributes +); diff --git a/src/storybook/__snapshots__/Stories.test.ts.snap b/src/storybook/__snapshots__/Stories.test.ts.snap index 055cf794b..02e94014a 100644 --- a/src/storybook/__snapshots__/Stories.test.ts.snap +++ b/src/storybook/__snapshots__/Stories.test.ts.snap @@ -48,6 +48,184 @@ exports[`Storyshots Discounts / Select countries default 1`] = ` /> `; +exports[`Storyshots Generics / Accordion default 1`] = ` +
+
+
+
+
+
+ Title +
+
+ +
+
+
+
+
+
+`; + +exports[`Storyshots Generics / Accordion opened 1`] = ` +
+
+
+
+
+
+ Title +
+
+ +
+
+
+
+ Content +
+
+
+
+
+`; + +exports[`Storyshots Generics / Accordion with quick peek 1`] = ` +
+
+
+
+
+
+ Title +
+
+ +
+
+
+
+ Quick Peek +
+
+
+
+
+`; + exports[`Storyshots Generics / Account Permission Groups Widget default 1`] = `
Select Values
Prices and SKU
Summary
@@ -119378,6 +119556,24 @@ exports[`Storyshots Views / Products / Create product variant with errors 1`] =
`; +exports[`Storyshots Views / Products / Export / Export settings errors 1`] = ` +
+`; + +exports[`Storyshots Views / Products / Export / Export settings interactive 1`] = ` +
+`; + +exports[`Storyshots Views / Products / Export / Export settings no products selected 1`] = ` +
+`; + exports[`Storyshots Views / Products / Product edit form errors 1`] = `
+
+ +
@@ -135289,6 +135512,33 @@ exports[`Storyshots Views / Products / Product list loading 1`] = `
+
+ +
@@ -135788,6 +136038,33 @@ exports[`Storyshots Views / Products / Product list no data 1`] = `
+
+ +
@@ -136183,6 +136460,33 @@ exports[`Storyshots Views / Products / Product list not published 1`] = `
+
+ +
@@ -136964,6 +137268,33 @@ exports[`Storyshots Views / Products / Product list published 1`] = `
+
+ +
diff --git a/src/storybook/stories/productTypes/ProductTypeAttributeEditDialog.tsx b/src/storybook/stories/productTypes/ProductTypeAttributeEditDialog.tsx deleted file mode 100644 index e8e276e2e..000000000 --- a/src/storybook/stories/productTypes/ProductTypeAttributeEditDialog.tsx +++ /dev/null @@ -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", () => ) - .add("loading", () => ( - - )) - .add("form errors", () => ( - formError(field))} - /> - )); diff --git a/src/storybook/stories/products/ProductListPage.tsx b/src/storybook/stories/products/ProductListPage.tsx index 07be01dd8..43acc0344 100644 --- a/src/storybook/stories/products/ProductListPage.tsx +++ b/src/storybook/stories/products/ProductListPage.tsx @@ -39,6 +39,7 @@ const props: ProductListPageProps = { defaultSettings: defaultListSettings[ListViews.PRODUCT_LIST], filterOpts: productListFilterOpts, gridAttributes: attributes, + onExport: () => undefined, products, settings: { ...pageListProps.default.settings, diff --git a/src/types/globalTypes.ts b/src/types/globalTypes.ts index 2e905f82b..a0a9b2207 100644 --- a/src/types/globalTypes.ts +++ b/src/types/globalTypes.ts @@ -401,6 +401,23 @@ export enum DiscountValueTypeEnum { 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 { CANCELED = "CANCELED", FULFILLED = "FULFILLED", @@ -668,6 +685,23 @@ export enum ProductErrorCode { 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 { DATE = "DATE", MINIMAL_PRICE = "MINIMAL_PRICE", @@ -1079,6 +1113,20 @@ export interface DraftOrderInput { 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 { warehouseId: string; } diff --git a/src/utils/errors/export.ts b/src/utils/errors/export.ts new file mode 100644 index 000000000..3d773122a --- /dev/null +++ b/src/utils/errors/export.ts @@ -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 | 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; diff --git a/src/utils/maps.ts b/src/utils/maps.ts index 11b76640e..85c5ff2b7 100644 --- a/src/utils/maps.ts +++ b/src/utils/maps.ts @@ -1,6 +1,7 @@ import { MultiAutocompleteChoiceType } from "@saleor/components/MultiAutocompleteSelectField"; import { ShopInfo_shop_countries } from "@saleor/components/Shop/types/ShopInfo"; import { SingleAutocompleteChoiceType } from "@saleor/components/SingleAutocompleteSelectField"; +import { Node } from "@saleor/types"; export function mapCountriesToChoices( countries: ShopInfo_shop_countries[] @@ -10,3 +11,12 @@ export function mapCountriesToChoices( value: country.code })); } + +export function mapNodeToChoice( + nodes: Array> +): Array { + return nodes.map(node => ({ + label: node.name, + value: node.id + })); +} diff --git a/testUtils/filters.ts b/testUtils/filters.ts index 0c3987080..c542ed8f6 100644 --- a/testUtils/filters.ts +++ b/testUtils/filters.ts @@ -1,6 +1,5 @@ -import clone from "lodash-es/clone"; - import { IFilter } from "@saleor/components/Filter"; +import clone from "lodash-es/clone"; export function getExistingKeys(o: object): string[] { return Object.keys(o).filter(key => o[key] !== undefined && o[key] !== null);