Add UI to enable channel listing in variants view (#2303)

* syncg

* manage channels modal, price editing

* fix typings

* fix typings, splitting components

* fix typings, splitting components

* update snapshot

* update types

* update messages

* use toggle

* update e2e tests

* update messages

* add manage button to edit page

* add manage button to edit page

* re-organize components

* update modals

* fix toggle all

* cover scenario when we have no channels
This commit is contained in:
Patryk Andrzejewski 2022-09-22 12:00:13 +02:00 committed by GitHub
parent 2bdd1311de
commit ae9b7b31dc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
35 changed files with 2608 additions and 297 deletions

View file

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

View file

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

View file

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

2
package-lock.json generated
View file

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

View file

@ -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<ChannelsAvailabilityDialogProp
const { query, onQueryChange, filteredChannels } = useChannelsSearch(
channels,
);
const hasChannels = channels.length > 0;
const handleToggleAll = () => toggleAll(channels, selected);
@ -53,20 +55,24 @@ export const ChannelsAvailabilityDialog: React.FC<ChannelsAvailabilityDialogProp
title={title}
disabled={disabled}
>
<ChannelsAvailabilityDialogWrapper
hasAnyChannelsToDisplay={!!filteredChannels.length}
hasAllSelected={hasAllSelected}
query={query}
onQueryChange={onQueryChange}
toggleAll={handleToggleAll}
contentType={contentType}
>
<ChannelsAvailabilityDialogChannelsList
channels={filteredChannels}
isChannelSelected={isSelected}
onChange={onChange}
/>
</ChannelsAvailabilityDialogWrapper>
{hasChannels ? (
<ChannelsAvailabilityDialogWrapper
hasAnyChannelsToDisplay={!!filteredChannels.length}
hasAllSelected={hasAllSelected}
query={query}
onQueryChange={onQueryChange}
toggleAll={handleToggleAll}
contentType={contentType}
>
<ChannelsAvailabilityDialogChannelsList
channels={filteredChannels}
isChannelSelected={isSelected}
onChange={onChange}
/>
</ChannelsAvailabilityDialogWrapper>
) : (
<NoChannels />
)}
</ActionDialog>
);
};

View file

@ -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 = () => (
<Typography variant="subtitle2">
<FormattedMessage {...messages.noChannels} />
</Typography>
);

View file

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

View file

