diff --git a/cypress/e2e/configuration/attributes/attributeVariantSelection.js b/cypress/e2e/configuration/attributes/attributeVariantSelection.js index 34f96320d..d5018214c 100644 --- a/cypress/e2e/configuration/attributes/attributeVariantSelection.js +++ b/cypress/e2e/configuration/attributes/attributeVariantSelection.js @@ -79,6 +79,8 @@ describe("As an admin I want to use attributes in variant selection", () => { sku: name, attributeName: attributeValues[0], attributeType: inputType, + costPrice: 10, + price: 10, }); cy.wait("@VariantCreate"); diff --git a/cypress/support/pages/catalog/products/VariantsPage.js b/cypress/support/pages/catalog/products/VariantsPage.js index 3d14de19f..fd0cb7a39 100644 --- a/cypress/support/pages/catalog/products/VariantsPage.js +++ b/cypress/support/pages/catalog/products/VariantsPage.js @@ -17,11 +17,17 @@ export function createVariant({ attributeName, price, costPrice = price, - channelName, quantity = 10, }) { cy.get(PRODUCT_DETAILS.addVariantButton).click(); - fillUpVariantDetails({ attributeName, sku, warehouseName, quantity }); + fillUpVariantDetails({ + attributeName, + sku, + warehouseName, + quantity, + costPrice, + price, + }); cy.get(VARIANTS_SELECTORS.saveButton) .click() .get(VARIANTS_SELECTORS.skuInput) @@ -30,12 +36,6 @@ export function createVariant({ .click() .get(PRODUCT_DETAILS.productNameInput) .should("be.enabled"); - selectChannelForVariantAndFillUpPrices({ - channelName, - variantName: attributeName, - price, - costPrice, - }); } export function fillUpGeneralVariantInputs({ @@ -64,6 +64,8 @@ export function fillUpVariantDetails({ sku, warehouseName, quantity, + costPrice, + price, }) { selectAttributeWithType({ attributeType, attributeName }); if (sku) { @@ -78,6 +80,16 @@ export function fillUpVariantDetails({ .get(VARIANTS_SELECTORS.stockInput) .type(quantity); } + + cy.get(PRICE_LIST.priceInput) + .each(input => { + cy.wrap(input).type(price); + }) + .get(PRICE_LIST.costPriceInput) + .each(input => { + cy.wrap(input).type(costPrice); + }); + cy.get(VARIANTS_SELECTORS.saveButton).click(); } diff --git a/locale/defaultMessages.json b/locale/defaultMessages.json index 4f3dd3ecb..7c135754b 100644 --- a/locale/defaultMessages.json +++ b/locale/defaultMessages.json @@ -516,6 +516,10 @@ "context": "gift card not found message", "string": "Couldn't find gift card" }, + "2CBcub": { + "context": "CreateVariantTitle manage", + "string": "Manage" + }, "2E1xZ0": { "context": "page header", "string": "Create Sale" @@ -6250,6 +6254,10 @@ "context": "voucher code, button", "string": "Generate Code" }, + "mTEqYL": { + "context": "NoChannels content", + "string": "No channels to assign. Please first assign them for the product." + }, "mUb8Gt": { "context": "section header", "string": "Taxes" diff --git a/package-lock.json b/package-lock.json index fb60287fe..daca8f7f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27156,7 +27156,7 @@ "request-progress": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/request-progress/-/request-progress-3.0.0.tgz", - "integrity": "sha1-TKdUCBx/7GP1BeT6qCWqBs1mnb4=", + "integrity": "sha512-MnWzEHHaxHO2iWiQuHrUPBi/1WeBf5PkxQqNyNvLl9VAYSdXkP8tQ3pBSeCPD+yw0v0Aq1zosWLz0BdeXpWwZg==", "dev": true, "requires": { "throttleit": "^1.0.0" diff --git a/src/components/ChannelsAvailabilityDialog/ChannelsAvailabilityDialog.tsx b/src/components/ChannelsAvailabilityDialog/ChannelsAvailabilityDialog.tsx index 259fee27f..0b4016bb2 100644 --- a/src/components/ChannelsAvailabilityDialog/ChannelsAvailabilityDialog.tsx +++ b/src/components/ChannelsAvailabilityDialog/ChannelsAvailabilityDialog.tsx @@ -5,6 +5,7 @@ import React from "react"; import ChannelsAvailabilityDialogChannelsList from "../ChannelsAvailabilityDialogChannelsList"; import ChannelsAvailabilityDialogWrapper from "../ChannelsAvailabilityDialogWrapper"; +import { NoChannels } from "./NoChannels"; import { useChannelsSearch } from "./utils"; export interface ChannelsAvailabilityDialogProps { @@ -39,6 +40,7 @@ export const ChannelsAvailabilityDialog: React.FC 0; const handleToggleAll = () => toggleAll(channels, selected); @@ -53,20 +55,24 @@ export const ChannelsAvailabilityDialog: React.FC - - - + {hasChannels ? ( + + + + ) : ( + + )} ); }; diff --git a/src/components/ChannelsAvailabilityDialog/NoChannels.tsx b/src/components/ChannelsAvailabilityDialog/NoChannels.tsx new file mode 100644 index 000000000..4548bf71a --- /dev/null +++ b/src/components/ChannelsAvailabilityDialog/NoChannels.tsx @@ -0,0 +1,11 @@ +import { Typography } from "@material-ui/core"; +import React from "react"; +import { FormattedMessage } from "react-intl"; + +import { channelsAvailabilityDialogMessages as messages } from "./messages"; + +export const NoChannels = () => ( + + + +); diff --git a/src/components/ChannelsAvailabilityDialog/messages.ts b/src/components/ChannelsAvailabilityDialog/messages.ts new file mode 100644 index 000000000..a298b2ece --- /dev/null +++ b/src/components/ChannelsAvailabilityDialog/messages.ts @@ -0,0 +1,10 @@ +import { defineMessages } from "react-intl"; + +export const channelsAvailabilityDialogMessages = defineMessages({ + noChannels: { + id: "mTEqYL", + defaultMessage: + "No channels to assign. Please first assign them for the product.", + description: "NoChannels content", + }, +}); diff --git a/src/graphql/hooks.generated.ts b/src/graphql/hooks.generated.ts index 910260b87..e80f4a5fd 100644 --- a/src/graphql/hooks.generated.ts +++ b/src/graphql/hooks.generated.ts @@ -12484,6 +12484,8 @@ export const ProductVariantCreateDataDocument = gql` url } channelListings { + isPublished + publicationDate channel { id name diff --git a/src/graphql/types.generated.ts b/src/graphql/types.generated.ts index 2402dd15b..c5b3e5948 100644 --- a/src/graphql/types.generated.ts +++ b/src/graphql/types.generated.ts @@ -7926,7 +7926,7 @@ export type ProductVariantCreateDataQueryVariables = Exact<{ }>; -export type ProductVariantCreateDataQuery = { __typename: 'Query', product: { __typename: 'Product', id: string, name: string, media: Array<{ __typename: 'ProductMedia', id: string, sortOrder: number | null, url: string }> | null, channelListings: Array<{ __typename: 'ProductChannelListing', channel: { __typename: 'Channel', id: string, name: string, currencyCode: string } }> | null, productType: { __typename: 'ProductType', id: string, selectionVariantAttributes: Array<{ __typename: 'Attribute', id: string, name: string | null, slug: string | null, inputType: AttributeInputTypeEnum | null, entityType: AttributeEntityTypeEnum | null, valueRequired: boolean, unit: MeasurementUnitsEnum | null, choices: { __typename: 'AttributeValueCountableConnection', pageInfo: { __typename: 'PageInfo', endCursor: string | null, hasNextPage: boolean, hasPreviousPage: boolean, startCursor: string | null }, edges: Array<{ __typename: 'AttributeValueCountableEdge', cursor: string, node: { __typename: 'AttributeValue', plainText: string | null, richText: any | null, id: string, name: string | null, slug: string | null, reference: string | null, boolean: boolean | null, date: any | null, dateTime: any | null, value: string | null, file: { __typename: 'File', url: string, contentType: string | null } | null } }> } | null }> | null, nonSelectionVariantAttributes: Array<{ __typename: 'Attribute', id: string, name: string | null, slug: string | null, inputType: AttributeInputTypeEnum | null, entityType: AttributeEntityTypeEnum | null, valueRequired: boolean, unit: MeasurementUnitsEnum | null, choices: { __typename: 'AttributeValueCountableConnection', pageInfo: { __typename: 'PageInfo', endCursor: string | null, hasNextPage: boolean, hasPreviousPage: boolean, startCursor: string | null }, edges: Array<{ __typename: 'AttributeValueCountableEdge', cursor: string, node: { __typename: 'AttributeValue', plainText: string | null, richText: any | null, id: string, name: string | null, slug: string | null, reference: string | null, boolean: boolean | null, date: any | null, dateTime: any | null, value: string | null, file: { __typename: 'File', url: string, contentType: string | null } | null } }> } | null }> | null }, thumbnail: { __typename: 'Image', url: string } | null, defaultVariant: { __typename: 'ProductVariant', id: string } | null, variants: Array<{ __typename: 'ProductVariant', id: string, name: string, sku: string | null, media: Array<{ __typename: 'ProductMedia', id: string, url: string, type: ProductMediaType }> | null }> | null } | null }; +export type ProductVariantCreateDataQuery = { __typename: 'Query', product: { __typename: 'Product', id: string, name: string, media: Array<{ __typename: 'ProductMedia', id: string, sortOrder: number | null, url: string }> | null, channelListings: Array<{ __typename: 'ProductChannelListing', isPublished: boolean, publicationDate: any | null, channel: { __typename: 'Channel', id: string, name: string, currencyCode: string } }> | null, productType: { __typename: 'ProductType', id: string, selectionVariantAttributes: Array<{ __typename: 'Attribute', id: string, name: string | null, slug: string | null, inputType: AttributeInputTypeEnum | null, entityType: AttributeEntityTypeEnum | null, valueRequired: boolean, unit: MeasurementUnitsEnum | null, choices: { __typename: 'AttributeValueCountableConnection', pageInfo: { __typename: 'PageInfo', endCursor: string | null, hasNextPage: boolean, hasPreviousPage: boolean, startCursor: string | null }, edges: Array<{ __typename: 'AttributeValueCountableEdge', cursor: string, node: { __typename: 'AttributeValue', plainText: string | null, richText: any | null, id: string, name: string | null, slug: string | null, reference: string | null, boolean: boolean | null, date: any | null, dateTime: any | null, value: string | null, file: { __typename: 'File', url: string, contentType: string | null } | null } }> } | null }> | null, nonSelectionVariantAttributes: Array<{ __typename: 'Attribute', id: string, name: string | null, slug: string | null, inputType: AttributeInputTypeEnum | null, entityType: AttributeEntityTypeEnum | null, valueRequired: boolean, unit: MeasurementUnitsEnum | null, choices: { __typename: 'AttributeValueCountableConnection', pageInfo: { __typename: 'PageInfo', endCursor: string | null, hasNextPage: boolean, hasPreviousPage: boolean, startCursor: string | null }, edges: Array<{ __typename: 'AttributeValueCountableEdge', cursor: string, node: { __typename: 'AttributeValue', plainText: string | null, richText: any | null, id: string, name: string | null, slug: string | null, reference: string | null, boolean: boolean | null, date: any | null, dateTime: any | null, value: string | null, file: { __typename: 'File', url: string, contentType: string | null } | null } }> } | null }> | null }, thumbnail: { __typename: 'Image', url: string } | null, defaultVariant: { __typename: 'ProductVariant', id: string } | null, variants: Array<{ __typename: 'ProductVariant', id: string, name: string, sku: string | null, media: Array<{ __typename: 'ProductMedia', id: string, url: string, type: ProductMediaType }> | null }> | null } | null }; export type ProductMediaByIdQueryVariables = Exact<{ productId: Scalars['ID']; diff --git a/src/products/components/ProductVariantChannels/ChannelsAvailabilityCard/AvailabilityCard.tsx b/src/products/components/ProductVariantChannels/ChannelsAvailabilityCard/AvailabilityCard.tsx new file mode 100644 index 000000000..0bdc5afaa --- /dev/null +++ b/src/products/components/ProductVariantChannels/ChannelsAvailabilityCard/AvailabilityCard.tsx @@ -0,0 +1,46 @@ +import React from "react"; +import { useIntl } from "react-intl"; + +import { variantDetailsChannelsAvailabilityCardMessages as messages } from "./../messages"; +import { Channel, ProductChannelListing } from "./../types"; +import { ChannelsList } from "./ChannelsList"; +import { ChannelsListItem } from "./ChannelsListItem"; +import { NotAvailable } from "./NotAvailable"; +import CardContainer from "./VariantDetailsChannelsAvailabilityCardContainer"; + +interface AvailabilityCardProps { + items: Channel[]; + productChannelListings: ProductChannelListing; + availabilityCount: Record; +} + +export const AvailabilityCard: React.FC = ({ + availabilityCount, + items, + productChannelListings, + children, +}) => { + const intl = useIntl(); + const channelListSummary = intl.formatMessage( + messages.subtitle, + availabilityCount, + ); + + if (items.length === 0) { + return ( + + + + ); + } + + return ( + + + {items.map(channel => ( + + ))} + + + ); +}; diff --git a/src/products/components/ProductVariantChannels/ChannelsAvailabilityCard/CardSkeleton.tsx b/src/products/components/ProductVariantChannels/ChannelsAvailabilityCard/CardSkeleton.tsx new file mode 100644 index 000000000..374ec5c51 --- /dev/null +++ b/src/products/components/ProductVariantChannels/ChannelsAvailabilityCard/CardSkeleton.tsx @@ -0,0 +1,13 @@ +import { CardContent } from "@material-ui/core"; +import Skeleton from "@saleor/components/Skeleton"; +import React from "react"; + +import CardContainer from "./VariantDetailsChannelsAvailabilityCardContainer"; + +export const CardSkeleton: React.FC = () => ( + + + + + +); diff --git a/src/products/components/ProductVariantChannels/ChannelsAvailabilityCard/ChannelsList.tsx b/src/products/components/ProductVariantChannels/ChannelsAvailabilityCard/ChannelsList.tsx new file mode 100644 index 000000000..0e5935a79 --- /dev/null +++ b/src/products/components/ProductVariantChannels/ChannelsAvailabilityCard/ChannelsList.tsx @@ -0,0 +1,42 @@ +import { + Accordion, + AccordionSummary, + CardContent, + Typography, +} from "@material-ui/core"; +import IconChevronDown from "@saleor/icons/ChevronDown"; +import React from "react"; + +import { + useAccordionStyles, + useExpanderStyles, + useSummaryStyles, +} from "./styles"; + +interface ChannelListProps { + summary: string; +} + +export const ChannelsList: React.FC = ({ + summary, + children, +}) => { + const classes = useAccordionStyles(); + const expanderClasses = useExpanderStyles({}); + const summaryClasses = useSummaryStyles({}); + + return ( + + + } + classes={summaryClasses} + data-test-id="channels-variant-availability-summary" + > + {summary} + + + {children} + + ); +}; diff --git a/src/products/components/ProductVariantChannels/ChannelsAvailabilityCard/ChannelsListItem.tsx b/src/products/components/ProductVariantChannels/ChannelsAvailabilityCard/ChannelsListItem.tsx new file mode 100644 index 000000000..d5f1e84d3 --- /dev/null +++ b/src/products/components/ProductVariantChannels/ChannelsAvailabilityCard/ChannelsListItem.tsx @@ -0,0 +1,55 @@ +import { CardContent, Divider, Typography } from "@material-ui/core"; +import useDateLocalize from "@saleor/hooks/useDateLocalize"; +import React from "react"; +import { useIntl } from "react-intl"; + +import { variantDetailsChannelsAvailabilityCardMessages as messages } from "./../messages"; +import { Channel, ProductChannelListing } from "./../types"; + +type ChannelsListItemProps = Pick & { + listings: ProductChannelListing; +}; + +export const ChannelsListItem: React.FC = ({ + id, + name, + listings, +}) => { + const intl = useIntl(); + const localizeDate = useDateLocalize(); + + const getItemSubtitle = (channelId: string) => { + const channelListing = listings.find( + ({ channel }) => channel.id === channelId, + ); + + const { isPublished, publicationDate } = channelListing; + + if (!isPublished) { + return intl.formatMessage(messages.itemSubtitleHidden); + } + + return intl.formatMessage(messages.itemSubtitlePublished, { + publicationDate: localizeDate(publicationDate), + }); + }; + + return ( + + + + + {name} + + + {getItemSubtitle(id)} + + + + ); +}; diff --git a/src/products/components/ProductVariantChannels/ChannelsAvailabilityCard/CreateVariantTitle.tsx b/src/products/components/ProductVariantChannels/ChannelsAvailabilityCard/CreateVariantTitle.tsx new file mode 100644 index 000000000..429cb6e1a --- /dev/null +++ b/src/products/components/ProductVariantChannels/ChannelsAvailabilityCard/CreateVariantTitle.tsx @@ -0,0 +1,27 @@ +import { Button } from "@saleor/components/Button"; +import CardTitle from "@saleor/components/CardTitle"; +import React from "react"; +import { useIntl } from "react-intl"; + +import { variantDetailsChannelsAvailabilityCardMessages as messages } from "./../messages"; + +interface CreateVariantTitleProps { + onManageClick: () => void; +} + +export const CreateVariantTitle: React.FC = ({ + onManageClick, +}) => { + const intl = useIntl(); + + return ( + + {intl.formatMessage(messages.manageButtonText)} + + } + /> + ); +}; diff --git a/src/products/components/ProductVariantChannels/ChannelsAvailabilityCard/NotAvailable.tsx b/src/products/components/ProductVariantChannels/ChannelsAvailabilityCard/NotAvailable.tsx new file mode 100644 index 000000000..2750426b4 --- /dev/null +++ b/src/products/components/ProductVariantChannels/ChannelsAvailabilityCard/NotAvailable.tsx @@ -0,0 +1,17 @@ +import { CardContent, Typography } from "@material-ui/core"; +import React from "react"; +import { useIntl } from "react-intl"; + +import { variantDetailsChannelsAvailabilityCardMessages as messages } from "./../messages"; + +export const NotAvailable: React.FC = () => { + const intl = useIntl(); + + return ( + + + {intl.formatMessage(messages.noItemsAvailable)} + + + ); +}; diff --git a/src/products/components/ProductVariantPage/VariantDetailsChannelsAvailabilityCard/VariantDetailsChannelsAvailabilityCardContainer.tsx b/src/products/components/ProductVariantChannels/ChannelsAvailabilityCard/VariantDetailsChannelsAvailabilityCardContainer.tsx similarity index 79% rename from src/products/components/ProductVariantPage/VariantDetailsChannelsAvailabilityCard/VariantDetailsChannelsAvailabilityCardContainer.tsx rename to src/products/components/ProductVariantChannels/ChannelsAvailabilityCard/VariantDetailsChannelsAvailabilityCardContainer.tsx index ce75d5078..3775d763e 100644 --- a/src/products/components/ProductVariantPage/VariantDetailsChannelsAvailabilityCard/VariantDetailsChannelsAvailabilityCardContainer.tsx +++ b/src/products/components/ProductVariantChannels/ChannelsAvailabilityCard/VariantDetailsChannelsAvailabilityCardContainer.tsx @@ -4,18 +4,22 @@ import CardTitle from "@saleor/components/CardTitle"; import React from "react"; import { FormattedMessage } from "react-intl"; -import { variantDetailsChannelsAvailabilityCardMessages as messages } from "../messages"; +import { variantDetailsChannelsAvailabilityCardMessages as messages } from "./../messages"; interface VariantDetailsChannelsAvailabilityCardContainerProps { children: React.ReactNode; + cardTitle?: React.ReactNode; } const VariantDetailsChannelsAvailabilityCardContainer: React.FC = ({ children, + cardTitle, }) => ( <> - } /> + {cardTitle || ( + } /> + )} {children} diff --git a/src/products/components/ProductVariantChannels/ChannelsAvailabilityCard/availabilityCount.ts b/src/products/components/ProductVariantChannels/ChannelsAvailabilityCard/availabilityCount.ts new file mode 100644 index 000000000..1a9738b64 --- /dev/null +++ b/src/products/components/ProductVariantChannels/ChannelsAvailabilityCard/availabilityCount.ts @@ -0,0 +1,38 @@ +import { + ProductVariantCreateDataQuery, + ProductVariantFragment, +} from "@saleor/graphql"; + +export const getAvailabilityCountForVariant = ( + item: ProductVariantFragment, +) => { + const variantChannelListingsChannelsIds = item.channelListings.map( + ({ channel: { id } }) => id, + ); + + const allAvailableChannelsListings = item.product.channelListings.filter( + ({ channel }) => variantChannelListingsChannelsIds.includes(channel.id), + ); + + const publishedInChannelsListings = allAvailableChannelsListings.filter( + ({ isPublished }) => isPublished, + ); + + return { + publishedInChannelsCount: publishedInChannelsListings.length, + availableChannelsCount: allAvailableChannelsListings.length, + }; +}; + +export const getAvailabilityCountForProduct = ( + item: ProductVariantCreateDataQuery["product"], +) => { + const publishedInChannelsListings = item.channelListings.filter( + ({ isPublished }) => isPublished, + ); + + return { + publishedInChannelsCount: publishedInChannelsListings.length, + availableChannelsCount: item.channelListings.length, + }; +}; diff --git a/src/products/components/ProductVariantChannels/ChannelsAvailabilityCard/index.tsx b/src/products/components/ProductVariantChannels/ChannelsAvailabilityCard/index.tsx new file mode 100644 index 000000000..510dde43f --- /dev/null +++ b/src/products/components/ProductVariantChannels/ChannelsAvailabilityCard/index.tsx @@ -0,0 +1,69 @@ +import React from "react"; + +import { Channel, Product, Variant } from "./../types"; +import { AvailabilityCard } from "./AvailabilityCard"; +import { + getAvailabilityCountForProduct, + getAvailabilityCountForVariant, +} from "./availabilityCount"; +import { CardSkeleton } from "./CardSkeleton"; +import { CreateVariantTitle } from "./CreateVariantTitle"; + +interface VariantDetailsChannelsAvailabilityCardProps { + variant: Variant; + onManageClick?: () => void; +} + +interface ProductDetailsChannelsAvailabilityCardProps { + product: Product; + onManageClick?: () => void; +} + +interface WrapperProps { + item: Product | Variant; + children: ({ channels }: { channels: Channel[] }) => React.ReactElement; +} + +const Wrapper: React.FC = ({ item, children }) => { + if (!item) { + return ; + } + + const channels = item.channelListings.map(({ channel }) => channel); + + return children({ channels }); +}; + +export const VariantDetailsChannelsAvailabilityCard: React.FC = ({ + variant, + onManageClick, +}) => ( + + {({ channels }) => ( + + + + )} + +); + +export const ProductDetailsChannelsAvailabilityCard: React.FC = ({ + product, + onManageClick, +}) => ( + + {({ channels }) => ( + + + + )} + +); diff --git a/src/products/components/ProductVariantChannels/ChannelsAvailabilityCard/styles.ts b/src/products/components/ProductVariantChannels/ChannelsAvailabilityCard/styles.ts new file mode 100644 index 000000000..8a73900fe --- /dev/null +++ b/src/products/components/ProductVariantChannels/ChannelsAvailabilityCard/styles.ts @@ -0,0 +1,56 @@ +import { makeStyles } from "@saleor/macaw-ui"; + +export const useAccordionStyles = makeStyles( + () => ({ + summaryContent: { + paddingTop: 0, + }, + }), + { name: "VariantDetailsChannelsAvailabilityCard" }, +); + +export const useExpanderStyles = makeStyles( + () => ({ + expanded: {}, + root: { + boxShadow: "none", + margin: 0, + padding: 0, + + "&:before": { + content: "none", + }, + + "&$expanded": { + margin: 0, + border: "none", + }, + }, + }), + { name: "VariantDetailsChannelsAvailabilityCardExpander" }, +); + +export const useSummaryStyles = makeStyles( + () => ({ + expanded: {}, + root: { + width: "100%", + border: "none", + margin: 0, + padding: 0, + minHeight: 0, + + "&$expanded": { + minHeight: 0, + }, + }, + content: { + margin: 0, + + "&$expanded": { + margin: 0, + }, + }, + }), + { name: "VariantDetailsChannelsAvailabilityCardExpanderSummary" }, +); diff --git a/src/products/components/ProductVariantChannels/VariantChannelsDialog/index.tsx b/src/products/components/ProductVariantChannels/VariantChannelsDialog/index.tsx new file mode 100644 index 000000000..75aec4a3b --- /dev/null +++ b/src/products/components/ProductVariantChannels/VariantChannelsDialog/index.tsx @@ -0,0 +1,72 @@ +import { + ChannelPriceAndPreorderData, + IChannelPriceAndPreorderArgs, +} from "@saleor/channels/utils"; +import ChannelsAvailabilityDialog from "@saleor/components/ChannelsAvailabilityDialog"; +import { FormsetData } from "@saleor/hooks/useFormset"; +import useModalDialogOpen from "@saleor/hooks/useModalDialogOpen"; +import { toggle } from "@saleor/utils/lists"; +import React, { useState } from "react"; + +import { ProductChannelListing } from "../types"; + +interface VariantChannelsDialogProps { + channelListings: ProductChannelListing; + selectedChannelListings?: FormsetData< + ChannelPriceAndPreorderData, + IChannelPriceAndPreorderArgs + >; + open: boolean; + onClose: () => void; + onConfirm: (selectedIds: string[]) => void; +} + +export const VariantChannelsDialog: React.FC = ({ + channelListings, + selectedChannelListings, + open, + onClose, + onConfirm, +}) => { + const selectedOrDefaults = selectedChannelListings ?? channelListings; + const allChannelsIds = channelListings.map(c => c.channel.id); + const allChannels = channelListings.map(c => c.channel); + const preSelectedIds = selectedOrDefaults.map(c => c.id); + const [selected, setSelected] = useState(preSelectedIds); + + const isSelected = currentItem => selected.includes(currentItem.id); + + const handleToggleAll = () => { + setSelected(prev => (prev.length > 0 ? [] : allChannelsIds)); + }; + + const handleConfirm = () => { + onConfirm(selected); + onClose(); + }; + + const handleChange = ({ id }) => { + setSelected(state => toggle(id, state, (aId, bId) => aId === bId)); + }; + + useModalDialogOpen(open, { + onOpen: () => { + setSelected(preSelectedIds); + }, + }); + + return ( + + ); +}; diff --git a/src/products/components/ProductVariantChannels/formOpretations.ts b/src/products/components/ProductVariantChannels/formOpretations.ts new file mode 100644 index 000000000..e90b3e91d --- /dev/null +++ b/src/products/components/ProductVariantChannels/formOpretations.ts @@ -0,0 +1,60 @@ +import { ChannelPriceAndPreorderData } from "@saleor/channels/utils"; +import { ProductVariantCreateDataQuery } from "@saleor/graphql"; +import { UseFormsetOutput } from "@saleor/hooks/useFormset"; +import { getChannelsInput } from "@saleor/products/utils/handlers"; +import { + validateCostPrice, + validatePrice, +} from "@saleor/products/utils/validation"; + +import { VariantChannelListing } from "./types"; + +type FormChannels = UseFormsetOutput; + +export const validateChannels = (channels: FormChannels["data"]) => + channels.some( + channelData => + validatePrice(channelData.value.price) || + validateCostPrice(channelData.value.costPrice), + ); + +export const createChannelsWithPreorderInfo = ( + product: ProductVariantCreateDataQuery["product"], +) => + product + ? product.channelListings.map(listing => ({ + ...listing.channel, + currency: listing.channel.currencyCode, + price: "", + })) + : []; + +export const concatChannelsBySelection = ( + selectedIds: string[], + formChannels: FormChannels, + allChannels: ChannelPriceAndPreorderData[], +) => { + const includedAndSelected = formChannels.data.filter(ch => + selectedIds.includes(ch.id), + ); + const includedAndSelectedIds = includedAndSelected.map(ch => ch.id); + const restSelectedIds = selectedIds.filter( + id => !includedAndSelectedIds.includes(id), + ); + const newlySelected = allChannels.filter(ch => + restSelectedIds.includes(ch.id), + ); + + return getChannelsInput(newlySelected).concat(includedAndSelected); +}; + +export const extractChannelPricesFromVariantChannel = ( + variantChannel: VariantChannelListing[number], +) => { + const { costPrice, price } = variantChannel; + + return { + costPrice: costPrice ? costPrice.amount.toString() : null, + price: price ? price.amount.toString() : null, + }; +}; diff --git a/src/products/components/ProductVariantChannels/messages.ts b/src/products/components/ProductVariantChannels/messages.ts new file mode 100644 index 000000000..0b2aa0596 --- /dev/null +++ b/src/products/components/ProductVariantChannels/messages.ts @@ -0,0 +1,36 @@ +import { defineMessages } from "react-intl"; + +export const variantDetailsChannelsAvailabilityCardMessages = defineMessages({ + title: { + id: "sedoZ3", + defaultMessage: "Availability", + description: "VariantDetailsChannelsAvailabilityCard title", + }, + subtitle: { + id: "3+KwtP", + defaultMessage: + "Available in {publishedInChannelsCount} out of {availableChannelsCount}", + description: "VariantDetailsChannelsAvailabilityCard subtitle", + }, + itemSubtitlePublished: { + id: "rJ3lkW", + defaultMessage: "Published since {publicationDate}", + description: + "VariantDetailsChannelsAvailabilityCard item subtitle published", + }, + itemSubtitleHidden: { + id: "EsZH44", + defaultMessage: "Hidden", + description: "VariantDetailsChannelsAvailabilityCard item subtitle hidden", + }, + noItemsAvailable: { + id: "jqJqdE", + defaultMessage: "This variant is not available at any of the channels", + description: "VariantDetailsChannelsAvailabilityCard no items available", + }, + manageButtonText: { + id: "2CBcub", + defaultMessage: "Manage", + description: "CreateVariantTitle manage", + }, +}); diff --git a/src/products/components/ProductVariantChannels/types.ts b/src/products/components/ProductVariantChannels/types.ts new file mode 100644 index 000000000..3a907ec98 --- /dev/null +++ b/src/products/components/ProductVariantChannels/types.ts @@ -0,0 +1,11 @@ +import { + ProductVariantCreateDataQuery, + ProductVariantFragment, +} from "@saleor/graphql"; + +export type Product = ProductVariantCreateDataQuery["product"]; +export type Variant = ProductVariantFragment; +export type ProductChannelListing = Product["channelListings"]; +export type VariantChannelListing = Variant["channelListings"]; +export type ChannelListings = ProductChannelListing | VariantChannelListing; +export type Channel = ProductChannelListing[number]["channel"]; diff --git a/src/products/components/ProductVariantChannels/useManageChannels.ts b/src/products/components/ProductVariantChannels/useManageChannels.ts new file mode 100644 index 000000000..bf6e7c2a9 --- /dev/null +++ b/src/products/components/ProductVariantChannels/useManageChannels.ts @@ -0,0 +1,14 @@ +import { useState } from "react"; + +export const useManageChannels = () => { + const [isOpen, setIsOpen] = useState(false); + + const toggle = () => { + setIsOpen(p => !p); + }; + + return { + isOpen, + toggle, + }; +}; diff --git a/src/products/components/ProductVariantCreatePage/ProductVariantCreatePage.tsx b/src/products/components/ProductVariantCreatePage/ProductVariantCreatePage.tsx index b9fefb3ba..98536e557 100644 --- a/src/products/components/ProductVariantCreatePage/ProductVariantCreatePage.tsx +++ b/src/products/components/ProductVariantCreatePage/ProductVariantCreatePage.tsx @@ -24,6 +24,7 @@ import { } from "@saleor/graphql"; import useNavigator from "@saleor/hooks/useNavigator"; import { ConfirmButtonTransitionState } from "@saleor/macaw-ui"; +import { ProductDetailsChannelsAvailabilityCard } from "@saleor/products/components/ProductVariantChannels/ChannelsAvailabilityCard"; import { productUrl } from "@saleor/products/urls"; import { FetchMoreProps, RelayToFlat, ReorderAction } from "@saleor/types"; import React from "react"; @@ -31,6 +32,8 @@ import { defineMessages, useIntl } from "react-intl"; import ProductShipping from "../ProductShipping/ProductShipping"; import ProductStocks from "../ProductStocks"; +import { useManageChannels } from "../ProductVariantChannels/useManageChannels"; +import { VariantChannelsDialog } from "../ProductVariantChannels/VariantChannelsDialog"; import ProductVariantCheckoutSettings from "../ProductVariantCheckoutSettings/ProductVariantCheckoutSettings"; import ProductVariantNavigation from "../ProductVariantNavigation"; import ProductVariantPrice from "../ProductVariantPrice"; @@ -128,7 +131,10 @@ const ProductVariantCreatePage: React.FC = ({ }) => { const intl = useIntl(); const navigate = useNavigator(); - + const { + isOpen: isManageChannelsModalOpen, + toggle: toggleManageChannels, + } = useManageChannels(); const canOpenAssignReferencesAttributeDialog = !!assignReferencesAttributeId; const handleAssignReferenceAttribute = ( @@ -185,6 +191,10 @@ const ProductVariantCreatePage: React.FC = ({ />
+ = ({ /> ({ + ...channel.data, + ...channel.value, + }), + )} + errors={[]} + loading={!product} + onChange={handlers.changeChannels} /> = ({ } /> )} + {product && ( + + )} )} diff --git a/src/products/components/ProductVariantCreatePage/form.tsx b/src/products/components/ProductVariantCreatePage/form.tsx index d8c02be3b..037e4db25 100644 --- a/src/products/components/ProductVariantCreatePage/form.tsx +++ b/src/products/components/ProductVariantCreatePage/form.tsx @@ -14,6 +14,10 @@ import { createFetchMoreReferencesHandler, createFetchReferencesHandler, } from "@saleor/attributes/utils/handlers"; +import { + ChannelPriceAndPreorderData, + IChannelPriceAndPreorderArgs, +} from "@saleor/channels/utils"; import { AttributeInput } from "@saleor/components/Attributes"; import { useExitFormDialog } from "@saleor/components/Form/useExitFormDialog"; import { MetadataFormData } from "@saleor/components/Metadata"; @@ -35,7 +39,10 @@ import useFormset, { import useHandleFormSubmit from "@saleor/hooks/useHandleFormSubmit"; import { errorMessages } from "@saleor/intl"; import { getVariantAttributeInputFromProduct } from "@saleor/products/utils/data"; -import { createPreorderEndDateChangeHandler } from "@saleor/products/utils/handlers"; +import { + createPreorderEndDateChangeHandler, + getChannelsInput, +} from "@saleor/products/utils/handlers"; import { FetchMoreProps, RelayToFlat, ReorderEvent } from "@saleor/types"; import useMetadataChangeTrigger from "@saleor/utils/metadata/useMetadataChangeTrigger"; import { useMultipleRichText } from "@saleor/utils/richText/useMultipleRichText"; @@ -43,6 +50,11 @@ import React, { useEffect } from "react"; import { useIntl } from "react-intl"; import { ProductStockFormsetData, ProductStockInput } from "../ProductStocks"; +import { + concatChannelsBySelection, + createChannelsWithPreorderInfo, + validateChannels, +} from "../ProductVariantChannels/formOpretations"; export interface ProductVariantCreateFormData extends MetadataFormData { sku: string; @@ -59,6 +71,10 @@ export interface ProductVariantCreateData extends ProductVariantCreateFormData { attributes: AttributeInput[]; attributesWithNewFileValue: FormsetData; stocks: ProductStockInput[]; + channelListings: FormsetData< + ChannelPriceAndPreorderData, + IChannelPriceAndPreorderArgs + >; } export interface UseProductVariantCreateFormOpts { @@ -74,7 +90,10 @@ export interface UseProductVariantCreateFormOpts { export interface ProductVariantCreateHandlers extends Record< - "changeStock" | "selectAttribute" | "selectAttributeMultiple", + | "changeStock" + | "selectAttribute" + | "selectAttributeMultiple" + | "changeChannels", FormsetChange >, Record<"selectAttributeReference", FormsetChange>, @@ -82,6 +101,7 @@ export interface ProductVariantCreateHandlers Record<"reorderAttributeValue", FormsetChange>, Record<"addStock" | "deleteStock", (id: string) => void> { changeMetadata: FormChange; + updateChannels: (selectedChannelsIds: string[]) => void; changePreorderEndDate: FormChange; fetchReferences: (value: string) => void; fetchMoreReferences: FetchMoreProps; @@ -138,7 +158,14 @@ function useProductVariantCreateForm( setIsSubmitDisabled, } = form; + const currentChannelsWithPreorderInfo = createChannelsWithPreorderInfo( + product, + ); + const channelsInput = getChannelsInput(currentChannelsWithPreorderInfo); + const attributes = useFormset(attributeInput); + const channels = useFormset(channelsInput); + const { getters: attributeRichTextGetters, getValues: getAttributeRichTextValues, @@ -221,6 +248,23 @@ function useProductVariantCreateForm( intl.formatMessage(errorMessages.preorderEndDateInFutureErrorText), ); + const handleChannelChange: FormsetChange = (id, value) => { + channels.change(id, value); + triggerChange(); + }; + + const handleUpdateChannels = (selectedIds: string[]) => { + channels.set( + concatChannelsBySelection( + selectedIds, + channels, + currentChannelsWithPreorderInfo, + ), + ); + + triggerChange(); + }; + const data: ProductVariantCreateData = { ...formData, attributes: getAttributesDisplayData( @@ -231,6 +275,7 @@ function useProductVariantCreateForm( ), attributesWithNewFileValue: attributesWithNewFileValue.data, stocks: stocks.data, + channelListings: channels.data, }; const getSubmitData = async (): Promise => ({ @@ -253,12 +298,15 @@ function useProductVariantCreateForm( useEffect(() => setExitDialogSubmitRef(submit), [submit]); - const formDisabled = + const invalidChannels = validateChannels(channels?.data); + const invalidPreorder = data.isPreorder && data.hasPreorderEndDate && !!form.errors.preorderEndDateTime; + const formDisabled = invalidPreorder || invalidChannels; const isSaveDisabled = disabled || formDisabled || !onSubmit; + setIsSubmitDisabled(isSaveDisabled); return { @@ -268,6 +316,8 @@ function useProductVariantCreateForm( formErrors: form.errors, handlers: { addStock: handleStockAdd, + changeChannels: handleChannelChange, + updateChannels: handleUpdateChannels, changeMetadata, changeStock: handleStockChange, changePreorderEndDate: handlePreorderEndDateChange, diff --git a/src/products/components/ProductVariantPage/ProductVariantPage.tsx b/src/products/components/ProductVariantPage/ProductVariantPage.tsx index 4e0297c3e..f23af145b 100644 --- a/src/products/components/ProductVariantPage/ProductVariantPage.tsx +++ b/src/products/components/ProductVariantPage/ProductVariantPage.tsx @@ -27,6 +27,7 @@ import { } from "@saleor/graphql"; import useNavigator from "@saleor/hooks/useNavigator"; import { ConfirmButtonTransitionState } from "@saleor/macaw-ui"; +import { VariantDetailsChannelsAvailabilityCard } from "@saleor/products/components/ProductVariantChannels/ChannelsAvailabilityCard"; import { productUrl } from "@saleor/products/urls"; import { FetchMoreProps, RelayToFlat, ReorderAction } from "@saleor/types"; import React from "react"; @@ -34,6 +35,8 @@ import { defineMessages, useIntl } from "react-intl"; import ProductShipping from "../ProductShipping/ProductShipping"; import ProductStocks, { ProductStockInput } from "../ProductStocks"; +import { useManageChannels } from "../ProductVariantChannels/useManageChannels"; +import { VariantChannelsDialog } from "../ProductVariantChannels/VariantChannelsDialog"; import ProductVariantCheckoutSettings from "../ProductVariantCheckoutSettings/ProductVariantCheckoutSettings"; import ProductVariantEndPreorderDialog from "../ProductVariantEndPreorderDialog"; import ProductVariantMediaSelectDialog from "../ProductVariantImageSelectDialog"; @@ -46,7 +49,6 @@ import ProductVariantUpdateForm, { ProductVariantUpdateHandlers, ProductVariantUpdateSubmitData, } from "./form"; -import VariantDetailsChannelsAvailabilityCard from "./VariantDetailsChannelsAvailabilityCard"; const messages = defineMessages({ nonSelectionAttributes: { @@ -157,7 +159,10 @@ const ProductVariantPage: React.FC = ({ }) => { const intl = useIntl(); const navigate = useNavigator(); - + const { + isOpen: isManageChannelsModalOpen, + toggle: toggleManageChannels, + } = useManageChannels(); const [isModalOpened, setModalStatus] = React.useState(false); const toggleModal = () => setModalStatus(!isModalOpened); @@ -254,7 +259,10 @@ const ProductVariantPage: React.FC = ({ />
- + {nonSelectionAttributes.length > 0 && ( <> = ({ } /> )} + {variant && ( + + )} ); }} diff --git a/src/products/components/ProductVariantPage/VariantDetailsChannelsAvailabilityCard/index.tsx b/src/products/components/ProductVariantPage/VariantDetailsChannelsAvailabilityCard/index.tsx deleted file mode 100644 index c234f3495..000000000 --- a/src/products/components/ProductVariantPage/VariantDetailsChannelsAvailabilityCard/index.tsx +++ /dev/null @@ -1,186 +0,0 @@ -import { - Accordion, - AccordionSummary, - CardContent, - Divider, - Typography, -} from "@material-ui/core"; -import Skeleton from "@saleor/components/Skeleton"; -import { ProductVariantFragment } from "@saleor/graphql"; -import useDateLocalize from "@saleor/hooks/useDateLocalize"; -import IconChevronDown from "@saleor/icons/ChevronDown"; -import { makeStyles } from "@saleor/macaw-ui"; -import React from "react"; -import { useIntl } from "react-intl"; - -import { variantDetailsChannelsAvailabilityCardMessages as messages } from "../messages"; -import CardContainer from "./VariantDetailsChannelsAvailabilityCardContainer"; - -const useExpanderStyles = makeStyles( - () => ({ - expanded: {}, - root: { - boxShadow: "none", - margin: 0, - padding: 0, - - "&:before": { - content: "none", - }, - - "&$expanded": { - margin: 0, - border: "none", - }, - }, - }), - { name: "VariantDetailsChannelsAvailabilityCardExpander" }, -); - -const useSummaryStyles = makeStyles( - () => ({ - expanded: {}, - root: { - width: "100%", - border: "none", - margin: 0, - padding: 0, - minHeight: 0, - - "&$expanded": { - minHeight: 0, - }, - }, - content: { - margin: 0, - - "&$expanded": { - margin: 0, - }, - }, - }), - { name: "VariantDetailsChannelsAvailabilityCardExpanderSummary" }, -); - -const useStyles = makeStyles( - () => ({ - summaryContent: { - paddingTop: 0, - }, - }), - { name: "VariantDetailsChannelsAvailabilityCard" }, -); - -interface VariantDetailsChannelsAvailabilityCardProps { - variant: ProductVariantFragment; -} - -const VariantDetailsChannelsAvailabilityCard: React.FC = ({ - variant, -}) => { - const classes = useStyles(); - const expanderClasses = useExpanderStyles({}); - const summaryClasses = useSummaryStyles({}); - const localizeDate = useDateLocalize(); - const intl = useIntl(); - - const getProductChannelListingByChannelId = (channelId: string) => - variant?.product.channelListings.find( - ({ channel }) => channel.id === channelId, - ); - - const getItemSubtitle = (channelId: string) => { - const { - isPublished, - publicationDate, - } = getProductChannelListingByChannelId(channelId); - - if (!isPublished) { - return intl.formatMessage(messages.itemSubtitleHidden); - } - - return intl.formatMessage(messages.itemSubtitlePublished, { - publicationDate: localizeDate(publicationDate), - }); - }; - - if (!variant) { - return ( - - - - - - ); - } - - const { channelListings } = variant; - - const isAvailableInAnyChannels = !!channelListings.length; - - const variantChannelListingsChannelsIds = channelListings.map( - ({ channel: { id } }) => id, - ); - - const allAvailableChannelsListings = variant.product.channelListings.filter( - ({ channel }) => variantChannelListingsChannelsIds.includes(channel.id), - ); - - const publishedInChannelsListings = allAvailableChannelsListings.filter( - ({ isPublished }) => isPublished, - ); - - if (!isAvailableInAnyChannels) { - return ( - - - - {intl.formatMessage(messages.noItemsAvailable)} - - - - ); - } - - return ( - - - - } - classes={summaryClasses} - data-test-id="channels-variant-availability-summary" - > - - {intl.formatMessage(messages.subtitle, { - publishedInChannelsCount: publishedInChannelsListings.length, - availableChannelsCount: allAvailableChannelsListings.length, - })} - - - - - {channelListings.map(({ channel }) => ( - - - - - {channel.name} - - - {getItemSubtitle(channel.id)} - - - - ))} - - - ); -}; - -export default VariantDetailsChannelsAvailabilityCard; diff --git a/src/products/components/ProductVariantPage/form.tsx b/src/products/components/ProductVariantPage/form.tsx index 91340568e..199036dbe 100644 --- a/src/products/components/ProductVariantPage/form.tsx +++ b/src/products/components/ProductVariantPage/form.tsx @@ -61,6 +61,10 @@ import React, { useEffect } from "react"; import { useIntl } from "react-intl"; import { ProductStockInput } from "../ProductStocks"; +import { + concatChannelsBySelection, + extractChannelPricesFromVariantChannel, +} from "../ProductVariantChannels/formOpretations"; export interface ProductVariantUpdateFormData extends MetadataFormData { sku: string; @@ -120,6 +124,7 @@ export interface ProductVariantUpdateHandlers Record<"addStock" | "deleteStock", (id: string) => void> { changePreorderEndDate: FormChange; changeMetadata: FormChange; + updateChannels: (selectedChannelsIds: string[]) => void; fetchReferences: (value: string) => void; fetchMoreReferences: FetchMoreProps; } @@ -284,6 +289,41 @@ function useProductVariantUpdateForm( intl.formatMessage(errorMessages.preorderEndDateInFutureErrorText), ); + const handleUpdateChannels = (selectedIds: string[]) => { + const allChannels = variant.product.channelListings.map(listing => { + const variantChannel = variant?.channelListings?.find( + channelListing => channelListing.channel.id === listing.channel.id, + ); + + if (variantChannel) { + const { costPrice, price } = extractChannelPricesFromVariantChannel( + variantChannel, + ); + + return { + ...variantChannel.channel, + currency: variantChannel.channel.currencyCode, + preorderThreshold: variantChannel?.preorderThreshold.quantity, + soldUnits: variantChannel?.preorderThreshold?.soldUnits, + price, + costPrice, + }; + } + + return { + ...listing.channel, + currency: listing.channel.currencyCode, + price: "", + preorderThreshold: null, + soldUnits: null, + }; + }); + + channels.set(concatChannelsBySelection(selectedIds, channels, allChannels)); + + triggerChange(); + }; + const dataStocks = stocks.data.map(stock => stock.id); const variantStocks = variant?.stocks.map(stock => stock.warehouse.id) || []; const stockDiff = arrayDiff(variantStocks, dataStocks); @@ -364,6 +404,7 @@ function useProductVariantUpdateForm( handlers: { addStock: handleStockAdd, changeChannels: handleChannelChange, + updateChannels: handleUpdateChannels, changeMetadata, changeStock: handleStockChange, changePreorderEndDate: handlePreorderEndDateChange, diff --git a/src/products/queries.ts b/src/products/queries.ts index e190a5d12..902a8b5dd 100644 --- a/src/products/queries.ts +++ b/src/products/queries.ts @@ -185,6 +185,8 @@ export const productVariantCreateQuery = gql` url } channelListings { + isPublished + publicationDate channel { id name diff --git a/src/products/views/ProductVariant.tsx b/src/products/views/ProductVariant/ProductVariant.tsx similarity index 84% rename from src/products/views/ProductVariant.tsx rename to src/products/views/ProductVariant/ProductVariant.tsx index a26fb0f26..92b6cef5f 100644 --- a/src/products/views/ProductVariant.tsx +++ b/src/products/views/ProductVariant/ProductVariant.tsx @@ -16,10 +16,8 @@ import { WindowTitle } from "@saleor/components/WindowTitle"; import { DEFAULT_INITIAL_SEARCH_DATA } from "@saleor/config"; import { ProductErrorWithAttributesFragment, - ProductVariantFragment, useAttributeValueDeleteMutation, useFileUploadMutation, - useProductVariantChannelListingUpdateMutation, useProductVariantDetailsQuery, useProductVariantPreorderDeactivateMutation, useProductVariantReorderMutation, @@ -36,6 +34,7 @@ import useNotifier from "@saleor/hooks/useNotifier"; import useOnSetDefaultVariant from "@saleor/hooks/useOnSetDefaultVariant"; import useShop from "@saleor/hooks/useShop"; import { commonMessages } from "@saleor/intl"; +import { weight } from "@saleor/misc"; import { getAttributeInputFromVariant } from "@saleor/products/utils/data"; import usePageSearch from "@saleor/searches/usePageSearch"; import useProductSearch from "@saleor/searches/useProductSearch"; @@ -47,18 +46,18 @@ import { warehouseAddPath } from "@saleor/warehouses/urls"; import React, { useEffect, useState } from "react"; import { useIntl } from "react-intl"; -import { extractMutationErrors, weight } from "../../misc"; -import ProductVariantDeleteDialog from "../components/ProductVariantDeleteDialog"; -import ProductVariantPage from "../components/ProductVariantPage"; -import { ProductVariantUpdateSubmitData } from "../components/ProductVariantPage/form"; +import ProductVariantDeleteDialog from "../../components/ProductVariantDeleteDialog"; +import ProductVariantPage from "../../components/ProductVariantPage"; +import { ProductVariantUpdateSubmitData } from "../../components/ProductVariantPage/form"; import { productUrl, productVariantEditUrl, ProductVariantEditUrlDialog, ProductVariantEditUrlQueryParams, -} from "../urls"; -import { mapFormsetStockToStockInput } from "../utils/data"; -import { createVariantReorderHandler } from "./ProductUpdate/handlers"; +} from "../../urls"; +import { mapFormsetStockToStockInput } from "../../utils/data"; +import { createVariantReorderHandler } from "./../ProductUpdate/handlers"; +import { useSubmitChannels } from "./useSubmitChannels"; interface ProductUpdateProps { variantId: string; @@ -92,11 +91,6 @@ export const ProductVariant: React.FC = ({ const [updateMetadata] = useUpdateMetadataMutation({}); const [updatePrivateMetadata] = useUpdatePrivateMetadataMutation({}); - const [ - updateChannels, - updateChannelsOpts, - ] = useProductVariantChannelListingUpdateMutation({}); - const [openModal] = createDialogActionHandlers< ProductVariantEditUrlDialog, ProductVariantEditUrlQueryParams @@ -142,49 +136,7 @@ export const ProductVariant: React.FC = ({ deleteAttributeValueOpts, ] = useAttributeValueDeleteMutation({}); - const handleSubmitChannels = async ( - data: ProductVariantUpdateSubmitData, - variant: ProductVariantFragment, - ) => { - const channelsHaveChanged = data.channelListings.some(channel => { - const variantChannel = variant.channelListings.find( - variantChannel => variantChannel.channel.id === channel.id, - ); - - const priceHasChanged = - channel.value.price !== variantChannel?.price?.amount.toString(); - - const costPriceHasChanged = - channel.value.costPrice !== - variantChannel?.costPrice?.amount.toString(); - - const preorderThresholdHasChanged = - channel.value?.preorderThreshold !== - variantChannel.preorderThreshold.quantity; - - return ( - priceHasChanged || costPriceHasChanged || preorderThresholdHasChanged - ); - }); - - if (channelsHaveChanged) { - return extractMutationErrors( - updateChannels({ - variables: { - id: variant.id, - input: data.channelListings.map(listing => ({ - channelId: listing.id, - costPrice: listing.value.costPrice || null, - price: listing.value.price, - preorderThreshold: listing.value.preorderThreshold, - })), - }, - }), - ); - } - - return []; - }; + const { handleSubmitChannels, updateChannelsOpts } = useSubmitChannels(); const variant = data?.productVariant; const channels = createVariantChannels(variant); @@ -292,7 +244,8 @@ export const ProductVariant: React.FC = ({ firstValues: 10, }, }); - await handleSubmitChannels(data, variant); + + const channelErrors = await handleSubmitChannels(data, variant); return [ ...mergeFileUploadErrors(uploadFilesResult), @@ -301,6 +254,7 @@ export const ProductVariant: React.FC = ({ ...result.data?.productVariantStocksDelete.errors, ...result.data?.productVariantStocksUpdate.errors, ...result.data?.productVariantUpdate.errors, + ...channelErrors, ]; }; const handleSubmit = createMetadataUpdateHandler( @@ -386,12 +340,7 @@ export const ProductVariant: React.FC = ({ warehouses={mapEdgesToItems(warehouses?.data?.warehouses) || []} onDelete={() => openModal("remove")} onMediaSelect={handleMediaSelect} - onSubmit={async data => { - const errors = await handleSubmit(data); - const channelErrors = await handleSubmitChannels(data, variant); - - return [...errors, ...channelErrors]; - }} + onSubmit={handleSubmit} onWarehouseConfigure={() => navigate(warehouseAddPath)} onVariantPreorderDeactivate={handleDeactivateVariantPreorder} variantDeactivatePreoderButtonState={deactivatePreoderOpts.status} diff --git a/src/products/views/ProductVariant/index.tsx b/src/products/views/ProductVariant/index.tsx new file mode 100644 index 000000000..3a7463b3c --- /dev/null +++ b/src/products/views/ProductVariant/index.tsx @@ -0,0 +1,2 @@ +export { default } from "./ProductVariant"; +export * from "./ProductVariant"; diff --git a/src/products/views/ProductVariant/useSubmitChannels.ts b/src/products/views/ProductVariant/useSubmitChannels.ts new file mode 100644 index 000000000..1e7789b28 --- /dev/null +++ b/src/products/views/ProductVariant/useSubmitChannels.ts @@ -0,0 +1,88 @@ +import { + ProductVariantFragment, + useProductChannelListingUpdateMutation, + useProductVariantChannelListingUpdateMutation, +} from "@saleor/graphql"; +import { extractMutationErrors } from "@saleor/misc"; +import { ProductVariantUpdateSubmitData } from "@saleor/products/components/ProductVariantPage/form"; + +type Product = ProductVariantUpdateSubmitData; +type Variant = ProductVariantFragment; + +const isFormDataChanged = (data: Product, variant: Variant) => + data.channelListings.some(channel => { + const variantChannel = variant.channelListings.find( + variantChannel => variantChannel.channel.id === channel.id, + ); + + const priceHasChanged = + channel.value.price !== variantChannel?.price?.amount.toString(); + + const costPriceHasChanged = + channel.value.costPrice !== variantChannel?.costPrice?.amount.toString(); + + const preorderThresholdHasChanged = + channel.value?.preorderThreshold !== + variantChannel?.preorderThreshold.quantity; + + return ( + priceHasChanged || costPriceHasChanged || preorderThresholdHasChanged + ); + }); + +const hasRecordDeleted = (data: Product, variant: Variant) => + data.channelListings.length !== variant.channelListings.length; + +const createProductUpdateListingInput = (data: Product, variant: Variant) => { + const ids = data.channelListings.map(c => c.data.id); + return variant.channelListings + .map(c => c.channel.id) + .filter(cId => !ids.includes(cId)) + .map(channelId => ({ channelId, removeVariants: [variant.id] })); +}; + +const createVariantUpdateListingInput = (data: Product) => + data.channelListings.map(listing => ({ + channelId: listing.id, + costPrice: listing.value.costPrice || null, + price: listing.value.price, + preorderThreshold: listing.value.preorderThreshold, + })); + +export const useSubmitChannels = () => { + const [updateChannelListing] = useProductChannelListingUpdateMutation(); + const [ + updateChannels, + updateChannelsOpts, + ] = useProductVariantChannelListingUpdateMutation(); + + const handleSubmitChannels = async (data: Product, variant: Variant) => { + const channelsHaveChanged = isFormDataChanged(data, variant); + const amountOfRecordsHasChanged = hasRecordDeleted(data, variant); + + if (amountOfRecordsHasChanged) { + const updateChannels = createProductUpdateListingInput(data, variant); + await updateChannelListing({ + variables: { + id: variant.product.id, + input: { updateChannels }, + }, + }); + } + + if (channelsHaveChanged) { + return extractMutationErrors( + updateChannels({ + variables: { + id: variant.id, + input: createVariantUpdateListingInput(data), + }, + }), + ); + } + + return []; + }; + + return { handleSubmitChannels, updateChannelsOpts }; +}; diff --git a/src/products/views/ProductVariantCreate.tsx b/src/products/views/ProductVariantCreate.tsx index 189eb17df..2387e20aa 100644 --- a/src/products/views/ProductVariantCreate.tsx +++ b/src/products/views/ProductVariantCreate.tsx @@ -9,6 +9,7 @@ import { WindowTitle } from "@saleor/components/WindowTitle"; import { DEFAULT_INITIAL_SEARCH_DATA } from "@saleor/config"; import { useFileUploadMutation, + useProductVariantChannelListingUpdateMutation, useProductVariantCreateDataQuery, useProductVariantReorderMutation, useUpdateMetadataMutation, @@ -86,7 +87,7 @@ export const ProductVariant: React.FC = ({ }); }, }); - + const [updateChannels] = useProductVariantChannelListingUpdateMutation({}); const [updateMetadata] = useUpdateMetadataMutation({}); const [updatePrivateMetadata] = useUpdatePrivateMetadataMutation({}); @@ -110,7 +111,7 @@ export const ProductVariant: React.FC = ({ uploadFilesResult, ); - const result = await variantCreate({ + const variantCreateResult = await variantCreate({ variables: { input: { attributes: prepareAttributesInput({ @@ -142,9 +143,30 @@ export const ProductVariant: React.FC = ({ firstValues: 10, }, }); - const id = result.data?.productVariantCreate?.productVariant?.id || null; - return { id, errors: getMutationErrors(result) }; + const variantCreateResultErrors = getMutationErrors(variantCreateResult); + + if (variantCreateResultErrors.length > 0) { + return { id: null, errors: variantCreateResultErrors }; + } + + const id = variantCreateResult.data.productVariantCreate.productVariant.id; + + const updateChannelsResult = await updateChannels({ + variables: { + id, + input: formData.channelListings.map(listing => ({ + channelId: listing.id, + costPrice: listing.value.costPrice || null, + price: listing.value.price, + preorderThreshold: listing.value.preorderThreshold, + })), + }, + }); + + const updateChannelsErrors = getMutationErrors(updateChannelsResult); + + return { id, errors: updateChannelsErrors }; }; const handleSubmit = createMetadataCreateHandler( diff --git a/src/storybook/__snapshots__/Stories.test.ts.snap b/src/storybook/__snapshots__/Stories.test.ts.snap index 5c2f378dc..40c1a072c 100644 --- a/src/storybook/__snapshots__/Stories.test.ts.snap +++ b/src/storybook/__snapshots__/Stories.test.ts.snap @@ -180165,6 +180165,147 @@ exports[`Storyshots Views / Products / Create product variant add first variant
+
+
+
+ + Availability + +
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+
+ Channel1 +
+
+ Published since Jul 14, 2020 +
+
+
+
+
+ Channel2 +
+
+ Hidden +
+
+
+
+
+
+
+
+
@@ -180577,11 +180718,273 @@ exports[`Storyshots Views / Products / Create product variant add first variant class="MuiCardContent-root-id" >
- There is no channel to define prices for. You need to first add variant to channels to define prices. + Channels that don’t have assigned prices will use their parent channel to define the price. Price will be converted to channel’s currency
+
+ + + + + + + + + + + + + + + + + + + + +
+ Channel Name + + Selling Price + + Cost price +
+ Channel1 + +
+ +
+ +
+
+ USD +
+
+ +
+
+
+
+ +
+ +
+
+ USD +
+
+ +
+
+
+ Channel2 + +
+ +
+ +
+
+ USD +
+
+ +
+
+
+
+ +
+ +
+
+ USD +
+
+ +
+
+
+
+
+
+
+ + Availability + +
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+
+ Channel1 +
+
+ Published since Jul 14, 2020 +
+
+
+
+
+ Channel2 +
+
+ Hidden +
+
+
+
+
+
+
+
+
@@ -181580,11 +182124,273 @@ exports[`Storyshots Views / Products / Create product variant default 1`] = ` class="MuiCardContent-root-id" >
- There is no channel to define prices for. You need to first add variant to channels to define prices. + Channels that don’t have assigned prices will use their parent channel to define the price. Price will be converted to channel’s currency
+
+ + + + + + + + + + + + + + + + + + + + +
+ Channel Name + + Selling Price + + Cost price +
+ Channel1 + +
+ +
+ +
+
+ USD +
+
+ +
+
+
+
+ +
+ +
+
+ USD +
+
+ +
+
+
+ Channel2 + +
+ +
+ +
+
+ USD +
+
+ +
+
+
+
+ +
+ +
+
+ USD +
+
+ +
+
+
+
+
+
+
+ + Availability + +
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+
+ Channel1 +
+
+ Published since Jul 14, 2020 +
+
+
+
+
+ Channel2 +
+
+ Hidden +
+
+
+
+
+
+
+
+
@@ -182583,11 +183530,273 @@ exports[`Storyshots Views / Products / Create product variant no warehouses 1`] class="MuiCardContent-root-id" >
- There is no channel to define prices for. You need to first add variant to channels to define prices. + Channels that don’t have assigned prices will use their parent channel to define the price. Price will be converted to channel’s currency
+
+ + + + + + + + + + + + + + + + + + + + +
+ Channel Name + + Selling Price + + Cost price +
+ Channel1 + +
+ +
+ +
+
+ USD +
+
+ +
+
+
+
+ +
+ +
+
+ USD +
+
+ +
+
+
+ Channel2 + +
+ +
+ +
+
+ USD +
+
+ +
+
+
+
+ +
+ +
+
+ USD +
+
+ +
+
+
+
+
+
+
+ + Availability + +
+
+
+ + ‌ + +
+
+
@@ -183415,7 +184654,7 @@ exports[`Storyshots Views / Products / Create product variant when loading data
- There is no channel to define prices for. You need to first add variant to channels to define prices. + Assign this variant to a channel in the product channel manager to define prices
@@ -184009,6 +185248,147 @@ exports[`Storyshots Views / Products / Create product variant with errors 1`] =
+
+
+
+ + Availability + +
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+
+ Channel1 +
+
+ Published since Jul 14, 2020 +
+
+
+
+
+ Channel2 +
+
+ Hidden +
+
+
+
+
+
+
+
+
@@ -184426,11 +185806,273 @@ exports[`Storyshots Views / Products / Create product variant with errors 1`] = class="MuiCardContent-root-id" >
- There is no channel to define prices for. You need to first add variant to channels to define prices. + Channels that don’t have assigned prices will use their parent channel to define the price. Price will be converted to channel’s currency
+
+ + + + + + + + + + + + + + + + + + + + +
+ Channel Name + + Selling Price + + Cost price +
+ Channel1 + +
+ +
+ +
+
+ USD +
+
+ +
+
+
+
+ +
+ +
+
+ USD +
+
+ +
+
+
+ Channel2 + +
+ +
+ +
+
+ USD +
+
+ +
+
+
+
+ +
+ +
+
+ USD +
+
+ +
+
+
+
+
+ +
+
+ +
+
+ +