diff --git a/.changeset/beige-moose-greet.md b/.changeset/beige-moose-greet.md new file mode 100644 index 00000000000..0c96fc86c31 --- /dev/null +++ b/.changeset/beige-moose-greet.md @@ -0,0 +1,5 @@ +--- +"saleor-dashboard": patch +--- + +Product edition no longer change the others work when changing different fields simultaneously. This means UI sends only form fields that were changed. diff --git a/src/hooks/useForm.ts b/src/hooks/useForm/index.ts similarity index 92% rename from src/hooks/useForm.ts rename to src/hooks/useForm/index.ts index 758517fb0ca..e8ad76d2378 100644 --- a/src/hooks/useForm.ts +++ b/src/hooks/useForm/index.ts @@ -1,4 +1,3 @@ -// @ts-strict-ignore import { CheckIfSaveIsDisabledFnType, FormId, @@ -11,7 +10,9 @@ import isEqual from "lodash/isEqual"; import omit from "lodash/omit"; import React, { useEffect, useState } from "react"; -import useStateFromProps from "./useStateFromProps"; +import useStateFromProps from "./../useStateFromProps"; +import { FormData } from "./types"; +import { useChangedData } from "./useChangedData"; export interface ChangeEvent { target: { @@ -51,6 +52,8 @@ export interface UseFormResult setError: (name: keyof TData, error: string | React.ReactNode) => void; clearErrors: (name?: keyof TData | Array) => void; setIsSubmitDisabled: (value: boolean) => void; + cleanChanged: () => void; + changedData: TData; } export interface CommonUseFormResult { @@ -65,8 +68,6 @@ export interface CommonUseFormResultWithHandlers handlers: THandlers; } -type FormData = Record; - function merge(prevData: T, prevState: T, data: T): T { return Object.keys(prevState).reduce( (acc, key) => { @@ -97,6 +98,8 @@ function useForm( mergeFunc: mergeData ? merge : undefined, }); + const { add: addChanged, clean: cleanChanged, data: changed } = useChangedData(data); + const isSaveDisabled = () => { if (checkIfSaveIsDisabled) { return checkIfSaveIsDisabled(data); @@ -145,7 +148,7 @@ function useForm( if (Array.isArray(field)) { handleSetChanged(true); - + addChanged(name); setData({ ...data, [name]: toggle(value, field, isEqual), @@ -163,6 +166,7 @@ function useForm( if (Array.isArray(field)) { handleSetChanged(true); + addChanged(name); setData({ ...data, @@ -186,6 +190,8 @@ function useForm( if (!(name in data)) { console.error(`Unknown form field: ${name}`); } else { + addChanged(name); + if (data[name] !== value) { handleSetChanged(true); } @@ -213,6 +219,8 @@ function useForm( return result; } + + return []; } const setError = (field: keyof T, error: string | React.ReactNode) => @@ -229,6 +237,8 @@ function useForm( }; return { + changedData: changed, + cleanChanged, formId, setError, errors, diff --git a/src/hooks/useForm/types.ts b/src/hooks/useForm/types.ts new file mode 100644 index 00000000000..164219b0806 --- /dev/null +++ b/src/hooks/useForm/types.ts @@ -0,0 +1 @@ +export type FormData = Record; diff --git a/src/hooks/useForm/useChangedData.test.tsx b/src/hooks/useForm/useChangedData.test.tsx new file mode 100644 index 00000000000..8dbebc72bdd --- /dev/null +++ b/src/hooks/useForm/useChangedData.test.tsx @@ -0,0 +1,46 @@ +import { renderHook } from "@testing-library/react-hooks"; + +import { useChangedData } from "./useChangedData"; + +describe("useForm / useChangedData", () => { + it("returns all changed fields", () => { + // Arrange + const { result } = renderHook(() => + useChangedData({ + "field-1": "value-1", + "field-2": "value-2", + "field-3": "value-3", + "field-4": "value-4", + }), + ); + + // Act + result.current.add("field-1"); + result.current.add("field-2"); + + // Assert + expect(result.current.data).toEqual({ + "field-1": "value-1", + "field-2": "value-2", + }); + }); + + it("clears changed fields", () => { + // Arrange + const { result } = renderHook(() => + useChangedData({ + "field-1": "value-1", + "field-2": "value-2", + "field-3": "value-3", + "field-4": "value-4", + }), + ); + + // Act + result.current.add("field-1"); + result.current.clean(); + + // Assert + expect(result.current.data).toEqual({}); + }); +}); diff --git a/src/hooks/useForm/useChangedData.ts b/src/hooks/useForm/useChangedData.ts new file mode 100644 index 00000000000..c7c8d9ca293 --- /dev/null +++ b/src/hooks/useForm/useChangedData.ts @@ -0,0 +1,27 @@ +import { useState } from "react"; + +import { FormData } from "./types"; + +export const useChangedData = (formData: T) => { + const [dirtyFields, setDirtyFields] = useState([]); + + const add = (name: string) => { + setDirtyFields(fields => { + return Array.from(new Set(fields.concat(name))); + }); + }; + + const clean = () => { + setDirtyFields([]); + }; + + const data = Object.entries(formData) + .filter(([key]) => dirtyFields.includes(key)) + .reduce((p, [key, value]) => ({ ...p, [key]: value }), {} as T); + + return { + add, + clean, + data, + }; +}; diff --git a/src/hooks/useHandleFormSubmit.ts b/src/hooks/useHandleFormSubmit.ts index 73b451f8299..6368652e731 100644 --- a/src/hooks/useHandleFormSubmit.ts +++ b/src/hooks/useHandleFormSubmit.ts @@ -1,4 +1,3 @@ -// @ts-strict-ignore import { FormId, useExitFormDialog } from "@dashboard/components/Form"; import { MessageContext } from "@dashboard/components/messages"; import { SubmitPromise } from "@dashboard/hooks/useForm"; @@ -6,7 +5,7 @@ import { useContext } from "react"; interface UseHandleFormSubmitProps { formId?: FormId; - onSubmit: (data: TData) => SubmitPromise | void; + onSubmit?: (data: TData) => SubmitPromise | void; } function useHandleFormSubmit({ @@ -25,7 +24,7 @@ function useHandleFormSubmit({ messageContext.clearErrorNotifications(); } - const result = onSubmit(data); + const result = onSubmit ? onSubmit(data) : null; if (!result) { return []; diff --git a/src/products/components/ProductUpdatePage/form.test.ts b/src/products/components/ProductUpdatePage/form.test.ts index 70b464ba93d..18e7157829e 100644 --- a/src/products/components/ProductUpdatePage/form.test.ts +++ b/src/products/components/ProductUpdatePage/form.test.ts @@ -19,30 +19,13 @@ jest.mock("@dashboard/utils/richText/useRichText", () => { const baseData = { attributes: [], attributesWithNewFileValue: [], - category: "", channels: { removeChannels: [], updateChannels: [], }, - collections: [], description: undefined, - globalSoldUnits: 0, - globalThreshold: "", - hasPreorderEndDate: false, - isAvailable: false, - isPreorder: false, metadata: undefined, - name: "", - preorderEndDateTime: undefined, privateMetadata: undefined, - rating: null, - seoDescription: "", - seoTitle: "", - sku: "", - slug: "", - taxClassId: undefined, - trackInventory: false, - weight: "", }; describe("useProductUpdateForm", () => { @@ -98,4 +81,55 @@ describe("useProductUpdateForm", () => { }, }); }); + + it("submits form with the only data that was modified", async () => { + // Arrange + const mockOnSubmit = jest.fn(); + const { result } = renderHook(() => + useProductUpdateForm( + { variants: [], channelListings: [] } as unknown as ProductFragment, + mockOnSubmit, + false, + jest.fn(), + {} as UseProductUpdateFormOpts, + ), + ); + + // Act + await act(() => { + result.current.change({ target: { name: "slug", value: "test-slug-1" } }); + result.current.change({ target: { name: "category", value: "test-category" } }); + result.current.change({ target: { name: "collections", value: ["collection-1"] } }); + result.current.change({ target: { name: "rating", value: 4 } }); + result.current.change({ target: { name: "seoTitle", value: "seo-title-1" } }); + result.current.change({ target: { name: "seoDescription", value: "seo-desc-1" } }); + }); + + await act(async () => { + await result.current.submit(); + }); + + expect(mockOnSubmit).toHaveBeenCalledWith({ + attributes: [], + attributesWithNewFileValue: [], + channels: { + removeChannels: [], + updateChannels: [], + }, + description: undefined, + metadata: undefined, + privateMetadata: undefined, + slug: "test-slug-1", + category: "test-category", + collections: ["collection-1"], + variants: { + added: [], + removed: [], + updates: [], + }, + rating: 4, + seoTitle: "seo-title-1", + seoDescription: "seo-desc-1", + }); + }); }); diff --git a/src/products/components/ProductUpdatePage/form.tsx b/src/products/components/ProductUpdatePage/form.tsx index 08a85c2810e..d232db7942d 100644 --- a/src/products/components/ProductUpdatePage/form.tsx +++ b/src/products/components/ProductUpdatePage/form.tsx @@ -67,13 +67,13 @@ export function useProductUpdateForm( confirmLeave: true, formId: PRODUCT_UPDATE_FORM_ID, }); - const { handleChange, triggerChange, toggleValues, data: formData, setIsSubmitDisabled, + cleanChanged, } = form; const { locale } = useLocale(); @@ -194,7 +194,7 @@ export function useProductUpdateForm( }; const getSubmitData = async (): Promise => ({ - ...data, + ...form.changedData, ...getMetadata(data, isMetadataModified, isPrivateMetadataModified), attributes: mergeAttributes( attributes.data, @@ -210,7 +210,7 @@ export function useProductUpdateForm( touchedChannels.current.includes(listing.channelId), ), }, - description: await richText.getValue(), + description: richText.isDirty ? await richText.getValue() : undefined, variants: variants.current, }); @@ -231,6 +231,8 @@ export function useProductUpdateForm( const submit = useCallback(async () => { const result = await handleFormSubmit(await getSubmitData()); + + cleanChanged(); await refetch(); datagrid.setAdded(prevAdded => diff --git a/src/products/views/ProductUpdate/handlers/utils.ts b/src/products/views/ProductUpdate/handlers/utils.ts index 3678d1cdf30..1bb6d293268 100644 --- a/src/products/views/ProductUpdate/handlers/utils.ts +++ b/src/products/views/ProductUpdate/handlers/utils.ts @@ -3,13 +3,13 @@ import { FetchResult } from "@apollo/client"; import { getAttributesAfterFileAttributesUpdate } from "@dashboard/attributes/utils/data"; import { prepareAttributesInput } from "@dashboard/attributes/utils/handlers"; import { DatagridChangeOpts } from "@dashboard/components/Datagrid/hooks/useDatagridChange"; -import { VALUES_PAGINATE_BY } from "@dashboard/config"; import { FileUploadMutation, ProductChannelListingAddInput, ProductChannelListingUpdateInput, ProductChannelListingUpdateMutationVariables, ProductFragment, + ProductUpdateMutationVariables, ProductVariantBulkUpdateInput, VariantAttributeFragment, } from "@dashboard/graphql"; @@ -42,7 +42,7 @@ export function getProductUpdateVariables( uploadFilesResult, ); - return { + const variables: ProductUpdateMutationVariables = { id: product.id, input: { attributes: prepareAttributesInput({ @@ -50,20 +50,50 @@ export function getProductUpdateVariables( prevAttributes: getAttributeInputFromProduct(product), updatedFileAttributes, }), - category: data.category, - collections: data.collections.map(collection => collection.value), - description: getParsedDataForJsonStringField(data.description), - name: data.name, - rating: data.rating, - seo: { - description: data.seoDescription, - title: data.seoTitle, - }, - slug: data.slug, - taxClass: data.taxClassId, }, - firstValues: VALUES_PAGINATE_BY, }; + + if (data.category) { + variables.input["category"] = data.category; + } + + if (data.collections) { + variables.input["collections"] = data.collections.map(collection => collection.value); + } + + if (data.description) { + variables.input["description"] = getParsedDataForJsonStringField(data.description); + } + + if (data.name) { + variables.input["name"] = data.name; + } + + if (data.rating) { + variables.input["rating"] = data.rating; + } + + if (data.slug) { + variables.input["slug"] = data.slug; + } + + if (data.taxClassId) { + variables.input["taxClass"] = data.taxClassId; + } + + if (data.seoDescription || data.seoTitle) { + variables.input["seo"] = {}; + } + + if (data.seoDescription && variables.input["seo"]) { + variables.input["seo"].description = data.seoDescription; + } + + if (data.seoTitle && variables.input["seo"]) { + variables.input["seo"].title = data.seoTitle; + } + + return variables; } export function getCreateVariantInput( diff --git a/src/utils/richText/__mocks__/useRichText.ts b/src/utils/richText/__mocks__/useRichText.ts index 3ff7752fc2f..9d6090629a8 100644 --- a/src/utils/richText/__mocks__/useRichText.ts +++ b/src/utils/richText/__mocks__/useRichText.ts @@ -9,6 +9,7 @@ const useRichTextMocked = ({ getValue: async () => ({ blocks: [] }), handleChange: triggerChange, isReadyForMount: true, + isDirty: false, }); export default useRichTextMocked; diff --git a/src/utils/richText/useRichText.test.ts b/src/utils/richText/useRichText.test.ts index 13407f386e8..f32a30a344f 100644 --- a/src/utils/richText/useRichText.test.ts +++ b/src/utils/richText/useRichText.test.ts @@ -36,6 +36,7 @@ describe("useRichText", () => { expect(result.current.defaultValue).toStrictEqual(fixtures.short); expect(result.current.isReadyForMount).toBe(true); + expect(result.current.isDirty).toBe(false); }); it("returns undefined when JSON cannot be parsed", () => { @@ -54,6 +55,7 @@ describe("useRichText", () => { expect(result.current.defaultValue).toBe(undefined); expect(result.current.isReadyForMount).toBe(false); + expect(result.current.isDirty).toBe(false); }); it("runs editorJS .save() when getValue is called", async () => { @@ -70,6 +72,7 @@ describe("useRichText", () => { expect(await result.current.getValue()).toStrictEqual(fixtures.short); expect(saveFn).toHaveBeenCalled(); + expect(result.current.isDirty).toBe(false); }); it("calls triggerChange when change is made in the editor", () => { @@ -81,5 +84,6 @@ describe("useRichText", () => { result.current.handleChange(); expect(triggerChange).toHaveBeenCalled(); + expect(result.current.isDirty).toBe(true); }); }); diff --git a/src/utils/richText/useRichText.ts b/src/utils/richText/useRichText.ts index 768f5fb8c78..6de26d816c5 100644 --- a/src/utils/richText/useRichText.ts +++ b/src/utils/richText/useRichText.ts @@ -14,6 +14,7 @@ interface UseRichTextResult { getValue: () => Promise; defaultValue: OutputData | undefined; isReadyForMount: boolean; + isDirty: boolean; } export function useRichText({ @@ -23,13 +24,16 @@ export function useRichText({ }: UseRichTextOptions): UseRichTextResult { const editorRef = useRef(null); const [isReadyForMount, setIsReadyForMount] = useState(false); - + const [isDirty, setIsDirty] = useState(false); const handleChange = () => { + setIsDirty(true); triggerChange(); }; const getValue = async () => { if (editorRef.current) { + setIsDirty(false); + return editorRef.current.save(); } else { throw new Error("Editor instance is not available"); @@ -43,11 +47,15 @@ export function useRichText({ if (!initial) { setIsReadyForMount(true); + setIsDirty(false); + return ""; } try { const result = JSON.parse(initial); + + setIsDirty(false); setIsReadyForMount(true); return result; } catch (e) { @@ -56,6 +64,7 @@ export function useRichText({ }, [initial, loading]); return { + isDirty, editorRef, handleChange, getValue,