@ -12484,6 +12484,8 @@ export const ProductVariantCreateDataDocument = gql`
url
}
channelListings {
isPublished
publicationDate
channel {
id
name

View file

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

View file

@ -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<string, number>;
}
export const AvailabilityCard: React.FC<AvailabilityCardProps> = ({
availabilityCount,
items,
productChannelListings,
children,
}) => {
const intl = useIntl();
const channelListSummary = intl.formatMessage(
messages.subtitle,
availabilityCount,
);
if (items.length === 0) {
return (
<CardContainer cardTitle={children}>
<NotAvailable />
</CardContainer>
);
}
return (
<CardContainer cardTitle={children}>
<ChannelsList summary={channelListSummary}>
{items.map(channel => (
<ChannelsListItem {...channel} listings={productChannelListings} />
))}
</ChannelsList>
</CardContainer>
);
};

View file

@ -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 = () => (
<CardContainer>
<CardContent>
<Skeleton />
</CardContent>
</CardContainer>
);

View file

@ -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<ChannelListProps> = ({
summary,
children,
}) => {
const classes = useAccordionStyles();
const expanderClasses = useExpanderStyles({});
const summaryClasses = useSummaryStyles({});
return (
<Accordion classes={expanderClasses}>
<CardContent className={classes.summaryContent}>
<AccordionSummary
expandIcon={<IconChevronDown />}
classes={summaryClasses}
data-test-id="channels-variant-availability-summary"
>
<Typography variant="caption">{summary}</Typography>
</AccordionSummary>
</CardContent>
{children}
</Accordion>
);
};

View file

@ -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<Channel, "id" | "name"> & {
listings: ProductChannelListing;
};
export const ChannelsListItem: React.FC<ChannelsListItemProps> = ({
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 (
<React.Fragment key={id}>
<Divider />
<CardContent>
<Typography
data-test-id={`channels-variant-availability-item-title-${id}`}
>
{name}
</Typography>
<Typography
variant="caption"
data-test-id={`channels-variant-availability-item-subtitle-${id}`}
>
{getItemSubtitle(id)}
</Typography>
</CardContent>
</React.Fragment>
);
};

View file

@ -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<CreateVariantTitleProps> = ({
onManageClick,
}) => {
const intl = useIntl();
return (
<CardTitle
title={intl.formatMessage(messages.title)}
toolbar={
<Button variant="tertiary" disabled={false} onClick={onManageClick}>
{intl.formatMessage(messages.manageButtonText)}
</Button>
}
/>
);
};

View file

@ -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 (
<CardContent>
<Typography variant="caption">
{intl.formatMessage(messages.noItemsAvailable)}
</Typography>
</CardContent>
);
};

View file

@ -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<VariantDetailsChannelsAvailabilityCardContainerProps> = ({
children,
cardTitle,
}) => (
<>
<Card>
<CardTitle title={<FormattedMessage {...messages.title} />} />
{cardTitle || (
<CardTitle title={<FormattedMessage {...messages.title} />} />
)}
{children}
</Card>
<CardSpacer />

View file

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

View file

@ -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<WrapperProps> = ({ item, children }) => {
if (!item) {
return <CardSkeleton />;
}
const channels = item.channelListings.map(({ channel }) => channel);
return children({ channels });
};
export const VariantDetailsChannelsAvailabilityCard: React.FC<VariantDetailsChannelsAvailabilityCardProps> = ({
variant,
onManageClick,
}) => (
<Wrapper item={variant}>
{({ channels }) => (
<AvailabilityCard
items={channels}
availabilityCount={getAvailabilityCountForVariant(variant)}
productChannelListings={variant.product.channelListings}
>
<CreateVariantTitle onManageClick={onManageClick} />
</AvailabilityCard>
)}
</Wrapper>
);
export const ProductDetailsChannelsAvailabilityCard: React.FC<ProductDetailsChannelsAvailabilityCardProps> = ({
product,
onManageClick,
}) => (
<Wrapper item={product}>
{({ channels }) => (
<AvailabilityCard
items={channels}
availabilityCount={getAvailabilityCountForProduct(product)}
productChannelListings={product.channelListings}
>
<CreateVariantTitle onManageClick={onManageClick} />
</AvailabilityCard>
)}
</Wrapper>
);

View file

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

View file

@ -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<VariantChannelsDialogProps> = ({
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 (
<ChannelsAvailabilityDialog
isSelected={isSelected}
channels={allChannels}
onChange={handleChange}
onClose={onClose}
open={open}
title="Manage Products Channel Availability"
confirmButtonState="default"
selected={selected.length}
onConfirm={handleConfirm}
toggleAll={handleToggleAll}
/>
);
};

View file

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

View file

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

View file

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

View file

@ -0,0 +1,14 @@
import { useState } from "react";
export const useManageChannels = () => {
const [isOpen, setIsOpen] = useState(false);
const toggle = () => {
setIsOpen(p => !p);
};
return {
isOpen,
toggle,
};
};

View file

@ -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<ProductVariantCreatePageProps> = ({
}) => {
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<ProductVariantCreatePageProps> = ({
/>
</div>
<div>
<ProductDetailsChannelsAvailabilityCard
product={product}
onManageClick={toggleManageChannels}
/>
<Attributes
title={intl.formatMessage(messages.attributesHeader)}
attributes={data.attributes.filter(
@ -247,7 +257,16 @@ const ProductVariantCreatePage: React.FC<ProductVariantCreatePageProps> = ({
/>
<CardSpacer />
<ProductVariantPrice
disabledMessage={messages.pricingCardSubtitle}
disabled={!product}
ProductVariantChannelListings={data.channelListings.map(
channel => ({
...channel.data,
...channel.value,
}),
)}
errors={[]}
loading={!product}
onChange={handlers.changeChannels}
/>
<CardSpacer />
<ProductStocks
@ -299,6 +318,15 @@ const ProductVariantCreatePage: React.FC<ProductVariantCreatePageProps> = ({
}
/>
)}
{product && (
<VariantChannelsDialog
channelListings={product.channelListings}
selectedChannelListings={data.channelListings}
open={isManageChannelsModalOpen}
onClose={toggleManageChannels}
onConfirm={handlers.updateChannels}
/>
)}
</Container>
)}
</ProductVariantCreateForm>

View file

@ -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<null, File>;
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<string[]>>,
@ -82,6 +101,7 @@ export interface ProductVariantCreateHandlers
Record<"reorderAttributeValue", FormsetChange<ReorderEvent>>,
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<ProductVariantCreateData> => ({
@ -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,

View file

@ -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<ProductVariantPageProps> = ({
}) => {
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<ProductVariantPageProps> = ({
/>
</div>
<div>
<VariantDetailsChannelsAvailabilityCard variant={variant} />
<VariantDetailsChannelsAvailabilityCard
variant={variant}
onManageClick={toggleManageChannels}
/>
{nonSelectionAttributes.length > 0 && (
<>
<Attributes
@ -403,6 +411,15 @@ const ProductVariantPage: React.FC<ProductVariantPageProps> = ({
}
/>
)}
{variant && (
<VariantChannelsDialog
channelListings={variant.product.channelListings}
selectedChannelListings={data.channelListings}
open={isManageChannelsModalOpen}
onClose={toggleManageChannels}
onConfirm={handlers.updateChannels}
/>
)}
</>
);
}}

View file

@ -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<VariantDetailsChannelsAvailabilityCardProps> = ({
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 (
<CardContainer>
<CardContent>
<Skeleton />
</CardContent>
</CardContainer>
);
}
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 (
<CardContainer>
<CardContent>
<Typography variant="caption">
{intl.formatMessage(messages.noItemsAvailable)}
</Typography>
</CardContent>
</CardContainer>
);
}
return (
<CardContainer>
<Accordion classes={expanderClasses}>
<CardContent className={classes.summaryContent}>
<AccordionSummary
expandIcon={<IconChevronDown />}
classes={summaryClasses}
data-test-id="channels-variant-availability-summary"
>
<Typography variant="caption">
{intl.formatMessage(messages.subtitle, {
publishedInChannelsCount: publishedInChannelsListings.length,
availableChannelsCount: allAvailableChannelsListings.length,
})}
</Typography>
</AccordionSummary>
</CardContent>
{channelListings.map(({ channel }) => (
<React.Fragment key={channel.id}>
<Divider />
<CardContent>
<Typography
data-test-id={`channels-variant-availability-item-title-${channel.id}`}
>
{channel.name}
</Typography>
<Typography
variant="caption"
data-test-id={`channels-variant-availability-item-subtitle-${channel.id}`}
>
{getItemSubtitle(channel.id)}
</Typography>
</CardContent>
</React.Fragment>
))}
</Accordion>
</CardContainer>
);
};
export default VariantDetailsChannelsAvailabilityCard;

View file

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

View file

@ -185,6 +185,8 @@ export const productVariantCreateQuery = gql`
url
}
channelListings {
isPublished
publicationDate
channel {
id
name

View file

@ -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<ProductUpdateProps> = ({
const [updateMetadata] = useUpdateMetadataMutation({});
const [updatePrivateMetadata] = useUpdatePrivateMetadataMutation({});
const [
updateChannels,
updateChannelsOpts,
] = useProductVariantChannelListingUpdateMutation({});
const [openModal] = createDialogActionHandlers<
ProductVariantEditUrlDialog,
ProductVariantEditUrlQueryParams
@ -142,49 +136,7 @@ export const ProductVariant: React.FC<ProductUpdateProps> = ({
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<ProductUpdateProps> = ({
firstValues: 10,
},
});
await handleSubmitChannels(data, variant);
const channelErrors = await handleSubmitChannels(data, variant);
return [
...mergeFileUploadErrors(uploadFilesResult),
@ -301,6 +254,7 @@ export const ProductVariant: React.FC<ProductUpdateProps> = ({
...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<ProductUpdateProps> = ({
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}

View file

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

View file

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

View file

@ -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<ProductVariantCreateProps> = ({
});
},
});
const [updateChannels] = useProductVariantChannelListingUpdateMutation({});
const [updateMetadata] = useUpdateMetadataMutation({});
const [updatePrivateMetadata] = useUpdatePrivateMetadataMutation({});
@ -110,7 +111,7 @@ export const ProductVariant: React.FC<ProductVariantCreateProps> = ({
uploadFilesResult,
);
const result = await variantCreate({
const variantCreateResult = await variantCreate({
variables: {
input: {
attributes: prepareAttributesInput({
@ -142,9 +143,30 @@ export const ProductVariant: React.FC<ProductVariantCreateProps> = ({
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(

File diff suppressed because it is too large Load diff