diff --git a/CHANGELOG.md b/CHANGELOG.md index 32070e7b7..fe07521bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,3 +32,4 @@ All notable, unreleased changes to this project will be documented in this file. - Allow sorting products by attribute - #180 by @dominik-zeglen - Hide variants and attributes if product has none - #179 by @dominik-zeglen - Add service account section - #188 by @dominik-zeglen +- Add variant creator - #177 by @dominik-zeglen diff --git a/locale/messages.pot b/locale/messages.pot index cbb6f26a6..1dcc13d48 100644 --- a/locale/messages.pot +++ b/locale/messages.pot @@ -1,6 +1,6 @@ msgid "" msgstr "" -"POT-Creation-Date: 2019-10-04T11:19:12.447Z\n" +"POT-Creation-Date: 2019-10-09T10:25:56.800Z\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "MIME-Version: 1.0\n" @@ -639,6 +639,38 @@ msgctxt "voucher" msgid "Applies to" msgstr "" +#: build/locale/src/products/components/ProductVariantCreateDialog/ProductVariantCreatePrices.json +#. [src.products.components.ProductVariantCreateDialog.2783195765] +#. defaultMessage is: +#. Apply single price to all SKUs +msgctxt "description" +msgid "Apply single price to all SKUs" +msgstr "" + +#: build/locale/src/products/components/ProductVariantCreateDialog/ProductVariantCreatePrices.json +#. [src.products.components.ProductVariantCreateDialog.3601538615] +#. defaultMessage is: +#. Apply single stock to all SKUs +msgctxt "description" +msgid "Apply single stock to all SKUs" +msgstr "" + +#: build/locale/src/products/components/ProductVariantCreateDialog/ProductVariantCreatePrices.json +#. [src.products.components.ProductVariantCreateDialog.3570949907] +#. defaultMessage is: +#. Apply unique prices by attribute to each SKU +msgctxt "description" +msgid "Apply unique prices by attribute to each SKU" +msgstr "" + +#: build/locale/src/products/components/ProductVariantCreateDialog/ProductVariantCreatePrices.json +#. [src.products.components.ProductVariantCreateDialog.3387090508] +#. defaultMessage is: +#. Apply unique stock by attribute to each SKU +msgctxt "description" +msgid "Apply unique stock by attribute to each SKU" +msgstr "" + #: build/locale/src/orders/components/OrderCancelDialog/OrderCancelDialog.json #. [src.orders.components.OrderCancelDialog.3981375672] #. defaultMessage is: @@ -1139,6 +1171,10 @@ msgctxt "description" msgid "Are you sure you want to void this payment?" msgstr "" +#: build/locale/src/products/components/ProductVariantCreateDialog/ProductVariantCreateDialog.json +#. [src.products.components.ProductVariantCreateDialog.3922579741] - dialog header +#. defaultMessage is: +#. Assign Attribute #: build/locale/src/productTypes/components/AssignAttributeDialog/AssignAttributeDialog.json #. [src.productTypes.components.AssignAttributeDialog.3922579741] - dialog header #. defaultMessage is: @@ -1267,6 +1303,14 @@ msgctxt "assign attribute value button" msgid "Assign value" msgstr "" +#: build/locale/src/products/components/ProductVariantCreateDialog/ProductVariantCreatePrices.json +#. [src.products.components.ProductVariantCreateDialog.168343345] - variant attribute +#. defaultMessage is: +#. Attribute +msgctxt "variant attribute" +msgid "Attribute" +msgstr "" + #: build/locale/src/attributes/components/AttributeDetails/AttributeDetails.json #. [src.attributes.components.AttributeDetails.3605174225] - attribute's slug short code label #. defaultMessage is: @@ -1511,6 +1555,10 @@ msgstr "" #. [src.cancel] - button #. defaultMessage is: #. Cancel +#: build/locale/src/products/components/ProductVariantCreateDialog/ProductVariantCreateDialog.json +#. [src.products.components.ProductVariantCreateDialog.3528672691] - button +#. defaultMessage is: +#. Cancel msgctxt "button" msgid "Cancel" msgstr "" @@ -1751,6 +1799,14 @@ msgctxt "tax rate" msgid "Children's clothing" msgstr "" +#: build/locale/src/products/components/ProductVariantCreateDialog/ProductVariantCreatePrices.json +#. [src.products.components.ProductVariantCreateDialog.2670525734] - variant attribute +#. defaultMessage is: +#. Choose attribute +msgctxt "variant attribute" +msgid "Choose attribute" +msgstr "" + #: build/locale/src/shipping/components/ShippingZoneCountriesAssignDialog/ShippingZoneCountriesAssignDialog.json #. [src.shipping.components.ShippingZoneCountriesAssignDialog.2404264158] #. defaultMessage is: @@ -2139,6 +2195,14 @@ msgctxt "description" msgid "Country area" msgstr "" +#: build/locale/src/products/components/ProductVariantCreateDialog/ProductVariantCreateDialog.json +#. [src.products.components.ProductVariantCreateDialog.4120989039] - create multiple variants, button +#. defaultMessage is: +#. Create +msgctxt "create multiple variants, button" +msgid "Create" +msgstr "" + #: build/locale/src/services/components/ServiceTokenCreateDialog/ServiceTokenCreateDialog.json #. [src.services.components.ServiceTokenCreateDialog.4120989039] - create service token, button #. defaultMessage is: @@ -2435,6 +2499,14 @@ msgctxt "window title" msgid "Create variant" msgstr "" +#: build/locale/src/products/components/ProductVariants/ProductVariants.json +#. [src.products.components.ProductVariants.1721716102] - button +#. defaultMessage is: +#. Create variants +msgctxt "button" +msgid "Create variants" +msgstr "" + #: build/locale/src/discounts/components/VoucherListPage/VoucherListPage.json #. [src.discounts.components.VoucherListPage.614836274] - button #. defaultMessage is: @@ -3931,6 +4003,14 @@ msgctxt "product stock" msgid "Inventory" msgstr "" +#: build/locale/src/products/components/ProductVariantCreateDialog/ProductVariantCreateSummary.json +#. [src.products.components.ProductVariantCreateDialog.3490038570] - variant stock amount +#. defaultMessage is: +#. Inventory +msgctxt "variant stock amount" +msgid "Inventory" +msgstr "" + #: build/locale/src/products/components/ProductVariantStock/ProductVariantStock.json #. [src.products.components.ProductVariantStock.3490038570] - product variant stock #. defaultMessage is: @@ -4507,6 +4587,14 @@ msgctxt "tax rate" msgid "Newspapers" msgstr "" +#: build/locale/src/products/components/ProductVariantCreateDialog/ProductVariantCreateDialog.json +#. [src.products.components.ProductVariantCreateDialog.3673120330] - button +#. defaultMessage is: +#. Next +msgctxt "button" +msgid "Next" +msgstr "" + #: build/locale/src/intl.json #. [src.no] #. defaultMessage is: @@ -5651,6 +5739,14 @@ msgctxt "order payment" msgid "Preauthorized amount" msgstr "" +#: build/locale/src/products/components/ProductVariantCreateDialog/ProductVariantCreateDialog.json +#. [src.products.components.ProductVariantCreateDialog.904693740] - previous step, button +#. defaultMessage is: +#. Previous +msgctxt "previous step, button" +msgid "Previous" +msgstr "" + #: build/locale/src/categories/components/CategoryProductList/CategoryProductList.json #. [src.categories.components.CategoryProductList.1134347598] - product price #. defaultMessage is: @@ -5695,10 +5791,34 @@ msgstr "" #. [src.products.components.ProductListFilter.1134347598] #. defaultMessage is: #. Price +#: build/locale/src/products/components/ProductVariantCreateDialog/ProductVariantCreatePrices.json +#. [productVariantCreatePricesPriceInputLabel] +#. defaultMessage is: +#. Price msgctxt "description" msgid "Price" msgstr "" +#: build/locale/src/products/components/ProductVariantCreateDialog/ProductVariantCreatePrices.json +#. [src.products.components.ProductVariantCreateDialog.1134347598] - variant price, header +#. defaultMessage is: +#. Price +msgctxt "variant price, header" +msgid "Price" +msgstr "" + +#: build/locale/src/products/components/ProductVariantCreateDialog/ProductVariantCreatePrices.json +#. [productVariantCreatePricesSetPricePlaceholder] - variant price +#. defaultMessage is: +#. Price +#: build/locale/src/products/components/ProductVariantCreateDialog/ProductVariantCreateSummary.json +#. [src.products.components.ProductVariantCreateDialog.1134347598] - variant price +#. defaultMessage is: +#. Price +msgctxt "variant price" +msgid "Price" +msgstr "" + #: build/locale/src/products/components/ProductVariants/ProductVariants.json #. [src.products.components.ProductVariants.1134347598] - product variant price #. defaultMessage is: @@ -5747,6 +5867,14 @@ msgctxt "filter by price" msgid "Price to {price}" msgstr "" +#: build/locale/src/products/components/ProductVariantCreateDialog/ProductVariantCreateTabs.json +#. [src.products.components.ProductVariantCreateDialog.705096461] - variant creation step +#. defaultMessage is: +#. Prices and SKU +msgctxt "variant creation step" +msgid "Prices and SKU" +msgstr "" + #: build/locale/src/discounts/components/SalePricing/SalePricing.json #. [src.discounts.components.SalePricing.1099355007] - sale pricing, header #. defaultMessage is: @@ -6387,6 +6515,10 @@ msgctxt "product's sku" msgid "SKU" msgstr "" +#: build/locale/src/products/components/ProductVariantCreateDialog/ProductVariantCreateSummary.json +#. [src.products.components.ProductVariantCreateDialog.693960049] +#. defaultMessage is: +#. SKU #: build/locale/src/products/components/ProductVariants/ProductVariants.json #. [src.products.components.ProductVariants.693960049] #. defaultMessage is: @@ -6839,6 +6971,14 @@ msgctxt "description" msgid "Select Filter..." msgstr "" +#: build/locale/src/products/components/ProductVariantCreateDialog/ProductVariantCreateTabs.json +#. [src.products.components.ProductVariantCreateDialog.2478977538] - attribute values, variant creation step +#. defaultMessage is: +#. Select Values +msgctxt "attribute values, variant creation step" +msgid "Select Values" +msgstr "" + #: build/locale/src/products/components/ProductVariantImages/ProductVariantImages.json #. [src.products.components.ProductVariantImages.3449133076] #. defaultMessage is: @@ -7347,6 +7487,30 @@ msgctxt "product stock" msgid "Stock" msgstr "" +#: build/locale/src/products/components/ProductVariantCreateDialog/ProductVariantCreatePrices.json +#. [src.products.components.ProductVariantCreateDialog.3841616483] - variant stock, header +#. defaultMessage is: +#. Stock +msgctxt "variant stock, header" +msgid "Stock" +msgstr "" + +#: build/locale/src/products/components/ProductVariantCreateDialog/ProductVariantCreatePrices.json +#. [productVariantCreatePricesStockInputLabel] +#. defaultMessage is: +#. Stock +msgctxt "description" +msgid "Stock" +msgstr "" + +#: build/locale/src/products/components/ProductVariantCreateDialog/ProductVariantCreatePrices.json +#. [productVariantCreatePricesSetStockPlaceholder] - variant stock +#. defaultMessage is: +#. Stock +msgctxt "variant stock" +msgid "Stock" +msgstr "" + #: build/locale/src/products/components/ProductVariantStock/ProductVariantStock.json #. [src.products.components.ProductVariantStock.3841616483] - product variant stock, section header #. defaultMessage is: @@ -7491,6 +7655,14 @@ msgctxt "description" msgid "Summary" msgstr "" +#: build/locale/src/products/components/ProductVariantCreateDialog/ProductVariantCreateTabs.json +#. [src.products.components.ProductVariantCreateDialog.2745385064] - variant creation step +#. defaultMessage is: +#. Summary +msgctxt "variant creation step" +msgid "Summary" +msgstr "" + #: build/locale/src/productTypes/components/ProductTypeList/ProductTypeList.json #. [src.productTypes.components.ProductTypeList.1240292548] - tax rate for a product type #. defaultMessage is: @@ -8459,6 +8631,14 @@ msgctxt "attribute values" msgid "Values" msgstr "" +#: build/locale/src/products/components/ProductVariantCreateDialog/ProductVariantCreateSummary.json +#. [src.products.components.ProductVariantCreateDialog.998917294] - variant name +#. defaultMessage is: +#. Variant +msgctxt "variant name" +msgid "Variant" +msgstr "" + #: build/locale/src/translations/components/TranslationsProductTypesPage/TranslationsProductTypesPage.json #. [src.translations.components.TranslationsProductTypesPage.3538502409] - header #. defaultMessage is: @@ -8711,6 +8891,14 @@ msgctxt "description" msgid "Yes" msgstr "" +#: build/locale/src/products/components/ProductVariantCreateDialog/ProductVariantCreateSummary.json +#. [src.products.components.ProductVariantCreateDialog.1009678918] - header +#. defaultMessage is: +#. You will create variants below +msgctxt "header" +msgid "You will create variants below" +msgstr "" + #: build/locale/src/components/AddressEdit/AddressEdit.json #. [src.components.AddressEdit.2965971965] #. defaultMessage is: diff --git a/src/attributes/fixtures.ts b/src/attributes/fixtures.ts index 4c236da3a..92ab3ef0d 100644 --- a/src/attributes/fixtures.ts +++ b/src/attributes/fixtures.ts @@ -1,3 +1,4 @@ +import { ProductDetails_product_productType_variantAttributes } from "@saleor/products/types/ProductDetails"; import { AttributeInputTypeEnum, AttributeValueType @@ -35,7 +36,10 @@ export const attribute: AttributeDetailsFragment = { visibleInStorefront: true }; -export const attributes: AttributeList_attributes_edges_node[] = [ +export const attributes: Array< + AttributeList_attributes_edges_node & + ProductDetails_product_productType_variantAttributes +> = [ { node: { __typename: "Attribute" as "Attribute", diff --git a/src/components/Timeline/Timeline.tsx b/src/components/Timeline/Timeline.tsx index 0bb77b6b5..1c449c8e8 100644 --- a/src/components/Timeline/Timeline.tsx +++ b/src/components/Timeline/Timeline.tsx @@ -30,6 +30,9 @@ const styles = (theme: Theme) => "& > div": { padding: "0 14px" }, + "& fieldset": { + background: theme.palette.background.paper + }, "& textarea": { "&::placeholder": { opacity: [[1], "!important"] as any diff --git a/src/products/components/ProductUpdatePage/ProductUpdatePage.tsx b/src/products/components/ProductUpdatePage/ProductUpdatePage.tsx index ac57ec9fc..f341f5875 100644 --- a/src/products/components/ProductUpdatePage/ProductUpdatePage.tsx +++ b/src/products/components/ProductUpdatePage/ProductUpdatePage.tsx @@ -60,6 +60,7 @@ export interface ProductUpdatePageProps extends ListActions { saveButtonBarState: ConfirmButtonTransitionState; fetchCategories: (query: string) => void; fetchCollections: (query: string) => void; + onVariantsAdd: () => void; onVariantShow: (id: string) => () => void; onImageDelete: (id: string) => () => void; onBack?(); @@ -100,6 +101,7 @@ export const ProductUpdatePage: React.FC = ({ onSeoClick, onSubmit, onVariantAdd, + onVariantsAdd, onVariantShow, isChecked, selected, @@ -236,6 +238,7 @@ export const ProductUpdatePage: React.FC = ({ fallbackPrice={product ? product.basePrice : undefined} onRowClick={onVariantShow} onVariantAdd={onVariantAdd} + onVariantsAdd={onVariantsAdd} toolbar={toolbar} isChecked={isChecked} selected={selected} diff --git a/src/products/components/ProductVariantCreateDialog/ProductVariantCreate.stories.tsx b/src/products/components/ProductVariantCreateDialog/ProductVariantCreate.stories.tsx new file mode 100644 index 000000000..2caf5fba2 --- /dev/null +++ b/src/products/components/ProductVariantCreateDialog/ProductVariantCreate.stories.tsx @@ -0,0 +1,124 @@ +import Card from "@material-ui/core/Card"; +import CardContent from "@material-ui/core/CardContent"; +import { storiesOf } from "@storybook/react"; +import React from "react"; + +import { attributes } from "@saleor/attributes/fixtures"; +import { ProductVariantBulkCreate_productVariantBulkCreate_bulkProductErrors } from "@saleor/products/types/ProductVariantBulkCreate"; +import { ProductErrorCode } from "@saleor/types/globalTypes"; +import Decorator from "../../../storybook/Decorator"; +import { createVariants } from "./createVariants"; +import { AllOrAttribute } from "./form"; +import ProductVariantCreateContent, { + ProductVariantCreateContentProps +} from "./ProductVariantCreateContent"; +import ProductVariantCreateDialog from "./ProductVariantCreateDialog"; + +const selectedAttributes = [1, 4, 5].map(index => attributes[index]); + +const price: AllOrAttribute = { + all: false, + attribute: selectedAttributes[1].id, + value: "2.79", + values: selectedAttributes[1].values.map((attribute, attributeIndex) => ({ + slug: attribute.slug, + value: (attributeIndex + 4).toFixed(2) + })) +}; + +const stock: AllOrAttribute = { + all: false, + attribute: selectedAttributes[1].id, + value: "8", + values: selectedAttributes[1].values.map((attribute, attributeIndex) => ({ + slug: attribute.slug, + value: (selectedAttributes.length * 10 - attributeIndex).toString() + })) +}; + +const dataAttributes = selectedAttributes.map(attribute => ({ + id: attribute.id, + values: attribute.values + .map(value => value.slug) + .filter((_, valueIndex) => valueIndex % 2 !== 1) +})); + +const errors: ProductVariantBulkCreate_productVariantBulkCreate_bulkProductErrors[] = [ + { + __typename: "BulkProductError", + code: ProductErrorCode.UNIQUE, + field: "sku", + index: 3, + message: "Duplicated SKU." + } +]; + +const props: ProductVariantCreateContentProps = { + attributes, + currencySymbol: "USD", + data: { + attributes: dataAttributes, + price, + stock, + variants: createVariants({ + attributes: dataAttributes, + price, + stock, + variants: [] + }) + }, + dispatchFormDataAction: () => undefined, + errors: [], + onStepClick: () => undefined, + step: "values" +}; + +storiesOf("Views / Products / Create multiple variants", module) + .addDecorator(storyFn => ( + + {storyFn()} + + )) + .addDecorator(Decorator) + .add("choose values", () => ) + .add("prices and SKU", () => ( + + )); + +storiesOf("Views / Products / Create multiple variants / summary", module) + .addDecorator(storyFn => ( + + {storyFn()} + + )) + .addDecorator(Decorator) + .add("default", () => ( + + )) + .add("errors", () => ( + + )); + +storiesOf("Views / Products / Create multiple variants", module) + .addDecorator(Decorator) + .add("interactive", () => ( + undefined} + onSubmit={() => undefined} + /> + )); diff --git a/src/products/components/ProductVariantCreateDialog/ProductVariantCreateContent.tsx b/src/products/components/ProductVariantCreateDialog/ProductVariantCreateContent.tsx new file mode 100644 index 000000000..cbc32664f --- /dev/null +++ b/src/products/components/ProductVariantCreateDialog/ProductVariantCreateContent.tsx @@ -0,0 +1,147 @@ +import { Theme } from "@material-ui/core/styles"; +import { makeStyles } from "@material-ui/styles"; +import React from "react"; + +import { ProductDetails_product_productType_variantAttributes } from "@saleor/products/types/ProductDetails"; +import { ProductVariantBulkCreate_productVariantBulkCreate_bulkProductErrors } from "@saleor/products/types/ProductVariantBulkCreate"; +import { isSelected } from "@saleor/utils/lists"; +import { ProductVariantCreateFormData } from "./form"; +import ProductVariantCreatePrices from "./ProductVariantCreatePrices"; +import ProductVariantCreateSummary from "./ProductVariantCreateSummary"; +import ProductVariantCreateTabs from "./ProductVariantCreateTabs"; +import ProductVariantCreateValues from "./ProductVariantCreateValues"; +import { ProductVariantCreateReducerAction } from "./reducer"; +import { ProductVariantCreateStep } from "./types"; + +const useStyles = makeStyles((theme: Theme) => ({ + root: { + maxHeight: 400, + overflowX: "hidden", + overflowY: "scroll", + paddingLeft: theme.spacing.unit * 3, + paddingRight: theme.spacing.unit * 2, + position: "relative", + right: theme.spacing.unit * 3, + width: `calc(100% + ${theme.spacing.unit * 3}px)` + } +})); + +export interface ProductVariantCreateContentProps { + attributes: ProductDetails_product_productType_variantAttributes[]; + currencySymbol: string; + data: ProductVariantCreateFormData; + dispatchFormDataAction: React.Dispatch; + errors: ProductVariantBulkCreate_productVariantBulkCreate_bulkProductErrors[]; + step: ProductVariantCreateStep; + onStepClick: (step: ProductVariantCreateStep) => void; +} + +const ProductVariantCreateContent: React.FC< + ProductVariantCreateContentProps +> = props => { + const { + attributes, + currencySymbol, + data, + dispatchFormDataAction, + errors, + step, + onStepClick + } = props; + const classes = useStyles(props); + + const selectedAttributes = attributes.filter(attribute => + isSelected( + attribute.id, + data.attributes.map(dataAttribute => dataAttribute.id), + (a, b) => a === b + ) + ); + + return ( +
+ +
+ {step === "values" && ( + + dispatchFormDataAction({ + attributeId, + type: "selectValue", + valueId + }) + } + /> + )} + {step === "prices" && ( + + dispatchFormDataAction({ + all, + type: type === "price" ? "applyPriceToAll" : "applyStockToAll" + }) + } + onApplyToAllChange={(value, type) => + dispatchFormDataAction({ + type: + type === "price" + ? "changeApplyPriceToAllValue" + : "changeApplyStockToAllValue", + value + }) + } + onAttributeSelect={(attributeId, type) => + dispatchFormDataAction({ + attributeId, + type: + type === "price" + ? "changeApplyPriceToAttributeId" + : "changeApplyStockToAttributeId" + }) + } + onAttributeValueChange={(valueId, value, type) => + dispatchFormDataAction({ + type: + type === "price" + ? "changeAttributeValuePrice" + : "changeAttributeValueStock", + value, + valueId + }) + } + /> + )} + {step === "summary" && ( + + dispatchFormDataAction({ + field, + type: "changeVariantData", + value, + variantIndex + }) + } + onVariantDelete={variantIndex => + dispatchFormDataAction({ + type: "deleteVariant", + variantIndex + }) + } + /> + )} +
+
+ ); +}; + +ProductVariantCreateContent.displayName = "ProductVariantCreateContent"; +export default ProductVariantCreateContent; diff --git a/src/products/components/ProductVariantCreateDialog/ProductVariantCreateDialog.tsx b/src/products/components/ProductVariantCreateDialog/ProductVariantCreateDialog.tsx new file mode 100644 index 000000000..c898e9157 --- /dev/null +++ b/src/products/components/ProductVariantCreateDialog/ProductVariantCreateDialog.tsx @@ -0,0 +1,214 @@ +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 { Theme } from "@material-ui/core/styles"; +import { makeStyles } from "@material-ui/styles"; +import React from "react"; +import { FormattedMessage } from "react-intl"; + +import useModalDialogErrors from "@saleor/hooks/useModalDialogErrors"; +import useModalDialogOpen from "@saleor/hooks/useModalDialogOpen"; +import { ProductVariantBulkCreateInput } from "../../../types/globalTypes"; +import { createInitialForm, ProductVariantCreateFormData } from "./form"; +import ProductVariantCreateContent, { + ProductVariantCreateContentProps +} from "./ProductVariantCreateContent"; +import reduceProductVariantCreateFormData from "./reducer"; +import { ProductVariantCreateStep } from "./types"; + +const useStyles = makeStyles((theme: Theme) => ({ + button: { + marginLeft: theme.spacing.unit * 2 + }, + content: { + overflowX: "visible", + overflowY: "hidden", + width: 800 + }, + spacer: { + flex: 1 + } +})); + +function canHitNext( + step: ProductVariantCreateStep, + data: ProductVariantCreateFormData +): boolean { + switch (step) { + case "values": + return data.attributes.every(attribute => attribute.values.length > 0); + case "prices": + if (data.price.all) { + if (data.price.value === "") { + return false; + } + } else { + if ( + data.price.attribute === "" || + data.price.values.some(attributeValue => attributeValue.value === "") + ) { + return false; + } + } + + if (data.stock.all) { + if (data.stock.value === "") { + return false; + } + } else { + if ( + data.stock.attribute === "" || + data.stock.values.some(attributeValue => attributeValue.value === "") + ) { + return false; + } + } + + return true; + case "summary": + return data.variants.every(variant => variant.sku !== ""); + + default: + return false; + } +} + +export interface ProductVariantCreateDialogProps + extends Omit< + ProductVariantCreateContentProps, + "data" | "dispatchFormDataAction" | "step" | "onStepClick" + > { + defaultPrice: string; + open: boolean; + onClose: () => void; + onSubmit: (data: ProductVariantBulkCreateInput[]) => void; +} + +const ProductVariantCreateDialog: React.FC< + ProductVariantCreateDialogProps +> = props => { + const { + attributes, + defaultPrice, + errors: apiErrors, + open, + onClose, + onSubmit, + ...contentProps + } = props; + const classes = useStyles(props); + const [step, setStep] = React.useState("values"); + + function handleNextStep() { + switch (step) { + case "values": + setStep("prices"); + break; + case "prices": + setStep("summary"); + break; + } + } + + function handlePrevStep() { + switch (step) { + case "prices": + setStep("values"); + break; + case "summary": + setStep("prices"); + break; + } + } + + const [data, dispatchFormDataAction] = React.useReducer( + reduceProductVariantCreateFormData, + createInitialForm(attributes, defaultPrice) + ); + + const reloadForm = () => + dispatchFormDataAction({ + data: createInitialForm(attributes, defaultPrice), + type: "reload" + }); + + React.useEffect(reloadForm, [attributes.length]); + + useModalDialogOpen(open, { + onClose: () => { + reloadForm(); + setStep("values"); + } + }); + + const errors = useModalDialogErrors(apiErrors, open); + + return ( + + + + + + setStep(step)} + /> + + + +
+ {step !== "values" && ( + + )} + {step !== "summary" ? ( + + ) : ( + + )} + +
+ ); +}; + +ProductVariantCreateDialog.displayName = "ProductVariantCreateDialog"; +export default ProductVariantCreateDialog; diff --git a/src/products/components/ProductVariantCreateDialog/ProductVariantCreatePrices.tsx b/src/products/components/ProductVariantCreateDialog/ProductVariantCreatePrices.tsx new file mode 100644 index 000000000..2880d8ad4 --- /dev/null +++ b/src/products/components/ProductVariantCreateDialog/ProductVariantCreatePrices.tsx @@ -0,0 +1,313 @@ +import FormControlLabel from "@material-ui/core/FormControlLabel"; +import Radio from "@material-ui/core/Radio"; +import RadioGroup from "@material-ui/core/RadioGroup"; +import { Theme } from "@material-ui/core/styles"; +import TextField from "@material-ui/core/TextField"; +import Typography from "@material-ui/core/Typography"; +import { makeStyles } from "@material-ui/styles"; +import React from "react"; +import { FormattedMessage, useIntl } from "react-intl"; + +import FormSpacer from "@saleor/components/FormSpacer"; +import Grid from "@saleor/components/Grid"; +import Hr from "@saleor/components/Hr"; +import SingleSelectField from "@saleor/components/SingleSelectField"; +import { ProductDetails_product_productType_variantAttributes } from "@saleor/products/types/ProductDetails"; +import { ProductVariantCreateFormData } from "./form"; + +const useStyles = makeStyles((theme: Theme) => ({ + hr: { + marginBottom: theme.spacing.unit, + marginTop: theme.spacing.unit / 2 + }, + hrAttribute: { + marginTop: theme.spacing.unit * 2 + }, + label: { + alignSelf: "center" + }, + shortInput: { + width: "50%" + } +})); + +export type PriceOrStock = "price" | "stock"; +export interface ProductVariantCreatePricesProps { + attributes: ProductDetails_product_productType_variantAttributes[]; + currencySymbol: string; + data: ProductVariantCreateFormData; + onApplyPriceOrStockChange: (applyToAll: boolean, type: PriceOrStock) => void; + onApplyToAllChange: (value: string, type: PriceOrStock) => void; + onAttributeSelect: (id: string, type: PriceOrStock) => void; + onAttributeValueChange: ( + id: string, + value: string, + type: PriceOrStock + ) => void; +} + +const ProductVariantCreatePrices: React.FC< + ProductVariantCreatePricesProps +> = props => { + const { + attributes, + currencySymbol, + data, + onApplyPriceOrStockChange, + onApplyToAllChange, + onAttributeSelect, + onAttributeValueChange + } = props; + const classes = useStyles(props); + const intl = useIntl(); + + const attributeChoices = attributes.map(attribute => ({ + label: attribute.name, + value: attribute.id + })); + const priceAttributeValues = data.price.all + ? null + : data.price.attribute + ? attributes + .find(attribute => attribute.id === data.price.attribute) + .values.filter(value => + data.attributes + .find(attribute => attribute.id === data.price.attribute) + .values.includes(value.slug) + ) + : []; + const stockAttributeValues = data.stock.all + ? null + : data.stock.attribute + ? attributes + .find(attribute => attribute.id === data.stock.attribute) + .values.filter(value => + data.attributes + .find(attribute => attribute.id === data.stock.attribute) + .values.includes(value.slug) + ) + : []; + + return ( + <> + + + +
+ + } + label={intl.formatMessage({ + defaultMessage: "Apply single price to all SKUs" + })} + onChange={() => onApplyPriceOrStockChange(true, "price")} + /> + + onApplyToAllChange(event.target.value, "price")} + /> + + } + label={intl.formatMessage({ + defaultMessage: "Apply unique prices by attribute to each SKU" + })} + onChange={() => onApplyPriceOrStockChange(false, "price")} + /> + + {!data.price.all && ( + <> + + +
+ + + +
+
+ + onAttributeSelect(event.target.value, "price") + } + /> +
+
+
+ {priceAttributeValues && + priceAttributeValues.map(attributeValue => ( + <> + + +
+ {attributeValue.name} +
+
+ value.slug === attributeValue.slug + ).value + } + onChange={event => + onAttributeValueChange( + attributeValue.slug, + event.target.value, + "price" + ) + } + /> +
+
+ + ))} + + )} + + + + +
+ + } + label={intl.formatMessage({ + defaultMessage: "Apply single stock to all SKUs" + })} + onChange={() => onApplyPriceOrStockChange(true, "stock")} + /> + + onApplyToAllChange(event.target.value, "stock")} + /> + + } + label={intl.formatMessage({ + defaultMessage: "Apply unique stock by attribute to each SKU" + })} + onChange={() => onApplyPriceOrStockChange(false, "stock")} + /> + + {!data.stock.all && ( + <> + + +
+ + + +
+
+ + onAttributeSelect(event.target.value, "stock") + } + /> +
+
+
+ {stockAttributeValues && + stockAttributeValues.map(attributeValue => ( + <> + + +
+ {attributeValue.name} +
+
+ value.slug === attributeValue.slug + ).value + } + onChange={event => + onAttributeValueChange( + attributeValue.slug, + event.target.value, + "stock" + ) + } + /> +
+
+ + ))} + + )} + + ); +}; + +ProductVariantCreatePrices.displayName = "ProductVariantCreatePrices"; +export default ProductVariantCreatePrices; diff --git a/src/products/components/ProductVariantCreateDialog/ProductVariantCreateSummary.tsx b/src/products/components/ProductVariantCreateDialog/ProductVariantCreateSummary.tsx new file mode 100644 index 000000000..b251e7723 --- /dev/null +++ b/src/products/components/ProductVariantCreateDialog/ProductVariantCreateSummary.tsx @@ -0,0 +1,300 @@ +import blue from "@material-ui/core/colors/blue"; +import cyan from "@material-ui/core/colors/cyan"; +import green from "@material-ui/core/colors/green"; +import purple from "@material-ui/core/colors/purple"; +import yellow from "@material-ui/core/colors/yellow"; +import IconButton from "@material-ui/core/IconButton"; +import { Theme } from "@material-ui/core/styles"; +import TextField from "@material-ui/core/TextField"; +import Typography from "@material-ui/core/Typography"; +import DeleteIcon from "@material-ui/icons/Delete"; +import { makeStyles } from "@material-ui/styles"; +import classNames from "classnames"; +import React from "react"; +import { FormattedMessage } from "react-intl"; + +import Hr from "@saleor/components/Hr"; +import { maybe } from "@saleor/misc"; +import { ProductVariantBulkCreate_productVariantBulkCreate_bulkProductErrors } from "@saleor/products/types/ProductVariantBulkCreate"; +import { ProductVariantBulkCreateInput } from "@saleor/types/globalTypes"; +import { ProductDetails_product_productType_variantAttributes } from "../../types/ProductDetails"; +import { ProductVariantCreateFormData } from "./form"; +import { VariantField } from "./reducer"; + +export interface ProductVariantCreateSummaryProps { + attributes: ProductDetails_product_productType_variantAttributes[]; + currencySymbol: string; + data: ProductVariantCreateFormData; + errors: ProductVariantBulkCreate_productVariantBulkCreate_bulkProductErrors[]; + onVariantDataChange: ( + variantIndex: number, + field: VariantField, + value: string + ) => void; + onVariantDelete: (variantIndex: number) => void; +} + +const colors = [blue, cyan, green, purple, yellow].map(color => color[800]); + +const useStyles = makeStyles( + (theme: Theme) => ({ + attributeValue: { + display: "inline-block", + marginRight: theme.spacing.unit + }, + col: { + ...theme.typography.body2, + fontSize: 14, + paddingLeft: theme.spacing.unit, + paddingRight: theme.spacing.unit + }, + colHeader: { + ...theme.typography.body2, + fontSize: 14 + }, + colName: { + "&&": { + paddingLeft: "0 !important" + }, + "&:not($colHeader)": { + paddingTop: theme.spacing.unit * 2 + } + }, + colPrice: {}, + colSku: {}, + colStock: {}, + delete: { + marginTop: theme.spacing.unit / 2 + }, + errorRow: {}, + hr: { + marginBottom: theme.spacing.unit, + marginTop: theme.spacing.unit / 2 + }, + input: { + "& input": { + padding: "16px 12px 17px" + }, + marginTop: theme.spacing.unit / 2 + }, + row: { + borderBottom: `1px solid ${theme.palette.divider}`, + display: "grid", + gridTemplateColumns: "1fr 180px 120px 180px 64px", + padding: `${theme.spacing.unit}px 0` + } + }), + { + name: "ProductVariantCreateSummary" + } +); + +function getVariantName( + variant: ProductVariantBulkCreateInput, + attributes: ProductDetails_product_productType_variantAttributes[] +): string[] { + return attributes.reduce( + (acc, attribute) => [ + ...acc, + attribute.values.find( + value => + value.slug === + variant.attributes.find( + variantAttribute => variantAttribute.id === attribute.id + ).values[0] + ).name + ], + [] + ); +} + +const ProductVariantCreateSummary: React.FC< + ProductVariantCreateSummaryProps +> = props => { + const { + attributes, + currencySymbol, + data, + errors, + onVariantDataChange, + onVariantDelete + } = props; + const classes = useStyles(props); + + return ( + <> + + + +
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ {data.variants.map((variant, variantIndex) => { + const variantErrors = errors.filter( + error => error.index === variantIndex + ); + + return ( +
0 + })} + key={variant.attributes + .map(attribute => attribute.values[0]) + .join(":")} + > +
+ {getVariantName(variant, attributes).map( + (value, valueIndex) => ( + + {value} + + ) + )} +
+
+ error.field === "priceOverride" + ) + } + helperText={maybe( + () => + variantErrors.find( + error => error.field === "priceOverride" + ).message + )} + inputProps={{ + min: 0, + type: "number" + }} + fullWidth + value={variant.priceOverride} + onChange={event => + onVariantDataChange( + variantIndex, + "price", + event.target.value + ) + } + /> +
+
+ error.field === "quantity") + } + helperText={maybe( + () => + variantErrors.find(error => error.field === "quantity") + .message + )} + inputProps={{ + min: 0, + type: "number" + }} + fullWidth + value={variant.quantity} + onChange={event => + onVariantDataChange( + variantIndex, + "stock", + event.target.value + ) + } + /> +
+
+ error.field === "sku")} + helperText={maybe( + () => + variantErrors.find(error => error.field === "sku").message + )} + fullWidth + value={variant.sku} + onChange={event => + onVariantDataChange(variantIndex, "sku", event.target.value) + } + /> +
+
+ onVariantDelete(variantIndex)} + > + + +
+
+ ); + })} +
+ + ); +}; + +ProductVariantCreateSummary.displayName = "ProductVariantCreateSummary"; +export default ProductVariantCreateSummary; diff --git a/src/products/components/ProductVariantCreateDialog/ProductVariantCreateTabs.tsx b/src/products/components/ProductVariantCreateDialog/ProductVariantCreateTabs.tsx new file mode 100644 index 000000000..3d4667f23 --- /dev/null +++ b/src/products/components/ProductVariantCreateDialog/ProductVariantCreateTabs.tsx @@ -0,0 +1,109 @@ +import { Theme } from "@material-ui/core/styles"; +import Typography from "@material-ui/core/Typography"; +import { makeStyles } from "@material-ui/styles"; +import classNames from "classnames"; +import React from "react"; +import { IntlShape, useIntl } from "react-intl"; + +import { ProductVariantCreateStep } from "./types"; + +interface Step { + label: string; + value: ProductVariantCreateStep; +} +function getSteps(intl: IntlShape): Step[] { + return [ + { + label: intl.formatMessage({ + defaultMessage: "Select Values", + description: "attribute values, variant creation step" + }), + value: "values" + }, + { + label: intl.formatMessage({ + defaultMessage: "Prices and SKU", + description: "variant creation step" + }), + value: "prices" + }, + { + label: intl.formatMessage({ + defaultMessage: "Summary", + description: "variant creation step" + }), + value: "summary" + } + ]; +} + +const useStyles = makeStyles( + (theme: Theme) => ({ + label: { + fontSize: 14, + textAlign: "center" + }, + root: { + borderBottom: `1px solid ${theme.palette.divider}`, + display: "flex", + justifyContent: "space-between", + marginBottom: theme.spacing.unit * 3 + }, + tab: { + flex: 1, + paddingBottom: theme.spacing.unit, + userSelect: "none" + }, + tabActive: { + fontWeight: 600 + }, + tabVisited: { + borderBottom: `3px solid ${theme.palette.primary.main}`, + cursor: "pointer" + } + }), + { + name: "ProductVariantCreateTabs" + } +); + +export interface ProductVariantCreateTabsProps { + step: ProductVariantCreateStep; + onStepClick: (step: ProductVariantCreateStep) => void; +} + +const ProductVariantCreateTabs: React.FC< + ProductVariantCreateTabsProps +> = props => { + const { step: currentStep, onStepClick } = props; + const classes = useStyles(props); + const intl = useIntl(); + const steps = getSteps(intl); + + return ( +
+ {steps.map((step, stepIndex) => { + const visitedStep = + steps.findIndex(step => step.value === currentStep) >= stepIndex; + + return ( +
onStepClick(step.value) : undefined} + key={step.value} + > + + {step.label} + +
+ ); + })} +
+ ); +}; + +ProductVariantCreateTabs.displayName = "ProductVariantCreateTabs"; +export default ProductVariantCreateTabs; diff --git a/src/products/components/ProductVariantCreateDialog/ProductVariantCreateValues.tsx b/src/products/components/ProductVariantCreateDialog/ProductVariantCreateValues.tsx new file mode 100644 index 000000000..dd6fe6dc3 --- /dev/null +++ b/src/products/components/ProductVariantCreateDialog/ProductVariantCreateValues.tsx @@ -0,0 +1,79 @@ +import { Theme } from "@material-ui/core/styles"; +import Typography from "@material-ui/core/Typography"; +import makeStyles from "@material-ui/styles/makeStyles"; +import React from "react"; + +import ControlledCheckbox from "@saleor/components/ControlledCheckbox"; +import Debounce from "@saleor/components/Debounce"; +import Hr from "@saleor/components/Hr"; +import Skeleton from "@saleor/components/Skeleton"; +import { maybe } from "@saleor/misc"; +import { ProductDetails_product_productType_variantAttributes } from "@saleor/products/types/ProductDetails"; +import { isSelected } from "@saleor/utils/lists"; +import { ProductVariantCreateFormData } from "./form"; + +export interface ProductVariantCreateValuesProps { + attributes: ProductDetails_product_productType_variantAttributes[]; + data: ProductVariantCreateFormData; + onValueClick: (attributeId: string, valueId: string) => void; +} + +const useStyles = makeStyles((theme: Theme) => ({ + hr: { + marginBottom: theme.spacing.unit, + marginTop: theme.spacing.unit / 2 + }, + valueContainer: { + display: "grid", + gridColumnGap: theme.spacing.unit * 3 + "px", + gridTemplateColumns: "repeat(3, 1fr)", + marginBottom: theme.spacing.unit * 3 + } +})); + +const ProductVariantCreateValues: React.FC< + ProductVariantCreateValuesProps +> = props => { + const { attributes, data, onValueClick } = props; + const classes = useStyles(props); + + return ( + <> + {attributes.map(attribute => ( + + + {maybe(() => attribute.name, )} + +
+
+ {attribute.values.map(value => ( + onValueClick(attribute.id, value.slug)} + time={100} + key={value.slug} + > + {change => ( + attribute.id === dataAttribute.id + ).values, + (a, b) => a === b + )} + name={`value:${value.slug}`} + label={value.name} + onChange={change} + /> + )} + + ))} +
+
+ ))} + + ); +}; + +ProductVariantCreateValues.displayName = "ProductVariantCreateValues"; +export default ProductVariantCreateValues; diff --git a/src/products/components/ProductVariantCreateDialog/__snapshots__/reducer.test.ts.snap b/src/products/components/ProductVariantCreateDialog/__snapshots__/reducer.test.ts.snap new file mode 100644 index 000000000..22a84acfc --- /dev/null +++ b/src/products/components/ProductVariantCreateDialog/__snapshots__/reducer.test.ts.snap @@ -0,0 +1,1028 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Reducer is able to select attribute values 1`] = ` +Object { + "attributes": Array [ + Object { + "id": "attr-1", + "values": Array [ + "val-1-1", + "val-1-7", + ], + }, + Object { + "id": "attr-2", + "values": Array [ + "val-2-2", + "val-2-4", + ], + }, + Object { + "id": "attr-4", + "values": Array [ + "val-4-1", + "val-4-5", + ], + }, + ], + "price": Object { + "all": true, + "attribute": undefined, + "value": "10.99", + "values": Array [], + }, + "stock": Object { + "all": true, + "attribute": undefined, + "value": "", + "values": Array [], + }, + "variants": Array [], +} +`; + +exports[`Reducer is able to select price for all variants 1`] = ` +Object { + "attributes": Array [ + Object { + "id": "attr-1", + "values": Array [ + "val-1-1", + "val-1-7", + ], + }, + Object { + "id": "attr-2", + "values": Array [ + "val-2-2", + "val-2-4", + ], + }, + Object { + "id": "attr-4", + "values": Array [ + "val-4-1", + "val-4-5", + ], + }, + ], + "price": Object { + "all": true, + "attribute": undefined, + "value": "45.99", + "values": Array [], + }, + "stock": Object { + "all": true, + "attribute": undefined, + "value": "", + "values": Array [], + }, + "variants": Array [ + Object { + "attributes": Array [ + Object { + "id": "attr-1", + "values": Array [ + "val-1-1", + ], + }, + Object { + "id": "attr-2", + "values": Array [ + "val-2-2", + ], + }, + Object { + "id": "attr-4", + "values": Array [ + "val-4-1", + ], + }, + ], + "priceOverride": "45.99", + "quantity": NaN, + "sku": "", + }, + Object { + "attributes": Array [ + Object { + "id": "attr-1", + "values": Array [ + "val-1-1", + ], + }, + Object { + "id": "attr-2", + "values": Array [ + "val-2-2", + ], + }, + Object { + "id": "attr-4", + "values": Array [ + "val-4-5", + ], + }, + ], + "priceOverride": "45.99", + "quantity": NaN, + "sku": "", + }, + Object { + "attributes": Array [ + Object { + "id": "attr-1", + "values": Array [ + "val-1-1", + ], + }, + Object { + "id": "attr-2", + "values": Array [ + "val-2-4", + ], + }, + Object { + "id": "attr-4", + "values": Array [ + "val-4-1", + ], + }, + ], + "priceOverride": "45.99", + "quantity": NaN, + "sku": "", + }, + Object { + "attributes": Array [ + Object { + "id": "attr-1", + "values": Array [ + "val-1-1", + ], + }, + Object { + "id": "attr-2", + "values": Array [ + "val-2-4", + ], + }, + Object { + "id": "attr-4", + "values": Array [ + "val-4-5", + ], + }, + ], + "priceOverride": "45.99", + "quantity": NaN, + "sku": "", + }, + Object { + "attributes": Array [ + Object { + "id": "attr-1", + "values": Array [ + "val-1-7", + ], + }, + Object { + "id": "attr-2", + "values": Array [ + "val-2-2", + ], + }, + Object { + "id": "attr-4", + "values": Array [ + "val-4-1", + ], + }, + ], + "priceOverride": "45.99", + "quantity": NaN, + "sku": "", + }, + Object { + "attributes": Array [ + Object { + "id": "attr-1", + "values": Array [ + "val-1-7", + ], + }, + Object { + "id": "attr-2", + "values": Array [ + "val-2-2", + ], + }, + Object { + "id": "attr-4", + "values": Array [ + "val-4-5", + ], + }, + ], + "priceOverride": "45.99", + "quantity": NaN, + "sku": "", + }, + Object { + "attributes": Array [ + Object { + "id": "attr-1", + "values": Array [ + "val-1-7", + ], + }, + Object { + "id": "attr-2", + "values": Array [ + "val-2-4", + ], + }, + Object { + "id": "attr-4", + "values": Array [ + "val-4-1", + ], + }, + ], + "priceOverride": "45.99", + "quantity": NaN, + "sku": "", + }, + Object { + "attributes": Array [ + Object { + "id": "attr-1", + "values": Array [ + "val-1-7", + ], + }, + Object { + "id": "attr-2", + "values": Array [ + "val-2-4", + ], + }, + Object { + "id": "attr-4", + "values": Array [ + "val-4-5", + ], + }, + ], + "priceOverride": "45.99", + "quantity": NaN, + "sku": "", + }, + ], +} +`; + +exports[`Reducer is able to select price to each attribute value 1`] = ` +Object { + "attributes": Array [ + Object { + "id": "attr-1", + "values": Array [ + "val-1-1", + "val-1-7", + ], + }, + Object { + "id": "attr-2", + "values": Array [ + "val-2-2", + "val-2-4", + ], + }, + Object { + "id": "attr-4", + "values": Array [ + "val-4-1", + "val-4-5", + ], + }, + ], + "price": Object { + "all": false, + "attribute": "attr-1", + "value": "10.99", + "values": Array [ + Object { + "slug": "val-1-1", + "value": "45.99", + }, + Object { + "slug": "val-1-7", + "value": "51.99", + }, + ], + }, + "stock": Object { + "all": true, + "attribute": undefined, + "value": "", + "values": Array [], + }, + "variants": Array [ + Object { + "attributes": Array [ + Object { + "id": "attr-1", + "values": Array [ + "val-1-1", + ], + }, + Object { + "id": "attr-2", + "values": Array [ + "val-2-2", + ], + }, + Object { + "id": "attr-4", + "values": Array [ + "val-4-1", + ], + }, + ], + "priceOverride": "45.99", + "quantity": NaN, + "sku": "", + }, + Object { + "attributes": Array [ + Object { + "id": "attr-1", + "values": Array [ + "val-1-1", + ], + }, + Object { + "id": "attr-2", + "values": Array [ + "val-2-2", + ], + }, + Object { + "id": "attr-4", + "values": Array [ + "val-4-5", + ], + }, + ], + "priceOverride": "45.99", + "quantity": NaN, + "sku": "", + }, + Object { + "attributes": Array [ + Object { + "id": "attr-1", + "values": Array [ + "val-1-1", + ], + }, + Object { + "id": "attr-2", + "values": Array [ + "val-2-4", + ], + }, + Object { + "id": "attr-4", + "values": Array [ + "val-4-1", + ], + }, + ], + "priceOverride": "45.99", + "quantity": NaN, + "sku": "", + }, + Object { + "attributes": Array [ + Object { + "id": "attr-1", + "values": Array [ + "val-1-1", + ], + }, + Object { + "id": "attr-2", + "values": Array [ + "val-2-4", + ], + }, + Object { + "id": "attr-4", + "values": Array [ + "val-4-5", + ], + }, + ], + "priceOverride": "45.99", + "quantity": NaN, + "sku": "", + }, + Object { + "attributes": Array [ + Object { + "id": "attr-1", + "values": Array [ + "val-1-7", + ], + }, + Object { + "id": "attr-2", + "values": Array [ + "val-2-2", + ], + }, + Object { + "id": "attr-4", + "values": Array [ + "val-4-1", + ], + }, + ], + "priceOverride": "51.99", + "quantity": NaN, + "sku": "", + }, + Object { + "attributes": Array [ + Object { + "id": "attr-1", + "values": Array [ + "val-1-7", + ], + }, + Object { + "id": "attr-2", + "values": Array [ + "val-2-2", + ], + }, + Object { + "id": "attr-4", + "values": Array [ + "val-4-5", + ], + }, + ], + "priceOverride": "51.99", + "quantity": NaN, + "sku": "", + }, + Object { + "attributes": Array [ + Object { + "id": "attr-1", + "values": Array [ + "val-1-7", + ], + }, + Object { + "id": "attr-2", + "values": Array [ + "val-2-4", + ], + }, + Object { + "id": "attr-4", + "values": Array [ + "val-4-1", + ], + }, + ], + "priceOverride": "51.99", + "quantity": NaN, + "sku": "", + }, + Object { + "attributes": Array [ + Object { + "id": "attr-1", + "values": Array [ + "val-1-7", + ], + }, + Object { + "id": "attr-2", + "values": Array [ + "val-2-4", + ], + }, + Object { + "id": "attr-4", + "values": Array [ + "val-4-5", + ], + }, + ], + "priceOverride": "51.99", + "quantity": NaN, + "sku": "", + }, + ], +} +`; + +exports[`Reducer is able to select stock for all variants 1`] = ` +Object { + "attributes": Array [ + Object { + "id": "attr-1", + "values": Array [ + "val-1-1", + "val-1-7", + ], + }, + Object { + "id": "attr-2", + "values": Array [ + "val-2-2", + "val-2-4", + ], + }, + Object { + "id": "attr-4", + "values": Array [ + "val-4-1", + "val-4-5", + ], + }, + ], + "price": Object { + "all": true, + "attribute": undefined, + "value": "10.99", + "values": Array [], + }, + "stock": Object { + "all": true, + "attribute": undefined, + "value": "45.99", + "values": Array [], + }, + "variants": Array [ + Object { + "attributes": Array [ + Object { + "id": "attr-1", + "values": Array [ + "val-1-1", + ], + }, + Object { + "id": "attr-2", + "values": Array [ + "val-2-2", + ], + }, + Object { + "id": "attr-4", + "values": Array [ + "val-4-1", + ], + }, + ], + "priceOverride": "10.99", + "quantity": 45, + "sku": "", + }, + Object { + "attributes": Array [ + Object { + "id": "attr-1", + "values": Array [ + "val-1-1", + ], + }, + Object { + "id": "attr-2", + "values": Array [ + "val-2-2", + ], + }, + Object { + "id": "attr-4", + "values": Array [ + "val-4-5", + ], + }, + ], + "priceOverride": "10.99", + "quantity": 45, + "sku": "", + }, + Object { + "attributes": Array [ + Object { + "id": "attr-1", + "values": Array [ + "val-1-1", + ], + }, + Object { + "id": "attr-2", + "values": Array [ + "val-2-4", + ], + }, + Object { + "id": "attr-4", + "values": Array [ + "val-4-1", + ], + }, + ], + "priceOverride": "10.99", + "quantity": 45, + "sku": "", + }, + Object { + "attributes": Array [ + Object { + "id": "attr-1", + "values": Array [ + "val-1-1", + ], + }, + Object { + "id": "attr-2", + "values": Array [ + "val-2-4", + ], + }, + Object { + "id": "attr-4", + "values": Array [ + "val-4-5", + ], + }, + ], + "priceOverride": "10.99", + "quantity": 45, + "sku": "", + }, + Object { + "attributes": Array [ + Object { + "id": "attr-1", + "values": Array [ + "val-1-7", + ], + }, + Object { + "id": "attr-2", + "values": Array [ + "val-2-2", + ], + }, + Object { + "id": "attr-4", + "values": Array [ + "val-4-1", + ], + }, + ], + "priceOverride": "10.99", + "quantity": 45, + "sku": "", + }, + Object { + "attributes": Array [ + Object { + "id": "attr-1", + "values": Array [ + "val-1-7", + ], + }, + Object { + "id": "attr-2", + "values": Array [ + "val-2-2", + ], + }, + Object { + "id": "attr-4", + "values": Array [ + "val-4-5", + ], + }, + ], + "priceOverride": "10.99", + "quantity": 45, + "sku": "", + }, + Object { + "attributes": Array [ + Object { + "id": "attr-1", + "values": Array [ + "val-1-7", + ], + }, + Object { + "id": "attr-2", + "values": Array [ + "val-2-4", + ], + }, + Object { + "id": "attr-4", + "values": Array [ + "val-4-1", + ], + }, + ], + "priceOverride": "10.99", + "quantity": 45, + "sku": "", + }, + Object { + "attributes": Array [ + Object { + "id": "attr-1", + "values": Array [ + "val-1-7", + ], + }, + Object { + "id": "attr-2", + "values": Array [ + "val-2-4", + ], + }, + Object { + "id": "attr-4", + "values": Array [ + "val-4-5", + ], + }, + ], + "priceOverride": "10.99", + "quantity": 45, + "sku": "", + }, + ], +} +`; + +exports[`Reducer is able to select stock to each attribute value 1`] = ` +Object { + "attributes": Array [ + Object { + "id": "attr-1", + "values": Array [ + "val-1-1", + "val-1-7", + ], + }, + Object { + "id": "attr-2", + "values": Array [ + "val-2-2", + "val-2-4", + ], + }, + Object { + "id": "attr-4", + "values": Array [ + "val-4-1", + "val-4-5", + ], + }, + ], + "price": Object { + "all": true, + "attribute": undefined, + "value": "10.99", + "values": Array [], + }, + "stock": Object { + "all": false, + "attribute": "attr-1", + "value": "", + "values": Array [ + Object { + "slug": "val-1-1", + "value": "13", + }, + Object { + "slug": "val-1-7", + "value": "19", + }, + ], + }, + "variants": Array [ + Object { + "attributes": Array [ + Object { + "id": "attr-1", + "values": Array [ + "val-1-1", + ], + }, + Object { + "id": "attr-2", + "values": Array [ + "val-2-2", + ], + }, + Object { + "id": "attr-4", + "values": Array [ + "val-4-1", + ], + }, + ], + "priceOverride": "10.99", + "quantity": 13, + "sku": "", + }, + Object { + "attributes": Array [ + Object { + "id": "attr-1", + "values": Array [ + "val-1-1", + ], + }, + Object { + "id": "attr-2", + "values": Array [ + "val-2-2", + ], + }, + Object { + "id": "attr-4", + "values": Array [ + "val-4-5", + ], + }, + ], + "priceOverride": "10.99", + "quantity": 13, + "sku": "", + }, + Object { + "attributes": Array [ + Object { + "id": "attr-1", + "values": Array [ + "val-1-1", + ], + }, + Object { + "id": "attr-2", + "values": Array [ + "val-2-4", + ], + }, + Object { + "id": "attr-4", + "values": Array [ + "val-4-1", + ], + }, + ], + "priceOverride": "10.99", + "quantity": 13, + "sku": "", + }, + Object { + "attributes": Array [ + Object { + "id": "attr-1", + "values": Array [ + "val-1-1", + ], + }, + Object { + "id": "attr-2", + "values": Array [ + "val-2-4", + ], + }, + Object { + "id": "attr-4", + "values": Array [ + "val-4-5", + ], + }, + ], + "priceOverride": "10.99", + "quantity": 13, + "sku": "", + }, + Object { + "attributes": Array [ + Object { + "id": "attr-1", + "values": Array [ + "val-1-7", + ], + }, + Object { + "id": "attr-2", + "values": Array [ + "val-2-2", + ], + }, + Object { + "id": "attr-4", + "values": Array [ + "val-4-1", + ], + }, + ], + "priceOverride": "10.99", + "quantity": 19, + "sku": "", + }, + Object { + "attributes": Array [ + Object { + "id": "attr-1", + "values": Array [ + "val-1-7", + ], + }, + Object { + "id": "attr-2", + "values": Array [ + "val-2-2", + ], + }, + Object { + "id": "attr-4", + "values": Array [ + "val-4-5", + ], + }, + ], + "priceOverride": "10.99", + "quantity": 19, + "sku": "", + }, + Object { + "attributes": Array [ + Object { + "id": "attr-1", + "values": Array [ + "val-1-7", + ], + }, + Object { + "id": "attr-2", + "values": Array [ + "val-2-4", + ], + }, + Object { + "id": "attr-4", + "values": Array [ + "val-4-1", + ], + }, + ], + "priceOverride": "10.99", + "quantity": 19, + "sku": "", + }, + Object { + "attributes": Array [ + Object { + "id": "attr-1", + "values": Array [ + "val-1-7", + ], + }, + Object { + "id": "attr-2", + "values": Array [ + "val-2-4", + ], + }, + Object { + "id": "attr-4", + "values": Array [ + "val-4-5", + ], + }, + ], + "priceOverride": "10.99", + "quantity": 19, + "sku": "", + }, + ], +} +`; diff --git a/src/products/components/ProductVariantCreateDialog/createVariants.test.ts b/src/products/components/ProductVariantCreateDialog/createVariants.test.ts new file mode 100644 index 000000000..e41c65229 --- /dev/null +++ b/src/products/components/ProductVariantCreateDialog/createVariants.test.ts @@ -0,0 +1,220 @@ +import { + createVariantFlatMatrixDimension, + createVariants +} from "./createVariants"; +import { attributes, thirdStep } from "./fixtures"; +import { ProductVariantCreateFormData } from "./form"; + +describe("Creates variant matrix", () => { + it("with proper size", () => { + const attributes = thirdStep.attributes; + + const matrix = createVariantFlatMatrixDimension([[]], attributes); + + expect(matrix).toHaveLength( + attributes.reduce((acc, attribute) => acc * attribute.values.length, 1) + ); + }); + + it("with constant price and stock", () => { + const price = "49.99"; + const stock = 80; + + const data: ProductVariantCreateFormData = { + ...thirdStep, + price: { + ...thirdStep.price, + all: true, + value: price + }, + stock: { + ...thirdStep.stock, + all: true, + value: stock.toString() + } + }; + + const variants = createVariants(data); + expect(variants).toHaveLength( + thirdStep.attributes.reduce( + (acc, attribute) => acc * attribute.values.length, + 1 + ) + ); + + variants.forEach(variant => { + expect(variant.priceOverride).toBe(price); + expect(variant.quantity).toBe(stock); + }); + }); + + it("with constant stock and attribute dependent price", () => { + const price = 49.99; + const stock = 80; + const attribute = attributes.find( + attribute => attribute.id === thirdStep.attributes[0].id + ); + + const data: ProductVariantCreateFormData = { + ...thirdStep, + price: { + ...thirdStep.price, + all: false, + attribute: attribute.id, + values: attribute.values.map((attributeValue, attributeValueIndex) => ({ + slug: attributeValue, + value: (price * (attributeValueIndex + 1)).toString() + })) + }, + stock: { + ...thirdStep.stock, + all: true, + value: stock.toString() + } + }; + + const variants = createVariants(data); + expect(variants).toHaveLength( + thirdStep.attributes.reduce( + (acc, attribute) => acc * attribute.values.length, + 1 + ) + ); + + variants.forEach(variant => { + expect(variant.quantity).toBe(stock); + }); + + attribute.values.forEach((attributeValue, attributeValueIndex) => { + variants + .filter( + variant => + variant.attributes.find( + variantAttribute => variantAttribute.id === attribute.id + ).values[0] === attributeValue + ) + .forEach(variant => { + expect(variant.priceOverride).toBe( + (price * (attributeValueIndex + 1)).toString() + ); + }); + }); + }); + + it("with constant price and attribute dependent stock", () => { + const price = "49.99"; + const stock = 80; + const attribute = attributes.find( + attribute => attribute.id === thirdStep.attributes[0].id + ); + + const data: ProductVariantCreateFormData = { + ...thirdStep, + price: { + ...thirdStep.price, + all: true, + value: price + }, + stock: { + ...thirdStep.stock, + all: false, + attribute: attribute.id, + values: attribute.values.map((attributeValue, attributeValueIndex) => ({ + slug: attributeValue, + value: (stock * (attributeValueIndex + 1)).toString() + })) + } + }; + + const variants = createVariants(data); + expect(variants).toHaveLength( + thirdStep.attributes.reduce( + (acc, attribute) => acc * attribute.values.length, + 1 + ) + ); + + variants.forEach(variant => { + expect(variant.priceOverride).toBe(price); + }); + + attribute.values.forEach((attributeValue, attributeValueIndex) => { + variants + .filter( + variant => + variant.attributes.find( + variantAttribute => variantAttribute.id === attribute.id + ).values[0] === attributeValue + ) + .forEach(variant => { + expect(variant.quantity).toBe(stock * (attributeValueIndex + 1)); + }); + }); + }); + + it("with attribute dependent price and stock", () => { + const price = 49.99; + const stock = 80; + const attribute = attributes.find( + attribute => attribute.id === thirdStep.attributes[0].id + ); + + const data: ProductVariantCreateFormData = { + ...thirdStep, + price: { + ...thirdStep.price, + all: false, + attribute: attribute.id, + values: attribute.values.map((attributeValue, attributeValueIndex) => ({ + slug: attributeValue, + value: (price * (attributeValueIndex + 1)).toString() + })) + }, + stock: { + ...thirdStep.stock, + all: false, + attribute: attribute.id, + values: attribute.values.map((attributeValue, attributeValueIndex) => ({ + slug: attributeValue, + value: (stock * (attributeValueIndex + 1)).toString() + })) + } + }; + + const variants = createVariants(data); + expect(variants).toHaveLength( + thirdStep.attributes.reduce( + (acc, attribute) => acc * attribute.values.length, + 1 + ) + ); + + attribute.values.forEach((attributeValue, attributeValueIndex) => { + variants + .filter( + variant => + variant.attributes.find( + variantAttribute => variantAttribute.id === attribute.id + ).values[0] === attributeValue + ) + .forEach(variant => { + expect(variant.priceOverride).toBe( + (price * (attributeValueIndex + 1)).toString() + ); + }); + }); + + attribute.values.forEach((attributeValue, attributeValueIndex) => { + variants + .filter( + variant => + variant.attributes.find( + variantAttribute => variantAttribute.id === attribute.id + ).values[0] === attributeValue + ) + .forEach(variant => { + expect(variant.quantity).toBe(stock * (attributeValueIndex + 1)); + }); + }); + }); +}); diff --git a/src/products/components/ProductVariantCreateDialog/createVariants.ts b/src/products/components/ProductVariantCreateDialog/createVariants.ts new file mode 100644 index 000000000..6239832e2 --- /dev/null +++ b/src/products/components/ProductVariantCreateDialog/createVariants.ts @@ -0,0 +1,105 @@ +import { ProductVariantBulkCreateInput } from "@saleor/types/globalTypes"; +import { + AllOrAttribute, + Attribute, + ProductVariantCreateFormData +} from "./form"; + +interface CreateVariantAttributeValueInput { + attributeId: string; + attributeValueSlug: string; +} +type CreateVariantInput = CreateVariantAttributeValueInput[]; + +function getAttributeValuePriceOrStock( + attributes: CreateVariantInput, + priceOrStock: AllOrAttribute +): string { + const attribute = attributes.find( + attribute => attribute.attributeId === priceOrStock.attribute + ); + + const attributeValue = priceOrStock.values.find( + attributeValue => attribute.attributeValueSlug === attributeValue.slug + ); + + return attributeValue.value; +} + +function createVariant( + data: ProductVariantCreateFormData, + attributes: CreateVariantInput +): ProductVariantBulkCreateInput { + const priceOverride = data.price.all + ? data.price.value + : getAttributeValuePriceOrStock(attributes, data.price); + const quantity = parseInt( + data.stock.all + ? data.stock.value + : getAttributeValuePriceOrStock(attributes, data.stock), + 10 + ); + + return { + attributes: attributes.map(attribute => ({ + id: attribute.attributeId, + values: [attribute.attributeValueSlug] + })), + priceOverride, + quantity, + sku: "" + }; +} + +function addAttributeToVariant( + attribute: Attribute, + variant: CreateVariantInput +): CreateVariantInput[] { + return attribute.values.map(attributeValueSlug => [ + ...variant, + { + attributeId: attribute.id, + attributeValueSlug + } + ]); +} +function addVariantAttributeInput( + data: CreateVariantInput[], + attribute: Attribute +): CreateVariantInput[] { + const variants = data + .map(variant => addAttributeToVariant(attribute, variant)) + .reduce((acc, variantInput) => [...acc, ...variantInput]); + + return variants; +} + +export function createVariantFlatMatrixDimension( + variants: CreateVariantInput[], + attributes: Attribute[] +): CreateVariantInput[] { + if (attributes.length > 0) { + return createVariantFlatMatrixDimension( + addVariantAttributeInput(variants, attributes[0]), + attributes.slice(1) + ); + } else { + return variants; + } +} + +export function createVariants( + data: ProductVariantCreateFormData +): ProductVariantBulkCreateInput[] { + if ( + (!data.price.all && !data.price.attribute) || + (!data.stock.all && !data.stock.attribute) + ) { + return []; + } + const variants = createVariantFlatMatrixDimension([[]], data.attributes).map( + variant => createVariant(data, variant) + ); + + return variants; +} diff --git a/src/products/components/ProductVariantCreateDialog/fixtures.ts b/src/products/components/ProductVariantCreateDialog/fixtures.ts new file mode 100644 index 000000000..5cc1b3ce2 --- /dev/null +++ b/src/products/components/ProductVariantCreateDialog/fixtures.ts @@ -0,0 +1,110 @@ +import { createVariants } from "./createVariants"; +import { + AllOrAttribute, + createInitialForm, + ProductVariantCreateFormData +} from "./form"; + +export const attributes = [ + { + id: "attr-1", + values: Array(9) + .fill(0) + .map((_, index) => `val-1-${index + 1}`) + }, + { + id: "attr-2", + values: Array(6) + .fill(0) + .map((_, index) => `val-2-${index + 1}`) + }, + { + id: "attr-3", + values: Array(4) + .fill(0) + .map((_, index) => `val-3-${index + 1}`) + }, + { + id: "attr-4", + values: Array(11) + .fill(0) + .map((_, index) => `val-4-${index + 1}`) + } +]; + +export const secondStep: ProductVariantCreateFormData = { + ...createInitialForm([], "10.99"), + attributes: [ + { + id: attributes[0].id, + values: [] + }, + { + id: attributes[1].id, + values: [] + }, + { + id: attributes[3].id, + values: [] + } + ] +}; + +export const thirdStep: ProductVariantCreateFormData = { + ...secondStep, + attributes: [ + { + id: attributes[0].id, + values: [0, 6].map(index => attributes[0].values[index]) + }, + { + id: attributes[1].id, + values: [1, 3].map(index => attributes[1].values[index]) + }, + { + id: attributes[3].id, + values: [0, 4].map(index => attributes[3].values[index]) + } + ] +}; + +const price: AllOrAttribute = { + all: false, + attribute: thirdStep.attributes[1].id, + value: "", + values: [ + { + slug: thirdStep.attributes[1].values[0], + value: "24.99" + }, + { + slug: thirdStep.attributes[1].values[1], + value: "26.99" + } + ] +}; +const stock: AllOrAttribute = { + all: false, + attribute: thirdStep.attributes[2].id, + value: "", + values: [ + { + slug: thirdStep.attributes[2].values[0], + value: "50" + }, + { + slug: thirdStep.attributes[2].values[1], + value: "35" + } + ] +}; +export const fourthStep: ProductVariantCreateFormData = { + ...thirdStep, + price, + stock, + variants: createVariants({ + ...thirdStep, + price, + stock + }) +}; diff --git a/src/products/components/ProductVariantCreateDialog/form.ts b/src/products/components/ProductVariantCreateDialog/form.ts new file mode 100644 index 000000000..d51f13b5d --- /dev/null +++ b/src/products/components/ProductVariantCreateDialog/form.ts @@ -0,0 +1,46 @@ +import { ProductDetails_product_productType_variantAttributes } from "@saleor/products/types/ProductDetails"; +import { ProductVariantBulkCreateInput } from "../../../types/globalTypes"; + +export interface AttributeValue { + slug: string; + value: string; +} +export interface AllOrAttribute { + all: boolean; + attribute: string; + value: string; + values: AttributeValue[]; +} +export interface Attribute { + id: string; + values: string[]; +} +export interface ProductVariantCreateFormData { + attributes: Attribute[]; + price: AllOrAttribute; + stock: AllOrAttribute; + variants: ProductVariantBulkCreateInput[]; +} + +export const createInitialForm = ( + attributes: ProductDetails_product_productType_variantAttributes[], + price: string +): ProductVariantCreateFormData => ({ + attributes: attributes.map(attribute => ({ + id: attribute.id, + values: [] + })), + price: { + all: true, + attribute: undefined, + value: price || "", + values: [] + }, + stock: { + all: true, + attribute: undefined, + value: "", + values: [] + }, + variants: [] +}); diff --git a/src/products/components/ProductVariantCreateDialog/index.ts b/src/products/components/ProductVariantCreateDialog/index.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/products/components/ProductVariantCreateDialog/reducer.test.ts b/src/products/components/ProductVariantCreateDialog/reducer.test.ts new file mode 100644 index 000000000..9a224af0f --- /dev/null +++ b/src/products/components/ProductVariantCreateDialog/reducer.test.ts @@ -0,0 +1,185 @@ +import { attributes, fourthStep, secondStep, thirdStep } from "./fixtures"; +import reducer, { VariantField } from "./reducer"; + +function execActions( + initialState: TState, + reducer: (state: TState, action: TAction) => TState, + actions: TAction[] +): TState { + return actions.reduce((acc, action) => reducer(acc, action), initialState); +} + +describe("Reducer is able to", () => { + it("select attribute values", () => { + const state = execActions(secondStep, reducer, [ + { + attributeId: attributes[0].id, + type: "selectValue", + valueId: attributes[0].values[0] + }, + { + attributeId: attributes[0].id, + type: "selectValue", + valueId: attributes[0].values[6] + }, + { + attributeId: attributes[1].id, + type: "selectValue", + valueId: attributes[1].values[1] + }, + { + attributeId: attributes[1].id, + type: "selectValue", + valueId: attributes[1].values[3] + }, + { + attributeId: attributes[3].id, + type: "selectValue", + valueId: attributes[3].values[0] + }, + { + attributeId: attributes[3].id, + type: "selectValue", + valueId: attributes[3].values[4] + } + ]); + + expect(state.attributes[0].values).toHaveLength(2); + expect(state.attributes[1].values).toHaveLength(2); + expect(state.attributes[2].values).toHaveLength(2); + expect(state).toMatchSnapshot(); + }); + + it("select price for all variants", () => { + const value = "45.99"; + const state = execActions(thirdStep, reducer, [ + { + all: true, + type: "applyPriceToAll" + }, + { + type: "changeApplyPriceToAllValue", + value + } + ]); + + expect(state.price.all).toBeTruthy(); + expect(state.price.value).toBe(value); + expect(state).toMatchSnapshot(); + }); + + it("select stock for all variants", () => { + const value = 45.99; + const state = execActions(thirdStep, reducer, [ + { + all: true, + type: "applyStockToAll" + }, + { + type: "changeApplyStockToAllValue", + value: value.toString() + } + ]); + + expect(state.stock.all).toBeTruthy(); + expect(state.stock.value).toBe(value.toString()); + expect(state).toMatchSnapshot(); + }); + + it("select price to each attribute value", () => { + const attribute = thirdStep.attributes[0]; + const value = 45.99; + const state = execActions(thirdStep, reducer, [ + { + all: false, + type: "applyPriceToAll" + }, + { + attributeId: attribute.id, + type: "changeApplyPriceToAttributeId" + }, + { + type: "changeAttributeValuePrice", + value: value.toString(), + valueId: attribute.values[0] + }, + { + type: "changeAttributeValuePrice", + value: (value + 6).toString(), + valueId: attribute.values[1] + } + ]); + + expect(state.price.all).toBeFalsy(); + expect(state.price.values).toHaveLength( + state.attributes.find(attribute => state.price.attribute === attribute.id) + .values.length + ); + expect(state).toMatchSnapshot(); + }); + + it("select stock to each attribute value", () => { + const attribute = thirdStep.attributes[0]; + const value = 13; + const state = execActions(thirdStep, reducer, [ + { + all: false, + type: "applyStockToAll" + }, + { + attributeId: attribute.id, + type: "changeApplyStockToAttributeId" + }, + { + type: "changeAttributeValueStock", + value: value.toString(), + valueId: attribute.values[0] + }, + { + type: "changeAttributeValueStock", + value: (value + 6).toString(), + valueId: attribute.values[1] + } + ]); + + expect(state.stock.all).toBeFalsy(); + expect(state.stock.values).toHaveLength( + state.attributes.find(attribute => state.stock.attribute === attribute.id) + .values.length + ); + expect(state).toMatchSnapshot(); + }); + + it("modify individual variant price", () => { + const field: VariantField = "price"; + const value = "49.99"; + const variantIndex = 3; + + const state = execActions(fourthStep, reducer, [ + { + field, + type: "changeVariantData", + value, + variantIndex + } + ]); + + expect(state.variants[variantIndex].priceOverride).toBe(value); + expect(state.variants[variantIndex - 1].priceOverride).toBe( + fourthStep.variants[variantIndex - 1].priceOverride + ); + }); + + it("delete variant", () => { + const variantIndex = 3; + + const state = execActions(fourthStep, reducer, [ + { + type: "deleteVariant", + variantIndex + } + ]); + + expect(state.variants.length).toBe(fourthStep.variants.length - 1); + }); +}); diff --git a/src/products/components/ProductVariantCreateDialog/reducer.ts b/src/products/components/ProductVariantCreateDialog/reducer.ts new file mode 100644 index 000000000..ee8480ef0 --- /dev/null +++ b/src/products/components/ProductVariantCreateDialog/reducer.ts @@ -0,0 +1,363 @@ +//#region +import { + add, + remove, + removeAtIndex, + toggle, + updateAtIndex +} from "@saleor/utils/lists"; +import { createVariants } from "./createVariants"; +import { ProductVariantCreateFormData } from "./form"; + +export type ProductVariantCreateReducerActionType = + | "applyPriceToAll" + | "applyPriceToAttribute" + | "applyStockToAll" + | "applyStockToAttribute" + | "changeApplyPriceToAllValue" + | "changeApplyPriceToAttributeId" + | "changeApplyStockToAllValue" + | "changeApplyStockToAttributeId" + | "changeAttributeValuePrice" + | "changeAttributeValueStock" + | "changeVariantData" + | "deleteVariant" + | "reload" + | "selectValue"; + +export type VariantField = "stock" | "price" | "sku"; +export interface ProductVariantCreateReducerAction { + all?: boolean; + attributeId?: string; + data?: ProductVariantCreateFormData; + field?: VariantField; + type: ProductVariantCreateReducerActionType; + value?: string; + valueId?: string; + variantIndex?: number; +} +//#endregion +function selectValue( + prevState: ProductVariantCreateFormData, + attributeId: string, + valueSlug: string +): ProductVariantCreateFormData { + const attribute = prevState.attributes.find( + attribute => attribute.id === attributeId + ); + const values = toggle(valueSlug, attribute.values, (a, b) => a === b); + const updatedAttributes = add( + { + id: attributeId, + values + }, + remove(attribute, prevState.attributes, (a, b) => a.id === b.id) + ); + + const priceValues = + prevState.price.attribute === attributeId + ? toggle( + { + slug: valueSlug, + value: "" + }, + prevState.price.values, + (a, b) => a.slug === b.slug + ) + : prevState.price.values; + + const stockValues = + prevState.stock.attribute === attributeId + ? toggle( + { + slug: valueSlug, + value: "" + }, + prevState.stock.values, + (a, b) => a.slug === b.slug + ) + : prevState.stock.values; + + return { + ...prevState, + attributes: updatedAttributes, + price: { + ...prevState.price, + values: priceValues + }, + stock: { + ...prevState.stock, + values: stockValues + } + }; +} + +function applyPriceToAll( + state: ProductVariantCreateFormData, + value: boolean +): ProductVariantCreateFormData { + const data = { + ...state, + price: { + ...state.price, + all: value + } + }; + + return { + ...data, + variants: createVariants(data) + }; +} + +function applyStockToAll( + state: ProductVariantCreateFormData, + value: boolean +): ProductVariantCreateFormData { + const data = { + ...state, + stock: { + ...state.stock, + all: value + } + }; + + return { + ...data, + variants: createVariants(data) + }; +} + +function changeAttributeValuePrice( + state: ProductVariantCreateFormData, + attributeValueSlug: string, + price: string +): ProductVariantCreateFormData { + const index = state.price.values.findIndex( + value => value.slug === attributeValueSlug + ); + + if (index === -1) { + throw new Error(`Value with id ${attributeValueSlug} not found`); + } + + const values = updateAtIndex( + { + slug: attributeValueSlug, + value: price + }, + state.price.values, + index + ); + + const data = { + ...state, + price: { + ...state.price, + values + } + }; + + return { + ...data, + variants: createVariants(data) + }; +} + +function changeAttributeValueStock( + state: ProductVariantCreateFormData, + attributeValueSlug: string, + stock: string +): ProductVariantCreateFormData { + const index = state.stock.values.findIndex( + value => value.slug === attributeValueSlug + ); + + if (index === -1) { + throw new Error(`Value with id ${attributeValueSlug} not found`); + } + + const values = updateAtIndex( + { + slug: attributeValueSlug, + value: stock + }, + state.stock.values, + index + ); + + const data = { + ...state, + stock: { + ...state.stock, + values + } + }; + + return { + ...data, + variants: createVariants(data) + }; +} + +function changeApplyPriceToAttributeId( + state: ProductVariantCreateFormData, + attributeId: string +): ProductVariantCreateFormData { + const attribute = state.attributes.find( + attribute => attribute.id === attributeId + ); + const values = attribute.values.map(slug => ({ + slug, + value: "" + })); + const data = { + ...state, + price: { + ...state.price, + attribute: attributeId, + values + } + }; + + return { + ...data, + variants: createVariants(data) + }; +} + +function changeApplyStockToAttributeId( + state: ProductVariantCreateFormData, + attributeId: string +): ProductVariantCreateFormData { + const attribute = state.attributes.find( + attribute => attribute.id === attributeId + ); + const values = attribute.values.map(slug => ({ + slug, + value: "" + })); + + const data = { + ...state, + stock: { + ...state.stock, + attribute: attributeId, + values + } + }; + + return { + ...data, + variants: createVariants(data) + }; +} + +function changeApplyPriceToAllValue( + state: ProductVariantCreateFormData, + value: string +): ProductVariantCreateFormData { + const data = { + ...state, + price: { + ...state.price, + value + } + }; + + return { + ...data, + variants: createVariants(data) + }; +} + +function changeApplyStockToAllValue( + state: ProductVariantCreateFormData, + value: string +): ProductVariantCreateFormData { + const data = { + ...state, + stock: { + ...state.stock, + value + } + }; + + return { + ...data, + variants: createVariants(data) + }; +} + +function changeVariantData( + state: ProductVariantCreateFormData, + field: VariantField, + value: string, + variantIndex: number +): ProductVariantCreateFormData { + const variant = state.variants[variantIndex]; + if (field === "price") { + variant.priceOverride = value; + } else if (field === "sku") { + variant.sku = value; + } else { + variant.quantity = parseInt(value, 10); + } + + return { + ...state, + variants: updateAtIndex(variant, state.variants, variantIndex) + }; +} + +function deleteVariant( + state: ProductVariantCreateFormData, + variantIndex: number +): ProductVariantCreateFormData { + return { + ...state, + variants: removeAtIndex(state.variants, variantIndex) + }; +} + +function reduceProductVariantCreateFormData( + prevState: ProductVariantCreateFormData, + action: ProductVariantCreateReducerAction +) { + switch (action.type) { + case "selectValue": + return selectValue(prevState, action.attributeId, action.valueId); + + case "applyPriceToAll": + return applyPriceToAll(prevState, action.all); + case "applyStockToAll": + return applyStockToAll(prevState, action.all); + case "changeAttributeValuePrice": + return changeAttributeValuePrice(prevState, action.valueId, action.value); + case "changeAttributeValueStock": + return changeAttributeValueStock(prevState, action.valueId, action.value); + case "changeApplyPriceToAttributeId": + return changeApplyPriceToAttributeId(prevState, action.attributeId); + case "changeApplyStockToAttributeId": + return changeApplyStockToAttributeId(prevState, action.attributeId); + case "changeApplyPriceToAllValue": + return changeApplyPriceToAllValue(prevState, action.value); + case "changeApplyStockToAllValue": + return changeApplyStockToAllValue(prevState, action.value); + case "changeVariantData": + return changeVariantData( + prevState, + action.field, + action.value, + action.variantIndex + ); + case "deleteVariant": + return deleteVariant(prevState, action.variantIndex); + case "reload": + return action.data; + default: + return prevState; + } +} + +export default reduceProductVariantCreateFormData; diff --git a/src/products/components/ProductVariantCreateDialog/types.ts b/src/products/components/ProductVariantCreateDialog/types.ts new file mode 100644 index 000000000..576a569aa --- /dev/null +++ b/src/products/components/ProductVariantCreateDialog/types.ts @@ -0,0 +1 @@ +export type ProductVariantCreateStep = "values" | "prices" | "summary"; diff --git a/src/products/components/ProductVariants/ProductVariants.tsx b/src/products/components/ProductVariants/ProductVariants.tsx index bf6332396..0de9f974d 100644 --- a/src/products/components/ProductVariants/ProductVariants.tsx +++ b/src/products/components/ProductVariants/ProductVariants.tsx @@ -69,6 +69,7 @@ interface ProductVariantsProps extends ListActions, WithStyles { fallbackPrice?: ProductVariant_costPrice; onRowClick: (id: string) => () => void; onVariantAdd?(); + onVariantsAdd?(); } const numberOfColumns = 5; @@ -81,6 +82,7 @@ export const ProductVariants = withStyles(styles, { name: "ProductVariants" })( fallbackPrice, onRowClick, onVariantAdd, + onVariantsAdd, isChecked, selected, toggle, @@ -98,7 +100,7 @@ export const ProductVariants = withStyles(styles, { name: "ProductVariants" })( description: "section header" })} toolbar={ - <> + hasVariants ? ( - + ) : ( + + ) } /> {!variants.length && ( diff --git a/src/products/containers/ProductUpdateOperations.tsx b/src/products/containers/ProductUpdateOperations.tsx index 3ed040bbc..73940d8e5 100644 --- a/src/products/containers/ProductUpdateOperations.tsx +++ b/src/products/containers/ProductUpdateOperations.tsx @@ -7,6 +7,7 @@ import { TypedProductImageCreateMutation, TypedProductImageDeleteMutation, TypedProductUpdateMutation, + TypedProductVariantBulkCreateMutation, TypedProductVariantBulkDeleteMutation, TypedSimpleProductUpdateMutation } from "../mutations"; @@ -25,6 +26,10 @@ import { ProductImageReorderVariables } from "../types/ProductImageReorder"; import { ProductUpdate, ProductUpdateVariables } from "../types/ProductUpdate"; +import { + ProductVariantBulkCreate, + ProductVariantBulkCreateVariables +} from "../types/ProductVariantBulkCreate"; import { ProductVariantBulkDelete, ProductVariantBulkDeleteVariables @@ -38,6 +43,10 @@ import ProductImagesReorderProvider from "./ProductImagesReorder"; interface ProductUpdateOperationsProps { product: ProductDetails_product; children: (props: { + bulkProductVariantCreate: PartialMutationProviderOutput< + ProductVariantBulkCreate, + ProductVariantBulkCreateVariables + >; bulkProductVariantDelete: PartialMutationProviderOutput< ProductVariantBulkDelete, ProductVariantBulkDeleteVariables @@ -67,6 +76,7 @@ interface ProductUpdateOperationsProps { SimpleProductUpdateVariables >; }) => React.ReactNode; + onBulkProductVariantCreate?: (data: ProductVariantBulkCreate) => void; onBulkProductVariantDelete?: (data: ProductVariantBulkDelete) => void; onDelete?: (data: ProductDelete) => void; onImageCreate?: (data: ProductImageCreate) => void; @@ -80,6 +90,7 @@ const ProductUpdateOperations: React.StatelessComponent< > = ({ product, children, + onBulkProductVariantCreate, onBulkProductVariantDelete, onDelete, onImageDelete, @@ -112,31 +123,40 @@ const ProductUpdateOperations: React.StatelessComponent< - {(...bulkProductVariantDelete) => - children({ - bulkProductVariantDelete: getMutationProviderData( - ...bulkProductVariantDelete - ), - createProductImage: getMutationProviderData( - ...createProductImage - ), - deleteProduct: getMutationProviderData( - ...deleteProduct - ), - deleteProductImage: getMutationProviderData( - ...deleteProductImage - ), - reorderProductImages: getMutationProviderData( - ...reorderProductImages - ), - updateProduct: getMutationProviderData( - ...updateProduct - ), - updateSimpleProduct: getMutationProviderData( - ...updateSimpleProduct - ) - }) - } + {(...bulkProductVariantDelete) => ( + + {(...bulkProductVariantCreate) => + children({ + bulkProductVariantCreate: getMutationProviderData( + ...bulkProductVariantCreate + ), + bulkProductVariantDelete: getMutationProviderData( + ...bulkProductVariantDelete + ), + createProductImage: getMutationProviderData( + ...createProductImage + ), + deleteProduct: getMutationProviderData( + ...deleteProduct + ), + deleteProductImage: getMutationProviderData( + ...deleteProductImage + ), + reorderProductImages: getMutationProviderData( + ...reorderProductImages + ), + updateProduct: getMutationProviderData( + ...updateProduct + ), + updateSimpleProduct: getMutationProviderData( + ...updateSimpleProduct + ) + }) + } + + )} )} diff --git a/src/products/mutations.ts b/src/products/mutations.ts index ef7024d69..c612d576d 100644 --- a/src/products/mutations.ts +++ b/src/products/mutations.ts @@ -45,6 +45,10 @@ import { productBulkPublish, productBulkPublishVariables } from "./types/productBulkPublish"; +import { + ProductVariantBulkCreate, + ProductVariantBulkCreateVariables +} from "./types/ProductVariantBulkCreate"; import { ProductVariantBulkDelete, ProductVariantBulkDeleteVariables @@ -319,26 +323,8 @@ export const TypedVariantUpdateMutation = TypedMutation< export const variantCreateMutation = gql` ${fragmentVariant} - mutation VariantCreate( - $attributes: [AttributeValueInput]! - $costPrice: Decimal - $priceOverride: Decimal - $product: ID! - $sku: String - $quantity: Int - $trackInventory: Boolean! - ) { - productVariantCreate( - input: { - attributes: $attributes - costPrice: $costPrice - priceOverride: $priceOverride - product: $product - sku: $sku - quantity: $quantity - trackInventory: $trackInventory - } - ) { + mutation VariantCreate($input: ProductVariantCreateInput!) { + productVariantCreate(input: $input) { errors { field message @@ -458,6 +444,30 @@ export const TypedProductBulkPublishMutation = TypedMutation< productBulkPublishVariables >(productBulkPublishMutation); +export const ProductVariantBulkCreateMutation = gql` + mutation ProductVariantBulkCreate( + $id: ID! + $inputs: [ProductVariantBulkCreateInput]! + ) { + productVariantBulkCreate(product: $id, variants: $inputs) { + bulkProductErrors { + field + message + code + index + } + errors { + field + message + } + } + } +`; +export const TypedProductVariantBulkCreateMutation = TypedMutation< + ProductVariantBulkCreate, + ProductVariantBulkCreateVariables +>(ProductVariantBulkCreateMutation); + export const ProductVariantBulkDeleteMutation = gql` mutation ProductVariantBulkDelete($ids: [ID!]!) { productVariantBulkDelete(ids: $ids) { diff --git a/src/products/queries.ts b/src/products/queries.ts index fa60db7cb..626b6ff3a 100644 --- a/src/products/queries.ts +++ b/src/products/queries.ts @@ -260,6 +260,17 @@ const productDetailsQuery = gql` query ProductDetails($id: ID!) { product(id: $id) { ...Product + productType { + variantAttributes { + id + name + values { + id + name + slug + } + } + } } } `; diff --git a/src/products/types/ProductDetails.ts b/src/products/types/ProductDetails.ts index 5094afd59..3ed43c47a 100644 --- a/src/products/types/ProductDetails.ts +++ b/src/products/types/ProductDetails.ts @@ -139,11 +139,26 @@ export interface ProductDetails_product_variants { stockQuantity: number; } +export interface ProductDetails_product_productType_variantAttributes_values { + __typename: "AttributeValue"; + id: string; + name: string | null; + slug: string | null; +} + +export interface ProductDetails_product_productType_variantAttributes { + __typename: "Attribute"; + id: string; + name: string | null; + values: (ProductDetails_product_productType_variantAttributes_values | null)[] | null; +} + export interface ProductDetails_product_productType { __typename: "ProductType"; id: string; name: string; hasVariants: boolean; + variantAttributes: (ProductDetails_product_productType_variantAttributes | null)[] | null; } export interface ProductDetails_product { diff --git a/src/products/types/ProductVariantBulkCreate.ts b/src/products/types/ProductVariantBulkCreate.ts new file mode 100644 index 000000000..f0a0160ff --- /dev/null +++ b/src/products/types/ProductVariantBulkCreate.ts @@ -0,0 +1,38 @@ +/* tslint:disable */ +/* eslint-disable */ +// This file was automatically generated and should not be edited. + +import { ProductVariantBulkCreateInput, ProductErrorCode } from "./../../types/globalTypes"; + +// ==================================================== +// GraphQL mutation operation: ProductVariantBulkCreate +// ==================================================== + +export interface ProductVariantBulkCreate_productVariantBulkCreate_bulkProductErrors { + __typename: "BulkProductError"; + field: string | null; + message: string | null; + code: ProductErrorCode | null; + index: number | null; +} + +export interface ProductVariantBulkCreate_productVariantBulkCreate_errors { + __typename: "Error"; + field: string | null; + message: string | null; +} + +export interface ProductVariantBulkCreate_productVariantBulkCreate { + __typename: "ProductVariantBulkCreate"; + bulkProductErrors: ProductVariantBulkCreate_productVariantBulkCreate_bulkProductErrors[] | null; + errors: ProductVariantBulkCreate_productVariantBulkCreate_errors[] | null; +} + +export interface ProductVariantBulkCreate { + productVariantBulkCreate: ProductVariantBulkCreate_productVariantBulkCreate | null; +} + +export interface ProductVariantBulkCreateVariables { + id: string; + inputs: (ProductVariantBulkCreateInput | null)[]; +} diff --git a/src/products/types/VariantCreate.ts b/src/products/types/VariantCreate.ts index 4dcfdd5d1..b1d2783b1 100644 --- a/src/products/types/VariantCreate.ts +++ b/src/products/types/VariantCreate.ts @@ -2,7 +2,7 @@ /* eslint-disable */ // This file was automatically generated and should not be edited. -import { AttributeValueInput } from "./../../types/globalTypes"; +import { ProductVariantCreateInput } from "./../../types/globalTypes"; // ==================================================== // GraphQL mutation operation: VariantCreate @@ -122,11 +122,5 @@ export interface VariantCreate { } export interface VariantCreateVariables { - attributes: (AttributeValueInput | null)[]; - costPrice?: any | null; - priceOverride?: any | null; - product: string; - sku?: string | null; - quantity?: number | null; - trackInventory: boolean; + input: ProductVariantCreateInput; } diff --git a/src/products/urls.ts b/src/products/urls.ts index 5ebfe8d31..2f240496f 100644 --- a/src/products/urls.ts +++ b/src/products/urls.ts @@ -53,7 +53,7 @@ export const productListUrl = (params?: ProductListUrlQueryParams): string => export const productPath = (id: string) => urlJoin(productSection + id); export type ProductUrlDialog = "remove"; export type ProductUrlQueryParams = BulkAction & - Dialog<"remove" | "remove-variants">; + Dialog<"create-variants" | "remove" | "remove-variants">; export const productUrl = (id: string, params?: ProductUrlQueryParams) => productPath(encodeURIComponent(id)) + "?" + stringifyQs(params); diff --git a/src/products/views/ProductUpdate/ProductUpdate.tsx b/src/products/views/ProductUpdate/ProductUpdate.tsx index d86c58c25..8f5d3fffb 100644 --- a/src/products/views/ProductUpdate/ProductUpdate.tsx +++ b/src/products/views/ProductUpdate/ProductUpdate.tsx @@ -10,7 +10,10 @@ import { WindowTitle } from "@saleor/components/WindowTitle"; import useBulkActions from "@saleor/hooks/useBulkActions"; import useNavigator from "@saleor/hooks/useNavigator"; import useNotifier from "@saleor/hooks/useNotifier"; +import useShop from "@saleor/hooks/useShop"; import { commonMessages } from "@saleor/intl"; +import ProductVariantCreateDialog from "@saleor/products/components/ProductVariantCreateDialog/ProductVariantCreateDialog"; +import { ProductVariantBulkCreate } from "@saleor/products/types/ProductVariantBulkCreate"; import { DEFAULT_INITIAL_SEARCH_DATA } from "../../../config"; import SearchCategories from "../../../containers/SearchCategories"; import SearchCollections from "../../../containers/SearchCollections"; @@ -54,6 +57,7 @@ export const ProductUpdate: React.StatelessComponent = ({ params.ids ); const intl = useIntl(); + const shop = useShop(); const openModal = (action: ProductUrlDialog) => navigate( @@ -115,6 +119,15 @@ export const ProductUpdate: React.StatelessComponent = ({ const handleVariantAdd = () => navigate(productVariantAddUrl(id)); + const handleBulkProductVariantCreate = ( + data: ProductVariantBulkCreate + ) => { + if (data.productVariantBulkCreate.errors.length === 0) { + navigate(productUrl(id), true); + refetch(); + } + }; + const handleBulkProductVariantDelete = ( data: ProductVariantBulkDelete ) => { @@ -125,10 +138,19 @@ export const ProductUpdate: React.StatelessComponent = ({ } }; + const handleVariantCreatorOpen = () => + navigate( + productUrl(id, { + ...params, + action: "create-variants" + }) + ); + const product = data ? data.product : undefined; return ( = ({ onUpdate={handleUpdate} > {({ + bulkProductVariantCreate, bulkProductVariantDelete, createProductImage, deleteProduct, @@ -245,6 +268,7 @@ export const ProductUpdate: React.StatelessComponent = ({ onImageReorder={handleImageReorder} onSubmit={handleSubmit} onVariantAdd={handleVariantAdd} + onVariantsAdd={handleVariantCreatorOpen} onVariantShow={variantId => () => navigate( productVariantEditUrl(product.id, variantId) @@ -328,6 +352,37 @@ export const ProductUpdate: React.StatelessComponent = ({ /> + + data.product.basePrice.amount.toFixed(2) + )} + errors={maybe( + () => + bulkProductVariantCreate.opts.data + .productVariantBulkCreate.bulkProductErrors, + [] + )} + open={params.action === "create-variants"} + attributes={maybe( + () => data.product.productType.variantAttributes, + [] + )} + currencySymbol={maybe(() => shop.defaultCurrency)} + onClose={() => + navigate( + productUrl(id, { + ...params, + action: undefined + }) + ) + } + onSubmit={inputs => + bulkProductVariantCreate.mutate({ + id, + inputs + }) + } + /> ); }} diff --git a/src/products/views/ProductVariantCreate.tsx b/src/products/views/ProductVariantCreate.tsx index b78ca66a9..a8ac30785 100644 --- a/src/products/views/ProductVariantCreate.tsx +++ b/src/products/views/ProductVariantCreate.tsx @@ -58,18 +58,20 @@ export const ProductVariant: React.StatelessComponent = ({ ) => variantCreate({ variables: { - attributes: formData.attributes - .filter(attribute => attribute.value !== "") - .map(attribute => ({ - id: attribute.id, - values: [attribute.value] - })), - costPrice: decimal(formData.costPrice), - priceOverride: decimal(formData.priceOverride), - product: productId, - quantity: formData.quantity || null, - sku: formData.sku, - trackInventory: true + input: { + attributes: formData.attributes + .filter(attribute => attribute.value !== "") + .map(attribute => ({ + id: attribute.id, + values: [attribute.value] + })), + costPrice: decimal(formData.costPrice), + priceOverride: decimal(formData.priceOverride), + product: productId, + quantity: formData.quantity || null, + sku: formData.sku, + trackInventory: true + } } }); const handleVariantClick = (id: string) => diff --git a/src/storybook/__snapshots__/Stories.test.ts.snap b/src/storybook/__snapshots__/Stories.test.ts.snap index a53de1617..f1fffe1cf 100644 --- a/src/storybook/__snapshots__/Stories.test.ts.snap +++ b/src/storybook/__snapshots__/Stories.test.ts.snap @@ -82051,6 +82051,2260 @@ exports[`Storyshots Views / Product types / Unassign multiple attributes default /> `; +exports[`Storyshots Views / Products / Create multiple variants / summary default 1`] = ` +
+
+
+
+
+
+ + Select Values + +
+
+ + Prices and SKU + +
+
+ + Summary + +
+
+
+
+ You will create variants below +
+
+
+
+
+ Variant +
+
+ Price +
+
+ Inventory +
+
+ SKU +
+
+
+
+ + 100g + + + Arabica + + + Round + +
+
+
+
+ + + USD +
+
+
+
+
+
+ + +
+
+
+
+
+
+ + +
+
+
+
+ +
+
+
+
+ + 100g + + + Arabica + + + Polo + +
+
+
+
+ + + USD +
+
+
+
+
+
+ + +
+
+
+
+
+
+ + +
+
+
+
+ +
+
+
+
+ + 500g + + + Arabica + + + Round + +
+
+
+
+ + + USD +
+
+
+
+
+
+ + +
+
+
+
+
+
+ + +
+
+
+
+ +
+
+
+
+ + 500g + + + Arabica + + + Polo + +
+
+
+
+ + + USD +
+
+
+
+
+
+ + +
+
+
+
+
+
+ + +
+
+
+
+ +
+
+
+
+
+
+
+
+`; + +exports[`Storyshots Views / Products / Create multiple variants / summary errors 1`] = ` +
+
+
+
+
+
+ + Select Values + +
+
+ + Prices and SKU + +
+
+ + Summary + +
+
+
+
+ You will create variants below +
+
+
+
+
+ Variant +
+
+ Price +
+
+ Inventory +
+
+ SKU +
+
+
+
+ + 100g + + + Arabica + + + Round + +
+
+
+
+ + + USD +
+
+
+
+
+
+ + +
+
+
+
+
+
+ + +
+
+
+
+ +
+
+
+
+ + 100g + + + Arabica + + + Polo + +
+
+
+
+ + + USD +
+
+
+
+
+
+ + +
+
+
+
+
+
+ + +
+
+
+
+ +
+
+
+
+ + 500g + + + Arabica + + + Round + +
+
+
+
+ + + USD +
+
+
+
+
+
+ + +
+
+
+
+
+
+ + +
+
+
+
+ +
+
+
+
+ + 500g + + + Arabica + + + Polo + +
+
+
+
+ + + USD +
+
+
+
+
+
+ + +
+
+
+
+
+
+ + +
+

+ Duplicated SKU. +

+
+
+
+ +
+
+
+
+
+
+
+
+`; + +exports[`Storyshots Views / Products / Create multiple variants choose values 1`] = ` +
+
+
+
+
+
+ + Select Values + +
+
+ + Prices and SKU + +
+
+ + Summary + +
+
+
+
+ Box Size +
+
+
+ + + + +
+
+ Coffee Genre +
+
+
+ + +
+
+ Collar +
+
+
+ + + +
+
+
+
+
+
+`; + +exports[`Storyshots Views / Products / Create multiple variants interactive 1`] = ` +
+`; + +exports[`Storyshots Views / Products / Create multiple variants prices and SKU 1`] = ` +
+
+
+
+
+
+ + Select Values + +
+
+ + Prices and SKU + +
+
+ + Summary + +
+
+
+
+ Price +
+
+
+ +
+
+ +
+ + + USD +
+
+
+ +
+
+
+
+

+ Choose attribute +

+
+
+
+ +
+ +
+
+ Coffee Genre +
+ + +
+
+
+
+
+
+
+
+
+

+ Arabica +

+
+
+
+ +
+ + + USD +
+
+
+
+
+
+ Stock +
+
+
+ +
+
+ +
+ + +
+
+
+ +
+
+
+
+

+ Choose attribute +

+
+
+
+ +
+ +
+
+ Coffee Genre +
+ + +
+
+
+
+
+
+
+
+
+

+ Arabica +

+
+
+
+ +
+ + +
+
+
+
+
+
+
+
+
+`; + exports[`Storyshots Views / Products / Create product When loading 1`] = `
undefined, onVariantAdd: () => undefined, onVariantShow: () => undefined, + onVariantsAdd: () => undefined, placeholderImage, product, saveButtonBarState: "default", diff --git a/src/theme.ts b/src/theme.ts index c8d42b7a8..ee52be8b7 100644 --- a/src/theme.ts +++ b/src/theme.ts @@ -60,7 +60,7 @@ export default (colors: IThemeColors): Theme => }, flat: { "& span": { - color: colors.primary + color: colors.font.gray } }, flatPrimary: { @@ -281,8 +281,7 @@ export default (colors: IThemeColors): Theme => "& fieldset": { "&&:not($error)": { borderColor: colors.input.border - }, - background: colors.background.paper + } }, "& legend": { display: "none" diff --git a/src/types/globalTypes.ts b/src/types/globalTypes.ts index 1e8cfaf3a..0c23293c3 100644 --- a/src/types/globalTypes.ts +++ b/src/types/globalTypes.ts @@ -193,6 +193,20 @@ export enum PermissionEnum { MANAGE_WEBHOOKS = "MANAGE_WEBHOOKS", } +export enum ProductErrorCode { + ALREADY_EXISTS = "ALREADY_EXISTS", + ATTRIBUTE_ALREADY_ASSIGNED = "ATTRIBUTE_ALREADY_ASSIGNED", + ATTRIBUTE_CANNOT_BE_ASSIGNED = "ATTRIBUTE_CANNOT_BE_ASSIGNED", + ATTRIBUTE_VARIANTS_DISABLED = "ATTRIBUTE_VARIANTS_DISABLED", + GRAPHQL_ERROR = "GRAPHQL_ERROR", + INVALID = "INVALID", + NOT_FOUND = "NOT_FOUND", + NOT_PRODUCTS_IMAGE = "NOT_PRODUCTS_IMAGE", + REQUIRED = "REQUIRED", + UNIQUE = "UNIQUE", + VARIANT_NO_DIGITAL_CONTENT = "VARIANT_NO_DIGITAL_CONTENT", +} + export enum ProductOrderField { DATE = "DATE", MINIMAL_PRICE = "MINIMAL_PRICE", @@ -614,6 +628,27 @@ export interface ProductTypeInput { taxCode?: string | null; } +export interface ProductVariantBulkCreateInput { + attributes: (AttributeValueInput | null)[]; + costPrice?: any | null; + priceOverride?: any | null; + sku: string; + quantity?: number | null; + trackInventory?: boolean | null; + weight?: any | null; +} + +export interface ProductVariantCreateInput { + attributes: (AttributeValueInput | null)[]; + costPrice?: any | null; + priceOverride?: any | null; + sku?: string | null; + quantity?: number | null; + trackInventory?: boolean | null; + weight?: any | null; + product: string; +} + export interface ProductVariantInput { attributes?: (AttributeValueInput | null)[] | null; costPrice?: any | null;