Merge pull request #177 from mirumee/add/variant-creator

Add variant creator
This commit is contained in:
Marcin Gębala 2019-10-09 13:37:02 +02:00 committed by GitHub
commit e5b19d2c23
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
35 changed files with 6064 additions and 73 deletions

View file

@ -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

View file

@ -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:

View file

@ -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",

View file

@ -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

View file

@ -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<ProductUpdatePageProps> = ({
onSeoClick,
onSubmit,
onVariantAdd,
onVariantsAdd,
onVariantShow,
isChecked,
selected,
@ -236,6 +238,7 @@ export const ProductUpdatePage: React.FC<ProductUpdatePageProps> = ({
fallbackPrice={product ? product.basePrice : undefined}
onRowClick={onVariantShow}
onVariantAdd={onVariantAdd}
onVariantsAdd={onVariantsAdd}
toolbar={toolbar}
isChecked={isChecked}
selected={selected}

View file

@ -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 => (
<Card
style={{
margin: "auto",
overflow: "visible",
width: 800
}}
>
<CardContent>{storyFn()}</CardContent>
</Card>
))
.addDecorator(Decorator)
.add("choose values", () => <ProductVariantCreateContent {...props} />)
.add("prices and SKU", () => (
<ProductVariantCreateContent {...props} step="prices" />
));
storiesOf("Views / Products / Create multiple variants / summary", module)
.addDecorator(storyFn => (
<Card
style={{
margin: "auto",
overflow: "visible",
width: 800
}}
>
<CardContent>{storyFn()}</CardContent>
</Card>
))
.addDecorator(Decorator)
.add("default", () => (
<ProductVariantCreateContent {...props} step="summary" />
))
.add("errors", () => (
<ProductVariantCreateContent {...props} step="summary" errors={errors} />
));
storiesOf("Views / Products / Create multiple variants", module)
.addDecorator(Decorator)
.add("interactive", () => (
<ProductVariantCreateDialog
{...props}
defaultPrice="10.99"
open={true}
onClose={() => undefined}
onSubmit={() => undefined}
/>
));

View file

@ -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<ProductVariantCreateReducerAction>;
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 (
<div>
<ProductVariantCreateTabs step={step} onStepClick={onStepClick} />
<div className={classes.root}>
{step === "values" && (
<ProductVariantCreateValues
attributes={selectedAttributes}
data={data}
onValueClick={(attributeId, valueId) =>
dispatchFormDataAction({
attributeId,
type: "selectValue",
valueId
})
}
/>
)}
{step === "prices" && (
<ProductVariantCreatePrices
attributes={selectedAttributes}
currencySymbol={currencySymbol}
data={data}
onApplyPriceOrStockChange={(all, type) =>
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" && (
<ProductVariantCreateSummary
attributes={selectedAttributes}
currencySymbol={currencySymbol}
data={data}
errors={errors}
onVariantDataChange={(variantIndex, field, value) =>
dispatchFormDataAction({
field,
type: "changeVariantData",
value,
variantIndex
})
}
onVariantDelete={variantIndex =>
dispatchFormDataAction({
type: "deleteVariant",
variantIndex
})
}
/>
)}
</div>
</div>
);
};
ProductVariantCreateContent.displayName = "ProductVariantCreateContent";
export default ProductVariantCreateContent;

View file

@ -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<ProductVariantCreateStep>("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 (
<Dialog open={open} maxWidth="md">
<DialogTitle>
<FormattedMessage
defaultMessage="Assign Attribute"
description="dialog header"
/>
</DialogTitle>
<DialogContent className={classes.content}>
<ProductVariantCreateContent
{...contentProps}
attributes={attributes}
data={data}
dispatchFormDataAction={dispatchFormDataAction}
errors={errors}
step={step}
onStepClick={step => setStep(step)}
/>
</DialogContent>
<DialogActions>
<Button className={classes.button} onClick={onClose}>
<FormattedMessage defaultMessage="Cancel" description="button" />
</Button>
<div className={classes.spacer} />
{step !== "values" && (
<Button
className={classes.button}
color="primary"
onClick={handlePrevStep}
>
<FormattedMessage
defaultMessage="Previous"
description="previous step, button"
/>
</Button>
)}
{step !== "summary" ? (
<Button
className={classes.button}
color="primary"
disabled={!canHitNext(step, data)}
variant="contained"
onClick={handleNextStep}
>
<FormattedMessage defaultMessage="Next" description="button" />
</Button>
) : (
<Button
className={classes.button}
color="primary"
disabled={!canHitNext(step, data)}
variant="contained"
onClick={() => onSubmit(data.variants)}
>
<FormattedMessage
defaultMessage="Create"
description="create multiple variants, button"
/>
</Button>
)}
</DialogActions>
</Dialog>
);
};
ProductVariantCreateDialog.displayName = "ProductVariantCreateDialog";
export default ProductVariantCreateDialog;

View file

@ -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 (
<>
<Typography color="textSecondary" variant="h5">
<FormattedMessage
defaultMessage="Price"
description="variant price, header"
/>
</Typography>
<Hr className={classes.hr} />
<RadioGroup value={data.price.all ? "applyToAll" : "applyToAttribute"}>
<FormControlLabel
value="applyToAll"
control={<Radio color="primary" />}
label={intl.formatMessage({
defaultMessage: "Apply single price to all SKUs"
})}
onChange={() => onApplyPriceOrStockChange(true, "price")}
/>
<FormSpacer />
<TextField
className={classes.shortInput}
inputProps={{
min: 0,
type: "number"
}}
InputProps={{
endAdornment: currencySymbol
}}
label={intl.formatMessage({
defaultMessage: "Price",
id: "productVariantCreatePricesPriceInputLabel"
})}
value={data.price.value}
onChange={event => onApplyToAllChange(event.target.value, "price")}
/>
<FormSpacer />
<FormControlLabel
value="applyToAttribute"
control={<Radio color="primary" />}
label={intl.formatMessage({
defaultMessage: "Apply unique prices by attribute to each SKU"
})}
onChange={() => onApplyPriceOrStockChange(false, "price")}
/>
</RadioGroup>
{!data.price.all && (
<>
<FormSpacer />
<Grid variant="uniform">
<div className={classes.label}>
<Typography>
<FormattedMessage
defaultMessage="Choose attribute"
description="variant attribute"
/>
</Typography>
</div>
<div>
<SingleSelectField
choices={attributeChoices}
label={intl.formatMessage({
defaultMessage: "Attribute",
description: "variant attribute"
})}
value={data.price.attribute}
onChange={event =>
onAttributeSelect(event.target.value, "price")
}
/>
</div>
</Grid>
<Hr className={classes.hrAttribute} />
{priceAttributeValues &&
priceAttributeValues.map(attributeValue => (
<>
<FormSpacer />
<Grid variant="uniform">
<div className={classes.label}>
<Typography>{attributeValue.name}</Typography>
</div>
<div>
<TextField
label={intl.formatMessage({
defaultMessage: "Price",
description: "variant price",
id: "productVariantCreatePricesSetPricePlaceholder"
})}
inputProps={{
min: 0,
type: "number"
}}
InputProps={{
endAdornment: currencySymbol
}}
fullWidth
value={
data.price.values.find(
value => value.slug === attributeValue.slug
).value
}
onChange={event =>
onAttributeValueChange(
attributeValue.slug,
event.target.value,
"price"
)
}
/>
</div>
</Grid>
</>
))}
</>
)}
<FormSpacer />
<Typography color="textSecondary" variant="h5">
<FormattedMessage
defaultMessage="Stock"
description="variant stock, header"
/>
</Typography>
<Hr className={classes.hr} />
<RadioGroup value={data.stock.all ? "applyToAll" : "applyToAttribute"}>
<FormControlLabel
value="applyToAll"
control={<Radio color="primary" />}
label={intl.formatMessage({
defaultMessage: "Apply single stock to all SKUs"
})}
onChange={() => onApplyPriceOrStockChange(true, "stock")}
/>
<FormSpacer />
<TextField
className={classes.shortInput}
inputProps={{
min: 0,
type: "number"
}}
label={intl.formatMessage({
defaultMessage: "Stock",
id: "productVariantCreatePricesStockInputLabel"
})}
value={data.stock.value}
onChange={event => onApplyToAllChange(event.target.value, "stock")}
/>
<FormSpacer />
<FormControlLabel
value="applyToAttribute"
control={<Radio color="primary" />}
label={intl.formatMessage({
defaultMessage: "Apply unique stock by attribute to each SKU"
})}
onChange={() => onApplyPriceOrStockChange(false, "stock")}
/>
</RadioGroup>
{!data.stock.all && (
<>
<FormSpacer />
<Grid variant="uniform">
<div className={classes.label}>
<Typography>
<FormattedMessage
defaultMessage="Choose attribute"
description="variant attribute"
/>
</Typography>
</div>
<div>
<SingleSelectField
choices={attributeChoices}
label={intl.formatMessage({
defaultMessage: "Attribute",
description: "variant attribute"
})}
value={data.stock.attribute}
onChange={event =>
onAttributeSelect(event.target.value, "stock")
}
/>
</div>
</Grid>
<Hr className={classes.hrAttribute} />
{stockAttributeValues &&
stockAttributeValues.map(attributeValue => (
<>
<FormSpacer />
<Grid variant="uniform">
<div className={classes.label}>
<Typography>{attributeValue.name}</Typography>
</div>
<div>
<TextField
label={intl.formatMessage({
defaultMessage: "Stock",
description: "variant stock",
id: "productVariantCreatePricesSetStockPlaceholder"
})}
fullWidth
value={
data.stock.values.find(
value => value.slug === attributeValue.slug
).value
}
onChange={event =>
onAttributeValueChange(
attributeValue.slug,
event.target.value,
"stock"
)
}
/>
</div>
</Grid>
</>
))}
</>
)}
</>
);
};
ProductVariantCreatePrices.displayName = "ProductVariantCreatePrices";
export default ProductVariantCreatePrices;

View file

@ -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 (
<>
<Typography color="textSecondary" variant="h5">
<FormattedMessage
defaultMessage="You will create variants below"
description="header"
/>
</Typography>
<Hr className={classes.hr} />
<div>
<div className={classes.row}>
<div
className={classNames(
classes.col,
classes.colHeader,
classes.colName
)}
>
<FormattedMessage
defaultMessage="Variant"
description="variant name"
/>
</div>
<div
className={classNames(
classes.col,
classes.colHeader,
classes.colPrice
)}
>
<FormattedMessage
defaultMessage="Price"
description="variant price"
/>
</div>
<div
className={classNames(
classes.col,
classes.colHeader,
classes.colStock
)}
>
<FormattedMessage
defaultMessage="Inventory"
description="variant stock amount"
/>
</div>
<div
className={classNames(
classes.col,
classes.colHeader,
classes.colSku
)}
>
<FormattedMessage defaultMessage="SKU" />
</div>
</div>
{data.variants.map((variant, variantIndex) => {
const variantErrors = errors.filter(
error => error.index === variantIndex
);
return (
<div
className={classNames(classes.row, {
[classes.errorRow]: variantErrors.length > 0
})}
key={variant.attributes
.map(attribute => attribute.values[0])
.join(":")}
>
<div className={classNames(classes.col, classes.colName)}>
{getVariantName(variant, attributes).map(
(value, valueIndex) => (
<span
className={classes.attributeValue}
style={{
color: colors[valueIndex % colors.length]
}}
>
{value}
</span>
)
)}
</div>
<div className={classNames(classes.col, classes.colPrice)}>
<TextField
InputProps={{
endAdornment: currencySymbol
}}
className={classes.input}
error={
!!variantErrors.find(
error => 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
)
}
/>
</div>
<div className={classNames(classes.col, classes.colStock)}>
<TextField
className={classes.input}
error={
!!variantErrors.find(error => 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
)
}
/>
</div>
<div className={classNames(classes.col, classes.colSku)}>
<TextField
className={classes.input}
error={!!variantErrors.find(error => error.field === "sku")}
helperText={maybe(
() =>
variantErrors.find(error => error.field === "sku").message
)}
fullWidth
value={variant.sku}
onChange={event =>
onVariantDataChange(variantIndex, "sku", event.target.value)
}
/>
</div>
<div className={classes.col}>
<IconButton
className={classes.delete}
color="primary"
onClick={() => onVariantDelete(variantIndex)}
>
<DeleteIcon />
</IconButton>
</div>
</div>
);
})}
</div>
</>
);
};
ProductVariantCreateSummary.displayName = "ProductVariantCreateSummary";
export default ProductVariantCreateSummary;

