diff --git a/src/hooks/useForm.ts b/src/hooks/useForm.ts index 752030949..2f3d54282 100644 --- a/src/hooks/useForm.ts +++ b/src/hooks/useForm.ts @@ -51,7 +51,7 @@ function handleRefresh( function useForm( initial: T, - onSubmit: (data: T) => void + onSubmit?: (data: T) => void ): UseFormResult { const [hasChanged, setChanged] = useState(false); const [data, setData] = useStateFromProps(initial, { @@ -107,7 +107,9 @@ function useForm( } function submit() { - return onSubmit(data); + if (typeof onSubmit === "function") { + onSubmit(data); + } } function triggerChange() { diff --git a/src/products/components/ProductCreatePage/ProductCreatePage.tsx b/src/products/components/ProductCreatePage/ProductCreatePage.tsx index b7ace4373..2b5f536ba 100644 --- a/src/products/components/ProductCreatePage/ProductCreatePage.tsx +++ b/src/products/components/ProductCreatePage/ProductCreatePage.tsx @@ -3,9 +3,8 @@ import AvailabilityCard from "@saleor/components/AvailabilityCard"; import CardSpacer from "@saleor/components/CardSpacer"; import { ConfirmButtonTransitionState } from "@saleor/components/ConfirmButton"; import Container from "@saleor/components/Container"; -import Form from "@saleor/components/Form"; import Grid from "@saleor/components/Grid"; -import Metadata, { MetadataFormData } from "@saleor/components/Metadata"; +import Metadata from "@saleor/components/Metadata"; import { MultiAutocompleteChoiceType } from "@saleor/components/MultiAutocompleteSelectField"; import PageHeader from "@saleor/components/PageHeader"; import SaveButtonBar from "@saleor/components/SaveButtonBar"; @@ -13,70 +12,29 @@ import SeoForm from "@saleor/components/SeoForm"; import { ProductErrorWithAttributesFragment } from "@saleor/fragments/types/ProductErrorWithAttributesFragment"; import { TaxTypeFragment } from "@saleor/fragments/types/TaxTypeFragment"; import useDateLocalize from "@saleor/hooks/useDateLocalize"; -import useFormset from "@saleor/hooks/useFormset"; import useStateFromProps from "@saleor/hooks/useStateFromProps"; import { sectionNames } from "@saleor/intl"; -import { - getAttributeInputFromProductType, - getChoices, - ProductType -} from "@saleor/products/utils/data"; +import { getChoices } from "@saleor/products/utils/data"; import { SearchCategories_search_edges_node } from "@saleor/searches/types/SearchCategories"; import { SearchCollections_search_edges_node } from "@saleor/searches/types/SearchCollections"; -import { SearchProductTypes_search_edges_node_productAttributes } from "@saleor/searches/types/SearchProductTypes"; +import { SearchProductTypes_search_edges_node } from "@saleor/searches/types/SearchProductTypes"; import { SearchWarehouses_search_edges_node } from "@saleor/searches/types/SearchWarehouses"; -import createMultiAutocompleteSelectHandler from "@saleor/utils/handlers/multiAutocompleteSelectChangeHandler"; -import createSingleAutocompleteSelectHandler from "@saleor/utils/handlers/singleAutocompleteSelectChangeHandler"; -import useMetadataChangeTrigger from "@saleor/utils/metadata/useMetadataChangeTrigger"; -import { ContentState, convertToRaw, RawDraftContentState } from "draft-js"; +import { ContentState, convertToRaw } from "draft-js"; import React from "react"; import { useIntl } from "react-intl"; import { FetchMoreProps } from "../../../types"; -import { - createAttributeChangeHandler, - createAttributeMultiChangeHandler, - createProductTypeSelectHandler -} from "../../utils/handlers"; -import ProductAttributes, { - ProductAttributeInput, - ProductAttributeInputData -} from "../ProductAttributes"; +import ProductAttributes from "../ProductAttributes"; import ProductDetailsForm from "../ProductDetailsForm"; import ProductOrganization from "../ProductOrganization"; import ProductPricing from "../ProductPricing"; import ProductShipping from "../ProductShipping/ProductShipping"; -import ProductStocks, { ProductStockInput } from "../ProductStocks"; +import ProductStocks from "../ProductStocks"; import ProductTaxes from "../ProductTaxes"; - -interface FormData extends MetadataFormData { - availableForPurchase: string; - basePrice: number; - category: string; - changeTaxCode: boolean; - chargeTaxes: boolean; - collections: string[]; - description: RawDraftContentState; - isAvailable: boolean; - isAvailableForPurchase: boolean; - isPublished: boolean; - name: string; - slug: string; - productType: string; - publicationDate: string; - seoDescription: string; - seoTitle: string; - sku: string; - stockQuantity: number; - taxCode: string; - trackInventory: boolean; - visibleInListings: boolean; - weight: string; -} -export interface ProductCreatePageSubmitData extends FormData { - attributes: ProductAttributeInput[]; - stocks: ProductStockInput[]; -} +import ProductCreateForm, { + ProductCreateData, + ProductCreateFormData +} from "./form"; interface ProductCreatePageProps { errors: ProductErrorWithAttributesFragment[]; @@ -87,13 +45,8 @@ interface ProductCreatePageProps { fetchMoreCategories: FetchMoreProps; fetchMoreCollections: FetchMoreProps; fetchMoreProductTypes: FetchMoreProps; - initial?: Partial; - productTypes?: Array<{ - id: string; - name: string; - hasVariants: boolean; - productAttributes: SearchProductTypes_search_edges_node_productAttributes[]; - }>; + initial?: Partial; + productTypes?: SearchProductTypes_search_edges_node[]; header: string; saveButtonBarState: ConfirmButtonTransitionState; weightUnit: string; @@ -104,7 +57,7 @@ interface ProductCreatePageProps { fetchProductTypes: (data: string) => void; onWarehouseConfigure: () => void; onBack?(); - onSubmit?(data: ProductCreatePageSubmitData); + onSubmit?(data: ProductCreateData); } export const ProductCreatePage: React.FC = ({ @@ -133,65 +86,12 @@ export const ProductCreatePage: React.FC = ({ const intl = useIntl(); const localizeDate = useDateLocalize(); - const initialProductType = productTypeChoiceList?.find( - productType => initial?.productType === productType.id - ); - - // Form values - const { - change: changeAttributeData, - data: attributes, - set: setAttributeData - } = useFormset( - initial?.productType - ? getAttributeInputFromProductType(initialProductType) - : [] - ); - const { - add: addStock, - change: changeStockData, - data: stocks, - remove: removeStock - } = useFormset([]); - // Ensures that it will not change after component rerenders, because it // generates different block keys and it causes editor to lose its content. const initialDescription = React.useRef( convertToRaw(ContentState.createFromText("")) ); - const { - makeChangeHandler: makeMetadataChangeHandler - } = useMetadataChangeTrigger(); - - const initialData: FormData = { - ...(initial || {}), - availableForPurchase: "", - basePrice: 0, - category: "", - changeTaxCode: false, - chargeTaxes: false, - collections: [], - description: {} as any, - isAvailable: false, - isAvailableForPurchase: false, - isPublished: false, - metadata: [], - name: "", - privateMetadata: [], - productType: "", - publicationDate: "", - seoDescription: "", - seoTitle: "", - sku: null, - slug: "", - stockQuantity: null, - taxCode: null, - trackInventory: false, - visibleInListings: false, - weight: "" - }; - // Display values const [selectedCategory, setSelectedCategory] = useStateFromProps( initial?.category || "" @@ -201,9 +101,6 @@ export const ProductCreatePage: React.FC = ({ MultiAutocompleteChoiceType[] >([]); - const [productType, setProductType] = useStateFromProps( - initialProductType || null - ); const [selectedTaxType, setSelectedTaxType] = useStateFromProps( initial?.taxCode || null ); @@ -217,214 +114,166 @@ export const ProductCreatePage: React.FC = ({ value: taxType.taxCode })) || []; - const handleSubmit = (data: FormData) => - onSubmit({ - ...data, - attributes, - stocks - }); - return ( -
- {({ change, data, hasChanged, submit, triggerChange, toggleValue }) => { - const handleCollectionSelect = createMultiAutocompleteSelectHandler( - toggleValue, - setSelectedCollections, - selectedCollections, - collections - ); - const handleCategorySelect = createSingleAutocompleteSelectHandler( - change, - setSelectedCategory, - categories - ); - const handleAttributeChange = createAttributeChangeHandler( - changeAttributeData, - triggerChange - ); - const handleAttributeMultiChange = createAttributeMultiChangeHandler( - changeAttributeData, - attributes, - triggerChange - ); - - const handleProductTypeSelect = createProductTypeSelectHandler( - change, - setAttributeData, - setProductType, - productTypeChoiceList - ); - const handleTaxTypeSelect = createSingleAutocompleteSelectHandler( - change, - setSelectedTaxType, - taxTypeChoices - ); - - const changeMetadata = makeMetadataChangeHandler(change); - - return ( - - - {intl.formatMessage(sectionNames.products)} - - - -
- + {({ change, data, handlers, hasChanged, submit }) => ( + + + {intl.formatMessage(sectionNames.products)} + + + +
+ + + {data.attributes.length > 0 && ( + - - {attributes.length > 0 && ( - + {!data.productType?.hasVariants && ( + <> + - )} - - {!!productType && !productType.hasVariants && ( - <> - - - - { - triggerChange(); - changeStockData(id, value); - }} - onWarehouseStockAdd={id => { - triggerChange(); - addStock({ - data: null, - id, - label: warehouses.find( - warehouse => warehouse.id === id - ).name, - value: "0" - }); - }} - onWarehouseStockDelete={id => { - triggerChange(); - removeStock(id); - }} - onWarehouseConfigure={onWarehouseConfigure} - /> - - - )} - - - -
-
- - - - - -
-
- -
- ); - }} - + + + + + + )} + + + +
+
+ + + + + +
+
+ +
+ )} + ); }; ProductCreatePage.displayName = "ProductCreatePage"; diff --git a/src/products/components/ProductCreatePage/form.tsx b/src/products/components/ProductCreatePage/form.tsx new file mode 100644 index 000000000..f7e100a16 --- /dev/null +++ b/src/products/components/ProductCreatePage/form.tsx @@ -0,0 +1,248 @@ +import { MetadataFormData } from "@saleor/components/Metadata"; +import { MultiAutocompleteChoiceType } from "@saleor/components/MultiAutocompleteSelectField"; +import { SingleAutocompleteChoiceType } from "@saleor/components/SingleAutocompleteSelectField"; +import useForm, { FormChange } from "@saleor/hooks/useForm"; +import useFormset, { FormsetChange } from "@saleor/hooks/useFormset"; +import useStateFromProps from "@saleor/hooks/useStateFromProps"; +import { + getAttributeInputFromProductType, + ProductType +} from "@saleor/products/utils/data"; +import { + createAttributeChangeHandler, + createAttributeMultiChangeHandler, + createProductTypeSelectHandler +} from "@saleor/products/utils/handlers"; +import { SearchWarehouses_search_edges_node } from "@saleor/searches/types/SearchWarehouses"; +import createMultiAutocompleteSelectHandler from "@saleor/utils/handlers/multiAutocompleteSelectChangeHandler"; +import createSingleAutocompleteSelectHandler from "@saleor/utils/handlers/singleAutocompleteSelectChangeHandler"; +import useMetadataChangeTrigger from "@saleor/utils/metadata/useMetadataChangeTrigger"; +import { RawDraftContentState } from "draft-js"; +import React from "react"; + +import { SearchProductTypes_search_edges_node } from "../../../searches/types/SearchProductTypes"; +import { + ProductAttributeInput, + ProductAttributeInputData +} from "../ProductAttributes"; +import { ProductStockInput } from "../ProductStocks"; + +export interface ProductCreateFormData extends MetadataFormData { + availableForPurchase: string; + basePrice: number; + category: string; + changeTaxCode: boolean; + chargeTaxes: boolean; + collections: string[]; + description: RawDraftContentState; + isAvailable: boolean; + isAvailableForPurchase: boolean; + isPublished: boolean; + name: string; + productType: ProductType; + publicationDate: string; + seoDescription: string; + seoTitle: string; + sku: string; + slug: string; + stockQuantity: number; + taxCode: string; + trackInventory: boolean; + visibleInListings: boolean; + weight: string; +} +export interface ProductCreateData extends ProductCreateFormData { + attributes: ProductAttributeInput[]; + stocks: ProductStockInput[]; +} + +export interface UseProductCreateFormResult { + change: FormChange; + data: ProductCreateData; + handlers: Record< + | "changeMetadata" + | "selectCategory" + | "selectCollection" + | "selectProductType" + | "selectTaxRate", + FormChange + > & + Record< + "changeStock" | "selectAttribute" | "selectAttributeMultiple", + FormsetChange + > & + Record<"addStock" | "deleteStock", (id: string) => void>; + hasChanged: boolean; + submit: () => Promise; +} + +export interface UseProductCreateFormOpts + extends Record< + "categories" | "collections" | "taxTypes", + SingleAutocompleteChoiceType[] + > { + setSelectedCategory: React.Dispatch>; + setSelectedCollections: React.Dispatch< + React.SetStateAction + >; + setSelectedTaxType: React.Dispatch>; + selectedCollections: MultiAutocompleteChoiceType[]; + productTypes: SearchProductTypes_search_edges_node[]; + warehouses: SearchWarehouses_search_edges_node[]; +} + +export interface ProductCreateFormProps extends UseProductCreateFormOpts { + children: (props: UseProductCreateFormResult) => React.ReactNode; + initial?: Partial; + onSubmit: (data: ProductCreateData) => Promise; +} + +const defaultInitialFormData: ProductCreateFormData & + Record<"productType", string> = { + availableForPurchase: "", + basePrice: 0, + category: "", + changeTaxCode: false, + chargeTaxes: false, + collections: [], + description: {} as any, + isAvailable: false, + isAvailableForPurchase: false, + isPublished: false, + metadata: [], + name: "", + privateMetadata: [], + productType: null, + publicationDate: "", + seoDescription: "", + seoTitle: "", + sku: null, + slug: "", + stockQuantity: null, + taxCode: null, + trackInventory: false, + visibleInListings: false, + weight: "" +}; + +function useProductCreateForm( + initial: Partial, + onSubmit: (data: ProductCreateData) => Promise, + opts: UseProductCreateFormOpts +): UseProductCreateFormResult { + const initialProductType = + opts.productTypes?.find( + productType => initial?.productType?.id === productType.id + ) || null; + + const [changed, setChanged] = React.useState(false); + const triggerChange = () => setChanged(true); + + const form = useForm({ + ...initial, + ...defaultInitialFormData + }); + const attributes = useFormset( + initial?.productType + ? getAttributeInputFromProductType(initialProductType) + : [] + ); + const stocks = useFormset([]); + const [productType, setProductType] = useStateFromProps( + initialProductType || null + ); + + const { + makeChangeHandler: makeMetadataChangeHandler + } = useMetadataChangeTrigger(); + const handleCollectionSelect = createMultiAutocompleteSelectHandler( + form.toggleValue, + opts.setSelectedCollections, + opts.selectedCollections, + opts.collections + ); + const handleCategorySelect = createSingleAutocompleteSelectHandler( + form.change, + opts.setSelectedCategory, + opts.categories + ); + const handleAttributeChange = createAttributeChangeHandler( + attributes.change, + triggerChange + ); + const handleAttributeMultiChange = createAttributeMultiChangeHandler( + attributes.change, + attributes.data, + triggerChange + ); + const handleProductTypeSelect = createProductTypeSelectHandler( + attributes.set, + setProductType, + opts.productTypes, + triggerChange + ); + const handleStockChange: FormsetChange = (id, value) => { + triggerChange(); + stocks.change(id, value); + }; + const handleStockAdd = (id: string) => { + triggerChange(); + stocks.add({ + data: null, + id, + label: opts.warehouses.find(warehouse => warehouse.id === id).name, + value: "0" + }); + }; + const handleStockDelete = (id: string) => { + triggerChange(); + stocks.remove(id); + }; + const handleTaxTypeSelect = createSingleAutocompleteSelectHandler( + form.change, + opts.setSelectedTaxType, + opts.taxTypes + ); + const changeMetadata = makeMetadataChangeHandler(form.change); + + const data: ProductCreateData = { + ...form.data, + attributes: attributes.data, + productType, + stocks: stocks.data + }; + const submit = () => onSubmit(data); + + return { + change: form.change, + data, + handlers: { + addStock: handleStockAdd, + changeMetadata, + changeStock: handleStockChange, + deleteStock: handleStockDelete, + selectAttribute: handleAttributeChange, + selectAttributeMultiple: handleAttributeMultiChange, + selectCategory: handleCategorySelect, + selectCollection: handleCollectionSelect, + selectProductType: handleProductTypeSelect, + selectTaxRate: handleTaxTypeSelect + }, + hasChanged: changed, + submit + }; +} + +const ProductCreateForm: React.FC = ({ + children, + initial, + onSubmit, + ...rest +}) => { + const props = useProductCreateForm(initial || {}, onSubmit, rest); + + return
{children(props)}
; +}; + +ProductCreateForm.displayName = "ProductCreateForm"; +export default ProductCreateForm; diff --git a/src/products/components/ProductOrganization/ProductOrganization.tsx b/src/products/components/ProductOrganization/ProductOrganization.tsx index 123ebeb7c..87897f2d8 100644 --- a/src/products/components/ProductOrganization/ProductOrganization.tsx +++ b/src/products/components/ProductOrganization/ProductOrganization.tsx @@ -51,7 +51,7 @@ interface ProductOrganizationProps { data: { category: string; collections: string[]; - productType?: string; + productType?: ProductType; }; disabled: boolean; errors: ProductErrorFragment[]; @@ -121,7 +121,7 @@ const ProductOrganization: React.FC = props => { defaultMessage: "Product Type" })} choices={productTypes} - value={data.productType} + value={data.productType?.id} onChange={onProductTypeChange} fetchChoices={fetchProductTypes} data-test="product-type" diff --git a/src/products/utils/handlers.ts b/src/products/utils/handlers.ts index 917b0c1e5..d73162759 100644 --- a/src/products/utils/handlers.ts +++ b/src/products/utils/handlers.ts @@ -37,19 +37,18 @@ export function createAttributeMultiChangeHandler( } export function createProductTypeSelectHandler( - change: FormChange, setAttributes: (data: FormsetData) => void, setProductType: (productType: ProductType) => void, - productTypeChoiceList: ProductType[] + productTypeChoiceList: ProductType[], + triggerChange: () => void ): FormChange { return (event: React.ChangeEvent) => { const id = event.target.value; const selectedProductType = productTypeChoiceList.find( productType => productType.id === id ); + triggerChange(); setProductType(selectedProductType); - change(event); - setAttributes(getAttributeInputFromProductType(selectedProductType)); }; } diff --git a/src/products/views/ProductCreate.tsx b/src/products/views/ProductCreate.tsx index b5df23fcd..ed3918d8c 100644 --- a/src/products/views/ProductCreate.tsx +++ b/src/products/views/ProductCreate.tsx @@ -19,9 +19,8 @@ import React from "react"; import { useIntl } from "react-intl"; import { decimal, weight } from "../../misc"; -import ProductCreatePage, { - ProductCreatePageSubmitData -} from "../components/ProductCreatePage"; +import ProductCreatePage from "../components/ProductCreatePage"; +import { ProductCreateData } from "../components/ProductCreatePage/form"; import { useProductCreateMutation, useProductSetAvailabilityForPurchase @@ -91,7 +90,7 @@ export const ProductCreateView: React.FC = () => { } }); - const handleCreate = async (formData: ProductCreatePageSubmitData) => { + const handleCreate = async (formData: ProductCreateData) => { const result = await productCreate({ variables: { input: { @@ -106,7 +105,7 @@ export const ProductCreateView: React.FC = () => { descriptionJson: JSON.stringify(formData.description), isPublished: formData.isPublished, name: formData.name, - productType: formData.productType, + productType: formData.productType?.id, publicationDate: formData.publicationDate !== "" ? formData.publicationDate : null, seo: {