Handle form errors before product creation (#2299)
* Handle form errors before product creation * Update e2e product create tests * Handle channel errors in product update * Update changelog * Fix invalid values of product type picker * Refactor product create utils * trigger ci Co-authored-by: Patryk Andrzejewski <vox3r69@gmail.com>
This commit is contained in:
parent
9f7c934dec
commit
6738467304
26 changed files with 391 additions and 82 deletions
|
@ -10,6 +10,7 @@ All notable, unreleased changes to this project will be documented in this file.
|
|||
- Fix invalid values in channel picker - #2313 by @orzechdev
|
||||
- Fix missing metadata and payment balance on unconfirmed orders - #2314 by @orzechdev
|
||||
- Fix exit form dialog false positive - #2311 by @orzechdev
|
||||
- Handle form errors before product creation - #2299 by @orzechdev
|
||||
- Fix no product error on unconfirmed order lines - #2324 by @orzechdev
|
||||
|
||||
## 3.4
|
||||
|
|
|
@ -19,7 +19,10 @@ import {
|
|||
fillUpPriceList,
|
||||
priceInputLists,
|
||||
} from "../../support/pages/catalog/products/priceListComponent";
|
||||
import { fillUpCommonFieldsForAllProductTypes } from "../../support/pages/catalog/products/productDetailsPage";
|
||||
import {
|
||||
fillUpCommonFieldsForAllProductTypes,
|
||||
fillUpProductTypeDialog,
|
||||
} from "../../support/pages/catalog/products/productDetailsPage";
|
||||
import { selectChannelInDetailsPages } from "../../support/pages/channelsPage";
|
||||
|
||||
describe("As an admin I should be able to create product", () => {
|
||||
|
@ -156,6 +159,8 @@ describe("As an admin I should be able to create product", () => {
|
|||
.visit(urlList.products)
|
||||
.get(PRODUCTS_LIST.createProductBtn)
|
||||
.click();
|
||||
fillUpProductTypeDialog(productData);
|
||||
cy.get(BUTTON_SELECTORS.submit).click();
|
||||
return fillUpCommonFieldsForAllProductTypes(productData);
|
||||
}
|
||||
});
|
||||
|
|
|
@ -4,7 +4,7 @@ export const COLLECTION_SELECTORS = {
|
|||
saveButton: "[data-test='button-bar-confirm']",
|
||||
addProductButton: "[data-test-id='add-product']",
|
||||
descriptionInput: '[data-test-id="rich-text-editor-description"]',
|
||||
placeholder: "[data-placeholder]"
|
||||
placeholder: "[data-placeholder]",
|
||||
};
|
||||
|
||||
export const collectionRow = collectionId =>
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
export const PRODUCTS_LIST = {
|
||||
productsList: "[data-test-id*='id']",
|
||||
productsNames: "[data-test-id='name']",
|
||||
dialogProductTypeInput: "[data-test-id='dialog-product-type']",
|
||||
createProductBtn: "[data-test-id='add-product']",
|
||||
searchProducts: "[placeholder='Search Products...']",
|
||||
emptyProductRow: "[data-test-id='skeleton']",
|
||||
|
|
|
@ -16,5 +16,5 @@ export const BUTTON_SELECTORS = {
|
|||
button: "button",
|
||||
deleteAssignedItemsConsentCheckbox: '[name="delete-assigned-items-consent"]',
|
||||
deleteSelectedElementsButton:
|
||||
'[data-test-id = "delete-selected-elements-icon"]'
|
||||
'[data-test-id = "delete-selected-elements-icon"]',
|
||||
};
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { PRODUCT_DETAILS } from "../../../../elements/catalog/products/product-details";
|
||||
import { PRODUCTS_LIST } from "../../../../elements/catalog/products/products-list";
|
||||
import { AVAILABLE_CHANNELS_FORM } from "../../../../elements/channels/available-channels-form";
|
||||
import { BUTTON_SELECTORS } from "../../../../elements/shared/button-selectors";
|
||||
import { addMetadataField } from "../metadataComponent";
|
||||
|
@ -90,6 +91,16 @@ export function fillUpProductGeneralInfo({ name, description, rating }) {
|
|||
.clearAndType(rating);
|
||||
}
|
||||
|
||||
export function fillUpProductTypeDialog({ productType }) {
|
||||
const organization = {};
|
||||
return cy
|
||||
.fillAutocompleteSelect(PRODUCTS_LIST.dialogProductTypeInput, productType)
|
||||
.then(selected => {
|
||||
organization.productType = selected;
|
||||
return organization;
|
||||
});
|
||||
}
|
||||
|
||||
export function fillUpProductOrganization({
|
||||
productType,
|
||||
category,
|
||||
|
|
|
@ -250,6 +250,10 @@
|
|||
"context": "OrderCustomer Fulfillment from Local Warehouse",
|
||||
"string": "Fulfill from Local Stock"
|
||||
},
|
||||
"/yJJvI": {
|
||||
"context": "dialog header",
|
||||
"string": "Select a product type"
|
||||
},
|
||||
"/z9uo1": {
|
||||
"context": "order returned success message",
|
||||
"string": "Successfully returned products!"
|
||||
|
@ -7451,6 +7455,10 @@
|
|||
"context": "card header",
|
||||
"string": "Sign In"
|
||||
},
|
||||
"w+3Q3e": {
|
||||
"context": "input label",
|
||||
"string": "Product type"
|
||||
},
|
||||
"w+5Djm": {
|
||||
"string": "Min. Order Weight"
|
||||
},
|
||||
|
|
|
@ -8,6 +8,8 @@ import { Autocomplete, ConfirmButtonTransitionState } from "@saleor/macaw-ui";
|
|||
import React from "react";
|
||||
import { useIntl } from "react-intl";
|
||||
|
||||
import { messages } from "./messages";
|
||||
|
||||
export interface ChannelPickerDialogProps {
|
||||
channelsChoices: Array<Choice<string, string>>;
|
||||
confirmButtonState: ConfirmButtonTransitionState;
|
||||
|
@ -44,20 +46,12 @@ const ChannelPickerDialog: React.FC<ChannelPickerDialogProps> = ({
|
|||
open={open}
|
||||
onClose={onClose}
|
||||
onConfirm={() => onConfirm(choice)}
|
||||
title={intl.formatMessage({
|
||||
id: "G/pgG3",
|
||||
defaultMessage: "Select a channel",
|
||||
description: "dialog header",
|
||||
})}
|
||||
title={intl.formatMessage(messages.selectChannel)}
|
||||
>
|
||||
<Autocomplete
|
||||
choices={result}
|
||||
fullWidth
|
||||
label={intl.formatMessage({
|
||||
defaultMessage: "Channel name",
|
||||
id: "nKwgxY",
|
||||
description: "select label",
|
||||
})}
|
||||
label={intl.formatMessage(messages.channelName)}
|
||||
data-test-id="channel-autocomplete"
|
||||
value={choice}
|
||||
onChange={e => setChoice(e.target.value)}
|
||||
|
|
14
src/channels/components/ChannelPickerDialog/messages.ts
Normal file
14
src/channels/components/ChannelPickerDialog/messages.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { defineMessages } from "react-intl";
|
||||
|
||||
export const messages = defineMessages({
|
||||
channelName: {
|
||||
defaultMessage: "Channel name",
|
||||
id: "nKwgxY",
|
||||
description: "select label",
|
||||
},
|
||||
selectChannel: {
|
||||
id: "G/pgG3",
|
||||
defaultMessage: "Select a channel",
|
||||
description: "dialog header",
|
||||
},
|
||||
});
|
|
@ -104,7 +104,7 @@ export const ProductCreatePage: React.FC<ProductCreatePageProps> = ({
|
|||
categories: categoryChoiceList,
|
||||
collections: collectionChoiceList,
|
||||
attributeValues,
|
||||
errors,
|
||||
errors: apiErrors,
|
||||
fetchCategories,
|
||||
fetchCollections,
|
||||
fetchMoreCategories,
|
||||
|
@ -210,6 +210,7 @@ export const ProductCreatePage: React.FC<ProductCreatePageProps> = ({
|
|||
change,
|
||||
data,
|
||||
formErrors,
|
||||
validationErrors,
|
||||
handlers,
|
||||
submit,
|
||||
isSaveDisabled,
|
||||
|
@ -218,6 +219,8 @@ export const ProductCreatePage: React.FC<ProductCreatePageProps> = ({
|
|||
// Comparing explicitly to false because `hasVariants` can be undefined
|
||||
const isSimpleProduct = data.productType?.hasVariants === false;
|
||||
|
||||
const errors = [...apiErrors, ...validationErrors];
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Backlink href={productListUrl()}>
|
||||
|
@ -315,7 +318,7 @@ export const ProductCreatePage: React.FC<ProductCreatePageProps> = ({
|
|||
collections={collections}
|
||||
data={data}
|
||||
disabled={loading}
|
||||
errors={errors}
|
||||
errors={[...errors, ...channelsErrors]}
|
||||
fetchCategories={fetchCategories}
|
||||
fetchCollections={fetchCollections}
|
||||
fetchMoreCategories={fetchMoreCategories}
|
||||
|
|
|
@ -25,6 +25,7 @@ import { MetadataFormData } from "@saleor/components/Metadata";
|
|||
import { MultiAutocompleteChoiceType } from "@saleor/components/MultiAutocompleteSelectField";
|
||||
import { SingleAutocompleteChoiceType } from "@saleor/components/SingleAutocompleteSelectField";
|
||||
import {
|
||||
ProductErrorWithAttributesFragment,
|
||||
ProductTypeQuery,
|
||||
SearchPagesQuery,
|
||||
SearchProductsQuery,
|
||||
|
@ -55,6 +56,7 @@ import {
|
|||
import {
|
||||
validateCostPrice,
|
||||
validatePrice,
|
||||
validateProductCreateData,
|
||||
} from "@saleor/products/utils/validation";
|
||||
import { PRODUCT_CREATE_FORM_ID } from "@saleor/products/views/ProductCreate/consts";
|
||||
import { FetchMoreProps, RelayToFlat, ReorderEvent } from "@saleor/types";
|
||||
|
@ -64,7 +66,7 @@ import useMetadataChangeTrigger from "@saleor/utils/metadata/useMetadataChangeTr
|
|||
import { RichTextContext } from "@saleor/utils/richText/context";
|
||||
import { useMultipleRichText } from "@saleor/utils/richText/useMultipleRichText";
|
||||
import useRichText from "@saleor/utils/richText/useRichText";
|
||||
import React, { useEffect } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useIntl } from "react-intl";
|
||||
|
||||
import { createPreorderEndDateChangeHandler } from "../../utils/handlers";
|
||||
|
@ -138,6 +140,7 @@ export interface UseProductCreateFormOutput
|
|||
RichTextProps {
|
||||
disabled: boolean;
|
||||
formErrors: FormErrors<ProductCreateData>;
|
||||
validationErrors: ProductErrorWithAttributesFragment[];
|
||||
}
|
||||
|
||||
export type UseProductCreateFormRenderProps = Omit<
|
||||
|
@ -185,6 +188,9 @@ function useProductCreateForm(
|
|||
opts: UseProductCreateFormOpts,
|
||||
): UseProductCreateFormOutput {
|
||||
const intl = useIntl();
|
||||
const [validationErrors, setValidationErrors] = useState<
|
||||
ProductErrorWithAttributesFragment[]
|
||||
>([]);
|
||||
const defaultInitialFormData: ProductCreateFormData &
|
||||
Record<"productType", string> = {
|
||||
category: "",
|
||||
|
@ -374,14 +380,39 @@ function useProductCreateForm(
|
|||
),
|
||||
});
|
||||
|
||||
const handleSubmit = async (data: ProductCreateData) => {
|
||||
const errors = validateProductCreateData(data);
|
||||
|
||||
setValidationErrors(errors);
|
||||
|
||||
if (errors.length) {
|
||||
return errors;
|
||||
}
|
||||
|
||||
return onSubmit(data);
|
||||
};
|
||||
|
||||
const handleFormSubmit = useHandleFormSubmit({
|
||||
formId,
|
||||
onSubmit,
|
||||
onSubmit: handleSubmit,
|
||||
});
|
||||
|
||||
const submit = async () => handleFormSubmit(await getData());
|
||||
const submit = async () => {
|
||||
const errors = await handleFormSubmit(await getData());
|
||||
|
||||
const { setExitDialogSubmitRef, setIsSubmitDisabled } = useExitFormDialog({
|
||||
if (errors.length) {
|
||||
setIsSubmitDisabled(isSubmitDisabled);
|
||||
setIsDirty(true);
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
const {
|
||||
setExitDialogSubmitRef,
|
||||
setIsSubmitDisabled,
|
||||
setIsDirty,
|
||||
} = useExitFormDialog({
|
||||
formId: PRODUCT_CREATE_FORM_ID,
|
||||
});
|
||||
|
||||
|
@ -415,14 +446,20 @@ function useProductCreateForm(
|
|||
return true;
|
||||
};
|
||||
|
||||
const isSaveDisabled = loading || !onSubmit || !isValid();
|
||||
setIsSubmitDisabled(isSaveDisabled);
|
||||
const isSaveDisabled = loading || !onSubmit;
|
||||
const isSubmitDisabled = isSaveDisabled || !isValid();
|
||||
|
||||
useEffect(() => {
|
||||
setIsSubmitDisabled(isSubmitDisabled);
|
||||
setIsDirty(true);
|
||||
}, [isSubmitDisabled]);
|
||||
|
||||
return {
|
||||
change: handleChange,
|
||||
data,
|
||||
disabled: isSaveDisabled,
|
||||
formErrors: form.errors,
|
||||
validationErrors,
|
||||
handlers: {
|
||||
addStock: handleStockAdd,
|
||||
changeChannelPrice: handleChannelPriceChange,
|
||||
|
|
|
@ -35,7 +35,7 @@ import { hasLimits, isLimitReached } from "@saleor/utils/limits";
|
|||
import React from "react";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
|
||||
import { productAddUrl, ProductListUrlSortField } from "../../urls";
|
||||
import { ProductListUrlSortField } from "../../urls";
|
||||
import ProductList from "../ProductList";
|
||||
import { columnsMessages } from "../ProductList/messages";
|
||||
import {
|
||||
|
@ -62,6 +62,7 @@ export interface ProductListPageProps
|
|||
limits: RefreshLimitsQuery["shop"]["limits"];
|
||||
totalGridAttributes: number;
|
||||
products: RelayToFlat<ProductListQuery["products"]>;
|
||||
onAdd: () => void;
|
||||
onExport: () => void;
|
||||
onColumnQueryChange: (query: string) => void;
|
||||
}
|
||||
|
@ -101,6 +102,7 @@ export const ProductListPage: React.FC<ProductListPageProps> = props => {
|
|||
settings,
|
||||
tabs,
|
||||
totalGridAttributes,
|
||||
onAdd,
|
||||
onAll,
|
||||
onColumnQueryChange,
|
||||
onExport,
|
||||
|
@ -229,7 +231,7 @@ export const ProductListPage: React.FC<ProductListPageProps> = props => {
|
|||
options={extensionCreateButtonItems}
|
||||
data-test-id="add-product"
|
||||
disabled={limitReached}
|
||||
href={productAddUrl()}
|
||||
onClick={onAdd}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="JFmOfi"
|
||||
|
|
|
@ -10,7 +10,11 @@ import MultiAutocompleteSelectField, {
|
|||
import SingleAutocompleteSelectField, {
|
||||
SingleAutocompleteChoiceType,
|
||||
} from "@saleor/components/SingleAutocompleteSelectField";
|
||||
import { ProductErrorFragment } from "@saleor/graphql";
|
||||
import {
|
||||
ProductChannelListingErrorFragment,
|
||||
ProductErrorCode,
|
||||
ProductErrorFragment,
|
||||
} from "@saleor/graphql";
|
||||
import { ChangeEvent } from "@saleor/hooks/useForm";
|
||||
import { commonMessages } from "@saleor/intl";
|
||||
import { makeStyles } from "@saleor/macaw-ui";
|
||||
|
@ -55,7 +59,7 @@ interface ProductOrganizationProps {
|
|||
productType?: ProductType;
|
||||
};
|
||||
disabled: boolean;
|
||||
errors: ProductErrorFragment[];
|
||||
errors: Array<ProductErrorFragment | ProductChannelListingErrorFragment>;
|
||||
productType?: ProductType;
|
||||
productTypeInputDisplayValue?: string;
|
||||
productTypes?: SingleAutocompleteChoiceType[];
|
||||
|
@ -98,9 +102,13 @@ const ProductOrganization: React.FC<ProductOrganizationProps> = props => {
|
|||
const intl = useIntl();
|
||||
|
||||
const formErrors = getFormErrors(
|
||||
["productType", "category", "collections"],
|
||||
["productType", "category", "collections", "isPublished"],
|
||||
errors,
|
||||
);
|
||||
const noCategoryError =
|
||||
formErrors.isPublished?.code === ProductErrorCode.PRODUCT_WITHOUT_CATEGORY
|
||||
? formErrors.isPublished
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Card className={classes.card}>
|
||||
|
@ -163,8 +171,11 @@ const ProductOrganization: React.FC<ProductOrganizationProps> = props => {
|
|||
<FormSpacer />
|
||||
<SingleAutocompleteSelectField
|
||||
displayValue={categoryInputDisplayValue}
|
||||
error={!!formErrors.category}
|
||||
helperText={getProductErrorMessage(formErrors.category, intl)}
|
||||
error={!!(formErrors.category || noCategoryError)}
|
||||
helperText={getProductErrorMessage(
|
||||
formErrors.category || noCategoryError,
|
||||
intl,
|
||||
)}
|
||||
disabled={disabled}
|
||||
label={intl.formatMessage({
|
||||
id: "ccXLVi",
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
import Decorator from "@saleor/storybook/Decorator";
|
||||
import { mapNodeToChoice } from "@saleor/utils/maps";
|
||||
import { storiesOf } from "@storybook/react";
|
||||
import React from "react";
|
||||
|
||||
import { productTypesList } from "../../fixtures";
|
||||
import ProductTypePickerDialog, {
|
||||
ProductTypePickerDialogProps,
|
||||
} from "./ProductTypePickerDialog";
|
||||
|
||||
const productTypes = mapNodeToChoice(productTypesList);
|
||||
|
||||
const props: ProductTypePickerDialogProps = {
|
||||
productTypes,
|
||||
confirmButtonState: "default",
|
||||
fetchProductTypes: () => undefined,
|
||||
fetchMoreProductTypes: {
|
||||
hasMore: false,
|
||||
loading: false,
|
||||
onFetchMore: () => undefined,
|
||||
},
|
||||
onClose: () => undefined,
|
||||
onConfirm: () => undefined,
|
||||
open: true,
|
||||
};
|
||||
|
||||
storiesOf("Views / Products / Product type dialog", module)
|
||||
.addDecorator(Decorator)
|
||||
.add("default", () => <ProductTypePickerDialog {...props} />);
|
|
@ -0,0 +1,70 @@
|
|||
import ActionDialog from "@saleor/components/ActionDialog";
|
||||
import SingleAutocompleteSelectField, {
|
||||
SingleAutocompleteChoiceType,
|
||||
} from "@saleor/components/SingleAutocompleteSelectField";
|
||||
import useModalDialogOpen from "@saleor/hooks/useModalDialogOpen";
|
||||
import useStateFromProps from "@saleor/hooks/useStateFromProps";
|
||||
import { ConfirmButtonTransitionState } from "@saleor/macaw-ui";
|
||||
import { FetchMoreProps } from "@saleor/types";
|
||||
import React from "react";
|
||||
import { useIntl } from "react-intl";
|
||||
|
||||
import { messages } from "./messages";
|
||||
|
||||
export interface ProductTypePickerDialogProps {
|
||||
confirmButtonState: ConfirmButtonTransitionState;
|
||||
open: boolean;
|
||||
productTypes?: SingleAutocompleteChoiceType[];
|
||||
fetchProductTypes: (data: string) => void;
|
||||
fetchMoreProductTypes: FetchMoreProps;
|
||||
onClose: () => void;
|
||||
onConfirm: (choice: string) => void;
|
||||
}
|
||||
|
||||
const ProductTypePickerDialog: React.FC<ProductTypePickerDialogProps> = ({
|
||||
confirmButtonState,
|
||||
open,
|
||||
productTypes,
|
||||
fetchProductTypes,
|
||||
fetchMoreProductTypes,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const [choice, setChoice] = useStateFromProps("");
|
||||
const productTypeDisplayValue = productTypes.find(
|
||||
productType => productType.value === choice,
|
||||
)?.label;
|
||||
|
||||
useModalDialogOpen(open, {
|
||||
onClose: () => {
|
||||
setChoice("");
|
||||
fetchProductTypes("");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<ActionDialog
|
||||
confirmButtonState={confirmButtonState}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
onConfirm={() => onConfirm(choice)}
|
||||
title={intl.formatMessage(messages.selectProductType)}
|
||||
disabled={!choice}
|
||||
>
|
||||
<SingleAutocompleteSelectField
|
||||
displayValue={productTypeDisplayValue}
|
||||
name="productType"
|
||||
label={intl.formatMessage(messages.productType)}
|
||||
choices={productTypes}
|
||||
value={choice}
|
||||
onChange={e => setChoice(e.target.value)}
|
||||
fetchChoices={fetchProductTypes}
|
||||
data-test-id="dialog-product-type"
|
||||
{...fetchMoreProductTypes}
|
||||
/>
|
||||
</ActionDialog>
|
||||
);
|
||||
};
|
||||
ProductTypePickerDialog.displayName = "ProductTypePickerDialog";
|
||||
export default ProductTypePickerDialog;
|
2
src/products/components/ProductTypePickerDialog/index.ts
Normal file
2
src/products/components/ProductTypePickerDialog/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
export * from "./ProductTypePickerDialog";
|
||||
export { default } from "./ProductTypePickerDialog";
|
14
src/products/components/ProductTypePickerDialog/messages.ts
Normal file
14
src/products/components/ProductTypePickerDialog/messages.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { defineMessages } from "react-intl";
|
||||
|
||||
export const messages = defineMessages({
|
||||
productType: {
|
||||
id: "w+3Q3e",
|
||||
defaultMessage: "Product type",
|
||||
description: "input label",
|
||||
},
|
||||
selectProductType: {
|
||||
id: "/yJJvI",
|
||||
defaultMessage: "Select a product type",
|
||||
description: "dialog header",
|
||||
},
|
||||
});
|
|
@ -462,7 +462,7 @@ export const ProductUpdatePage: React.FC<ProductUpdatePageProps> = ({
|
|||
collectionsInputDisplayValue={selectedCollections}
|
||||
data={data}
|
||||
disabled={disabled}
|
||||
errors={errors}
|
||||
errors={[...errors, ...channelsErrors]}
|
||||
fetchCategories={fetchCategories}
|
||||
fetchCollections={fetchCollections}
|
||||
fetchMoreCategories={fetchMoreCategories}
|
||||
|
|
|
@ -469,11 +469,12 @@ function useProductUpdateForm(
|
|||
return true;
|
||||
};
|
||||
|
||||
const isSaveDisabled = disabled || !isValid();
|
||||
const isSaveDisabled = disabled;
|
||||
const isSubmitDisabled = isSaveDisabled || !isValid();
|
||||
|
||||
useEffect(() => {
|
||||
setIsSubmitDisabled(isSaveDisabled);
|
||||
}, [isSaveDisabled]);
|
||||
setIsSubmitDisabled(isSubmitDisabled);
|
||||
}, [isSubmitDisabled]);
|
||||
|
||||
return {
|
||||
change: handleChange,
|
||||
|
|
|
@ -7,6 +7,7 @@ import {
|
|||
ProductVariantFragment,
|
||||
WeightUnitsEnum,
|
||||
} from "@saleor/graphql";
|
||||
import { ProductType } from "@saleor/sdk/dist/apollo/types";
|
||||
import { RelayToFlat } from "@saleor/types";
|
||||
import { warehouseList } from "@saleor/warehouses/fixtures";
|
||||
|
||||
|
@ -3531,3 +3532,24 @@ export const variantProductImages = (placeholderImage: string) =>
|
|||
variant(placeholderImage).product.media;
|
||||
export const variantSiblings = (placeholderImage: string) =>
|
||||
variant(placeholderImage).product.variants;
|
||||
|
||||
export const productTypesList: Array<Pick<
|
||||
ProductType,
|
||||
"id" | "name" | "hasVariants"
|
||||
>> = [
|
||||
{
|
||||
hasVariants: true,
|
||||
id: "UHJvZHVjdFR5cGU6Nw==",
|
||||
name: "Salt",
|
||||
},
|
||||
{
|
||||
hasVariants: true,
|
||||
id: "UHJvZHVjdFR5cGU6Nw==",
|
||||
name: "Sugar",
|
||||
},
|
||||
{
|
||||
hasVariants: true,
|
||||
id: "UHJvZHVjdFR5cGU6Nw==",
|
||||
name: "Mushroom",
|
||||
},
|
||||
];
|
||||
|
|
|
@ -23,7 +23,11 @@ export const productAddUrl = (params?: ProductCreateUrlQueryParams) =>
|
|||
productAddPath + "?" + stringifyQs(params);
|
||||
|
||||
export const productListPath = productSection;
|
||||
export type ProductListUrlDialog = "delete" | "export" | TabActionDialog;
|
||||
export type ProductListUrlDialog =
|
||||
| "delete"
|
||||
| "export"
|
||||
| "create-product"
|
||||
| TabActionDialog;
|
||||
export enum ProductListUrlFiltersEnum {
|
||||
priceFrom = "priceFrom",
|
||||
priceTo = "priceTo",
|
||||
|
@ -82,8 +86,12 @@ export type ProductUrlQueryParams = BulkAction &
|
|||
Dialog<ProductUrlDialog> &
|
||||
SingleAction;
|
||||
export type ProductCreateUrlDialog = "assign-attribute-value" | ChannelsAction;
|
||||
export interface ProductCreateUrlProductType {
|
||||
"product-type-id"?: string;
|
||||
}
|
||||
export type ProductCreateUrlQueryParams = Dialog<ProductCreateUrlDialog> &
|
||||
SingleAction;
|
||||
SingleAction &
|
||||
ProductCreateUrlProductType;
|
||||
export const productUrl = (id: string, params?: ProductUrlQueryParams) =>
|
||||
productPath(encodeURIComponent(id)) + "?" + stringifyQs(params);
|
||||
|
||||
|
|
|
@ -1,5 +1,36 @@
|
|||
import {
|
||||
ProductErrorCode,
|
||||
ProductErrorWithAttributesFragment,
|
||||
} from "@saleor/graphql";
|
||||
|
||||
import { ProductCreateData } from "../components/ProductCreatePage";
|
||||
|
||||
export const validatePrice = (price: string) =>
|
||||
price === "" || parseInt(price, 10) < 0;
|
||||
|
||||
export const validateCostPrice = (price: string) =>
|
||||
price !== "" && parseInt(price, 10) < 0;
|
||||
|
||||
const createEmptyRequiredError = (
|
||||
field: string,
|
||||
): ProductErrorWithAttributesFragment => ({
|
||||
__typename: "ProductError",
|
||||
code: ProductErrorCode.REQUIRED,
|
||||
field,
|
||||
message: null,
|
||||
attributes: [],
|
||||
});
|
||||
|
||||
export const validateProductCreateData = (data: ProductCreateData) => {
|
||||
let errors: ProductErrorWithAttributesFragment[] = [];
|
||||
|
||||
if (!data.productType) {
|
||||
errors = [...errors, createEmptyRequiredError("productType")];
|
||||
}
|
||||
|
||||
if (!data.name) {
|
||||
errors = [...errors, createEmptyRequiredError("name")];
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
|
|
@ -8,6 +8,8 @@ import {
|
|||
VALUES_PAGINATE_BY,
|
||||
} from "@saleor/config";
|
||||
import {
|
||||
ProductChannelListingErrorFragment,
|
||||
ProductErrorWithAttributesFragment,
|
||||
useFileUploadMutation,
|
||||
useProductChannelListingUpdateMutation,
|
||||
useProductCreateMutation,
|
||||
|
@ -24,6 +26,7 @@ import useChannels from "@saleor/hooks/useChannels";
|
|||
import useNavigator from "@saleor/hooks/useNavigator";
|
||||
import useNotifier from "@saleor/hooks/useNotifier";
|
||||
import useShop from "@saleor/hooks/useShop";
|
||||
import { getMutationErrors } from "@saleor/misc";
|
||||
import ProductCreatePage, {
|
||||
ProductCreateData,
|
||||
} from "@saleor/products/components/ProductCreatePage";
|
||||
|
@ -62,9 +65,15 @@ export const ProductCreateView: React.FC<ProductCreateProps> = ({ params }) => {
|
|||
const [productCreateComplete, setProductCreateComplete] = React.useState(
|
||||
false,
|
||||
);
|
||||
const [selectedProductTypeId, setSelectedProductTypeId] = React.useState<
|
||||
string
|
||||
>();
|
||||
const selectedProductTypeId = params["product-type-id"];
|
||||
|
||||
const handleSelectProductType = (productTypeId: string) =>
|
||||
navigate(
|
||||
productAddUrl({
|
||||
...params,
|
||||
"product-type-id": productTypeId,
|
||||
}),
|
||||
);
|
||||
|
||||
const [openModal, closeModal] = createDialogActionHandlers<
|
||||
ProductCreateUrlDialog,
|
||||
|
@ -282,6 +291,15 @@ export const ProductCreateView: React.FC<ProductCreateProps> = ({ params }) => {
|
|||
updateChannelsOpts.loading ||
|
||||
updateVariantChannelsOpts.loading;
|
||||
|
||||
const channelsErrors = [
|
||||
...getMutationErrors(updateVariantChannelsOpts),
|
||||
...getMutationErrors(updateChannelsOpts),
|
||||
] as ProductChannelListingErrorFragment[];
|
||||
const errors = [
|
||||
...getMutationErrors(productCreateOpts),
|
||||
...getMutationErrors(productVariantCreateOpts),
|
||||
] as ProductErrorWithAttributesFragment[];
|
||||
|
||||
return (
|
||||
<>
|
||||
<WindowTitle
|
||||
|
@ -318,14 +336,8 @@ export const ProductCreateView: React.FC<ProductCreateProps> = ({ params }) => {
|
|||
[]
|
||||
}
|
||||
loading={loading}
|
||||
channelsErrors={
|
||||
updateVariantChannelsOpts.data?.productVariantChannelListingUpdate
|
||||
?.errors
|
||||
}
|
||||
errors={[
|
||||
...(productCreateOpts.data?.productCreate.errors || []),
|
||||
...(productVariantCreateOpts.data?.productVariantCreate.errors || []),
|
||||
]}
|
||||
channelsErrors={channelsErrors}
|
||||
errors={errors}
|
||||
fetchCategories={searchCategory}
|
||||
fetchCollections={searchCollection}
|
||||
fetchProductTypes={searchProductTypes}
|
||||
|
@ -362,7 +374,7 @@ export const ProductCreateView: React.FC<ProductCreateProps> = ({ params }) => {
|
|||
fetchMoreAttributeValues={fetchMoreAttributeValues}
|
||||
onCloseDialog={() => navigate(productAddUrl())}
|
||||
selectedProductType={selectedProductType?.productType}
|
||||
onSelectProductType={id => setSelectedProductTypeId(id)}
|
||||
onSelectProductType={handleSelectProductType}
|
||||
onAttributeSelectBlur={searchAttributeReset}
|
||||
/>
|
||||
</>
|
||||
|
|
|
@ -45,7 +45,9 @@ import {
|
|||
getAttributeIdFromColumnValue,
|
||||
isAttributeColumnValue,
|
||||
} from "@saleor/products/components/ProductListPage/utils";
|
||||
import ProductTypePickerDialog from "@saleor/products/components/ProductTypePickerDialog";
|
||||
import {
|
||||
productAddUrl,
|
||||
productListUrl,
|
||||
ProductListUrlDialog,
|
||||
ProductListUrlQueryParams,
|
||||
|
@ -324,6 +326,20 @@ export const ProductList: React.FC<ProductListProps> = ({ params }) => {
|
|||
},
|
||||
});
|
||||
|
||||
const {
|
||||
loadMore: loadMoreDialogProductTypes,
|
||||
search: searchDialogProductTypes,
|
||||
result: searchDialogProductTypesOpts,
|
||||
} = useProductTypeSearch({
|
||||
variables: DEFAULT_INITIAL_SEARCH_DATA,
|
||||
});
|
||||
|
||||
const fetchMoreDialogProductTypes = {
|
||||
hasMore: searchDialogProductTypesOpts.data?.search?.pageInfo?.hasNextPage,
|
||||
loading: searchDialogProductTypesOpts.loading,
|
||||
onFetchMore: loadMoreDialogProductTypes,
|
||||
};
|
||||
|
||||
const filterOpts = getFilterOpts(
|
||||
params,
|
||||
(mapEdgesToItems(initialFilterAttributes?.attributes) || []).filter(
|
||||
|
@ -393,6 +409,7 @@ export const ProductList: React.FC<ProductListProps> = ({ params }) => {
|
|||
onColumnQueryChange={availableInGridAttributesOpts.search}
|
||||
onFetchMore={availableInGridAttributesOpts.loadMore}
|
||||
onUpdateListSettings={updateListSettings}
|
||||
onAdd={() => openModal("create-product")}
|
||||
onAll={resetFilters}
|
||||
toolbar={
|
||||
<IconButton
|
||||
|
@ -499,6 +516,23 @@ export const ProductList: React.FC<ProductListProps> = ({ params }) => {
|
|||
onSubmit={handleFilterTabDelete}
|
||||
tabName={maybe(() => tabs[currentTab - 1].name, "...")}
|
||||
/>
|
||||
<ProductTypePickerDialog
|
||||
confirmButtonState="success"
|
||||
open={params.action === "create-product"}
|
||||
productTypes={mapNodeToChoice(
|
||||
mapEdgesToItems(searchDialogProductTypesOpts?.data?.search),
|
||||
)}
|
||||
fetchProductTypes={searchDialogProductTypes}
|
||||
fetchMoreProductTypes={fetchMoreDialogProductTypes}
|
||||
onClose={closeModal}
|
||||
onConfirm={productTypeId =>
|
||||
navigate(
|
||||
productAddUrl({
|
||||
"product-type-id": productTypeId,
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</PaginatorContext.Provider>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -208242,20 +208242,18 @@ exports[`Storyshots Views / Products / Product list default 1`] = `
|
|||
data-test-id="add-product"
|
||||
role="group"
|
||||
>
|
||||
<a
|
||||
aria-disabled="false"
|
||||
<button
|
||||
class="MuiButtonBase-root-id MuiButton-root-id MuiButton-contained-id MuiButtonGroup-grouped-id MuiButtonGroup-groupedHorizontal-id MuiButtonGroup-groupedOutlined-id MuiButtonGroup-groupedOutlinedHorizontal-id MuiButtonGroup-groupedOutlined-id MuiButton-containedPrimary-id"
|
||||
href="/products/add"
|
||||
role="button"
|
||||
style="width:100%"
|
||||
tabindex="0"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="MuiButton-label-id"
|
||||
>
|
||||
Create Product
|
||||
</span>
|
||||
</a>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -210688,20 +210686,19 @@ exports[`Storyshots Views / Products / Product list limits reached 1`] = `
|
|||
data-test-id="add-product"
|
||||
role="group"
|
||||
>
|
||||
<a
|
||||
aria-disabled="true"
|
||||
<button
|
||||
class="MuiButtonBase-root-id MuiButton-root-id MuiButton-contained-id MuiButtonGroup-grouped-id MuiButtonGroup-groupedHorizontal-id MuiButtonGroup-groupedOutlined-id MuiButtonGroup-groupedOutlinedHorizontal-id MuiButtonGroup-groupedOutlined-id MuiButtonGroup-disabled-id MuiButton-containedPrimary-id MuiButton-disabled-id MuiButtonBase-disabled-id"
|
||||
href="/products/add"
|
||||
role="button"
|
||||
disabled=""
|
||||
style="width:100%"
|
||||
tabindex="-1"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="MuiButton-label-id"
|
||||
>
|
||||
Create Product
|
||||
</span>
|
||||
</a>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -213217,20 +213214,18 @@ exports[`Storyshots Views / Products / Product list loading 1`] = `
|
|||
data-test-id="add-product"
|
||||
role="group"
|
||||
>
|
||||
<a
|
||||
aria-disabled="false"
|
||||
<button
|
||||
class="MuiButtonBase-root-id MuiButton-root-id MuiButton-contained-id MuiButtonGroup-grouped-id MuiButtonGroup-groupedHorizontal-id MuiButtonGroup-groupedOutlined-id MuiButtonGroup-groupedOutlinedHorizontal-id MuiButtonGroup-groupedOutlined-id MuiButton-containedPrimary-id"
|
||||
href="/products/add"
|
||||
role="button"
|
||||
style="width:100%"
|
||||
tabindex="0"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="MuiButton-label-id"
|
||||
>
|
||||
Create Product
|
||||
</span>
|
||||
</a>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -213791,20 +213786,18 @@ exports[`Storyshots Views / Products / Product list no channels 1`] = `
|
|||
data-test-id="add-product"
|
||||
role="group"
|
||||
>
|
||||
<a
|
||||
aria-disabled="false"
|
||||
<button
|
||||
class="MuiButtonBase-root-id MuiButton-root-id MuiButton-contained-id MuiButtonGroup-grouped-id MuiButtonGroup-groupedHorizontal-id MuiButtonGroup-groupedOutlined-id MuiButtonGroup-groupedOutlinedHorizontal-id MuiButtonGroup-groupedOutlined-id MuiButton-containedPrimary-id"
|
||||
href="/products/add"
|
||||
role="button"
|
||||
style="width:100%"
|
||||
tabindex="0"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="MuiButton-label-id"
|
||||
>
|
||||
Create Product
|
||||
</span>
|
||||
</a>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -216237,20 +216230,18 @@ exports[`Storyshots Views / Products / Product list no data 1`] = `
|
|||
data-test-id="add-product"
|
||||
role="group"
|
||||
>
|
||||
<a
|
||||
aria-disabled="false"
|
||||
<button
|
||||
class="MuiButtonBase-root-id MuiButton-root-id MuiButton-contained-id MuiButtonGroup-grouped-id MuiButtonGroup-groupedHorizontal-id MuiButtonGroup-groupedOutlined-id MuiButtonGroup-groupedOutlinedHorizontal-id MuiButtonGroup-groupedOutlined-id MuiButton-containedPrimary-id"
|
||||
href="/products/add"
|
||||
role="button"
|
||||
style="width:100%"
|
||||
tabindex="0"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="MuiButton-label-id"
|
||||
>
|
||||
Create Product
|
||||
</span>
|
||||
</a>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -216649,20 +216640,18 @@ exports[`Storyshots Views / Products / Product list no limits 1`] = `
|
|||
data-test-id="add-product"
|
||||
role="group"
|
||||
>
|
||||
<a
|
||||
aria-disabled="false"
|
||||
<button
|
||||
class="MuiButtonBase-root-id MuiButton-root-id MuiButton-contained-id MuiButtonGroup-grouped-id MuiButtonGroup-groupedHorizontal-id MuiButtonGroup-groupedOutlined-id MuiButtonGroup-groupedOutlinedHorizontal-id MuiButtonGroup-groupedOutlined-id MuiButton-containedPrimary-id"
|
||||
href="/products/add"
|
||||
role="button"
|
||||
style="width:100%"
|
||||
tabindex="0"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="MuiButton-label-id"
|
||||
>
|
||||
Create Product
|
||||
</span>
|
||||
</a>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -219095,20 +219084,18 @@ exports[`Storyshots Views / Products / Product list with data 1`] = `
|
|||
data-test-id="add-product"
|
||||
role="group"
|
||||
>
|
||||
<a
|
||||
aria-disabled="false"
|
||||
<button
|
||||
class="MuiButtonBase-root-id MuiButton-root-id MuiButton-contained-id MuiButtonGroup-grouped-id MuiButtonGroup-groupedHorizontal-id MuiButtonGroup-groupedOutlined-id MuiButtonGroup-groupedOutlinedHorizontal-id MuiButtonGroup-groupedOutlined-id MuiButton-containedPrimary-id"
|
||||
href="/products/add"
|
||||
role="button"
|
||||
style="width:100%"
|
||||
tabindex="0"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="MuiButton-label-id"
|
||||
>
|
||||
Create Product
|
||||
</span>
|
||||
</a>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -221439,6 +221426,12 @@ exports[`Storyshots Views / Products / Product list with data 1`] = `
|
|||
</div>
|
||||
`;
|
||||
|
||||
exports[`Storyshots Views / Products / Product type dialog default 1`] = `
|
||||
<div
|
||||
style="padding:24px"
|
||||
/>
|
||||
`;
|
||||
|
||||
exports[`Storyshots Views / Products / Product variant details attribute errors 1`] = `
|
||||
<div
|
||||
style="padding:24px"
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import {
|
||||
BulkProductErrorFragment,
|
||||
CollectionErrorFragment,
|
||||
ProductChannelListingErrorFragment,
|
||||
ProductErrorCode,
|
||||
ProductErrorFragment,
|
||||
} from "@saleor/graphql";
|
||||
|
@ -74,7 +75,12 @@ const messages = defineMessages({
|
|||
|
||||
function getProductErrorMessage(
|
||||
err:
|
||||
| Omit<ProductErrorFragment | CollectionErrorFragment, "__typename">
|
||||
| Omit<
|
||||
| ProductErrorFragment
|
||||
| CollectionErrorFragment
|
||||
| ProductChannelListingErrorFragment,
|
||||
"__typename"
|
||||
>
|
||||
| undefined,
|
||||
intl: IntlShape,
|
||||
): string {
|
||||
|
|
Loading…
Reference in a new issue