View file

@ -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 (
<div className={classes.root}>
{steps.map((step, stepIndex) => {
const visitedStep =
steps.findIndex(step => step.value === currentStep) >= stepIndex;
return (
<div
className={classNames(classes.tab, {
[classes.tabActive]: step.value === currentStep,
[classes.tabVisited]: visitedStep
})}
onClick={visitedStep ? () => onStepClick(step.value) : undefined}
key={step.value}
>
<Typography className={classes.label} variant="caption">
{step.label}
</Typography>
</div>
);
})}
</div>
);
};
ProductVariantCreateTabs.displayName = "ProductVariantCreateTabs";
export default ProductVariantCreateTabs;

View file

@ -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 => (
<React.Fragment key={attribute.id}>
<Typography color="textSecondary" variant="h5">
{maybe<React.ReactNode>(() => attribute.name, <Skeleton />)}
</Typography>
<Hr className={classes.hr} />
<div className={classes.valueContainer}>
{attribute.values.map(value => (
<Debounce
debounceFn={() => onValueClick(attribute.id, value.slug)}
time={100}
key={value.slug}
>
{change => (
<ControlledCheckbox
checked={isSelected(
value.slug,
data.attributes.find(
dataAttribute => attribute.id === dataAttribute.id
).values,
(a, b) => a === b
)}
name={`value:${value.slug}`}
label={value.name}
onChange={change}
/>
)}
</Debounce>
))}
</div>
</React.Fragment>
))}
</>
);
};
ProductVariantCreateValues.displayName = "ProductVariantCreateValues";
export default ProductVariantCreateValues;

View file

@ -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));
});
});
});
});

View file

@ -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;
}

View file

@ -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
})
};

View file

@ -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: []
});

View file

@ -0,0 +1,185 @@
import { attributes, fourthStep, secondStep, thirdStep } from "./fixtures";
import reducer, { VariantField } from "./reducer";
function execActions<TState, TAction>(
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);
});
});

View file

@ -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;

View file

@ -0,0 +1 @@
export type ProductVariantCreateStep = "values" | "prices" | "summary";

View file

@ -69,6 +69,7 @@ interface ProductVariantsProps extends ListActions, WithStyles<typeof styles> {
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 ? (
<Button
onClick={onVariantAdd}
variant="text"
@ -110,7 +112,19 @@ export const ProductVariants = withStyles(styles, { name: "ProductVariants" })(
description="button"
/>
</Button>
</>
) : (
<Button
onClick={onVariantsAdd}
variant="text"
color="primary"
data-tc="button-add-variants"
>
<FormattedMessage
defaultMessage="Create variants"
description="button"
/>
</Button>
)
}
/>
{!variants.length && (

View file

@ -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<
<TypedProductVariantBulkDeleteMutation
onCompleted={onBulkProductVariantDelete}
>
{(...bulkProductVariantDelete) =>
children({
bulkProductVariantDelete: getMutationProviderData(
...bulkProductVariantDelete
),
createProductImage: getMutationProviderData(
...createProductImage
),
deleteProduct: getMutationProviderData(
...deleteProduct
),
deleteProductImage: getMutationProviderData(
...deleteProductImage
),
reorderProductImages: getMutationProviderData(
...reorderProductImages
),
updateProduct: getMutationProviderData(
...updateProduct
),
updateSimpleProduct: getMutationProviderData(
...updateSimpleProduct
)
})
}
{(...bulkProductVariantDelete) => (
<TypedProductVariantBulkCreateMutation
onCompleted={onBulkProductVariantCreate}
>
{(...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
)
})
}
</TypedProductVariantBulkCreateMutation>
)}
</TypedProductVariantBulkDeleteMutation>
)}
</TypedSimpleProductUpdateMutation>

View file

@ -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) {

View file

@ -260,6 +260,17 @@ const productDetailsQuery = gql`
query ProductDetails($id: ID!) {
product(id: $id) {
...Product
productType {
variantAttributes {
id
name
values {
id
name
slug
}
}
}
}
}
`;

View file

@ -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 {

View file

@ -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)[];
}

View file

@ -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;
}

View file

@ -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);

View file

@ -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<ProductUpdateProps> = ({
params.ids
);
const intl = useIntl();
const shop = useShop();
const openModal = (action: ProductUrlDialog) =>
navigate(
@ -115,6 +119,15 @@ export const ProductUpdate: React.StatelessComponent<ProductUpdateProps> = ({
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<ProductUpdateProps> = ({
}
};
const handleVariantCreatorOpen = () =>
navigate(
productUrl(id, {
...params,
action: "create-variants"
})
);
const product = data ? data.product : undefined;
return (
<ProductUpdateOperations
product={product}
onBulkProductVariantCreate={handleBulkProductVariantCreate}
onBulkProductVariantDelete={handleBulkProductVariantDelete}
onDelete={handleDelete}
onImageCreate={handleImageCreate}
@ -136,6 +158,7 @@ export const ProductUpdate: React.StatelessComponent<ProductUpdateProps> = ({
onUpdate={handleUpdate}
>
{({
bulkProductVariantCreate,
bulkProductVariantDelete,
createProductImage,
deleteProduct,
@ -245,6 +268,7 @@ export const ProductUpdate: React.StatelessComponent<ProductUpdateProps> = ({
onImageReorder={handleImageReorder}
onSubmit={handleSubmit}
onVariantAdd={handleVariantAdd}
onVariantsAdd={handleVariantCreatorOpen}
onVariantShow={variantId => () =>
navigate(
productVariantEditUrl(product.id, variantId)
@ -328,6 +352,37 @@ export const ProductUpdate: React.StatelessComponent<ProductUpdateProps> = ({
/>
</DialogContentText>
</ActionDialog>
<ProductVariantCreateDialog
defaultPrice={maybe(() =>
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
})
}
/>
</>
);
}}

View file

@ -58,18 +58,20 @@ export const ProductVariant: React.StatelessComponent<ProductUpdateProps> = ({
) =>
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) =>

File diff suppressed because it is too large Load diff

View file

@ -30,6 +30,7 @@ const props: ProductUpdatePageProps = {
onSubmit: () => undefined,
onVariantAdd: () => undefined,
onVariantShow: () => undefined,
onVariantsAdd: () => undefined,
placeholderImage,
product,
saveButtonBarState: "default",

View file

@ -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"

View file

@ -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;