diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f7298daa..6315fb66e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,7 @@ All notable, unreleased changes to this project will be documented in this file. - Add manage product types and attributes permission - #768 by @orzechdev - Fix isPublished and isAvailable behaviour for products, collections and pages - #780 by @mmarkusik - Add metadata editor to page views - #782 by @dominik-zeglen +- Add missing infinite scroll to searches - #793 by @dominik-zeglen - Fix for fulfilling order when allocated quantity equals total quantity - #788 by @GrzegorzDerdak ## 2.10.1 diff --git a/locale/defaultMessages.json b/locale/defaultMessages.json index bb9d0b772..414af4209 100644 --- a/locale/defaultMessages.json +++ b/locale/defaultMessages.json @@ -1400,23 +1400,15 @@ "context": "section header", "string": "App Status" }, - "src_dot_components_dot_AssignCategoryDialog_dot_1305061437": { - "string": "Search Categories" - }, - "src_dot_components_dot_AssignCategoryDialog_dot_190977792": { + "src_dot_components_dot_AssignCategoryDialog_dot_3125506097": { "context": "dialog header", - "string": "Assign Categories" + "string": "Assign Category" }, "src_dot_components_dot_AssignCategoryDialog_dot_3690273268": { "string": "Search by category name, etc..." }, - "src_dot_components_dot_AssignCategoryDialog_dot_3973677075": { - "context": "button", - "string": "Assign categories" - }, - "src_dot_components_dot_AssignCollectionDialog_dot_1035511604": { - "context": "button", - "string": "Assign collections" + "src_dot_components_dot_AssignCategoryDialog_dot_3841025483": { + "string": "Search Category" }, "src_dot_components_dot_AssignCollectionDialog_dot_2605414502": { "string": "Search by collection name, etc..." @@ -1428,6 +1420,10 @@ "src_dot_components_dot_AssignCollectionDialog_dot_4057224233": { "string": "Search Collection" }, + "src_dot_components_dot_AssignContainerDialog_dot_1731102929": { + "context": "button", + "string": "Assign" + }, "src_dot_components_dot_AssignProductDialog_dot_2100305525": { "context": "button", "string": "Assign products" diff --git a/package-lock.json b/package-lock.json index 2cfb06a3a..64aa11b5f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9537,6 +9537,12 @@ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true }, + "lodash": { + "version": "4.17.15", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", + "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==", + "dev": true + }, "log-symbols": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-3.0.0.tgz", @@ -16392,9 +16398,9 @@ } }, "lodash": { - "version": "4.17.15", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", - "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==" + "version": "4.17.20", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz", + "integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==" }, "lodash-es": { "version": "4.17.15", diff --git a/package.json b/package.json index 14d84ddec..c3c83cc11 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "is-url": "^1.2.4", "jss": "^9.8.7", "keycode": "^2.2.0", - "lodash": "^4.17.14", + "lodash": "^4.17.20", "lodash-es": "^4.17.14", "moment-timezone": "^0.5.26", "qs": "^6.9.0", diff --git a/src/apps/components/CustomAppDetailsPage/CustomAppDetailsPage.tsx b/src/apps/components/CustomAppDetailsPage/CustomAppDetailsPage.tsx index f452257e2..75bdef75d 100644 --- a/src/apps/components/CustomAppDetailsPage/CustomAppDetailsPage.tsx +++ b/src/apps/components/CustomAppDetailsPage/CustomAppDetailsPage.tsx @@ -10,6 +10,7 @@ import Grid from "@saleor/components/Grid"; import PageHeader from "@saleor/components/PageHeader"; import SaveButtonBar from "@saleor/components/SaveButtonBar"; import { ShopInfo_shop_permissions } from "@saleor/components/Shop/types/ShopInfo"; +import { SubmitPromise } from "@saleor/hooks/useForm"; import { sectionNames } from "@saleor/intl"; import { PermissionEnum } from "@saleor/types/globalTypes"; import { getFormErrors } from "@saleor/utils/errors"; @@ -42,7 +43,7 @@ export interface CustomAppDetailsPageProps { onTokenDelete: (id: string) => void; onTokenClose: () => void; onTokenCreate: () => void; - onSubmit: (data: CustomAppDetailsPageFormData) => void; + onSubmit: (data: CustomAppDetailsPageFormData) => SubmitPromise; onWebhookCreate: () => void; onWebhookRemove: (id: string) => void; navigateToWebhookDetails: (id: string) => () => void; diff --git a/src/apps/views/CustomAppDetails/CustomAppDetails.tsx b/src/apps/views/CustomAppDetails/CustomAppDetails.tsx index da7111e6d..4edd2a465 100644 --- a/src/apps/views/CustomAppDetails/CustomAppDetails.tsx +++ b/src/apps/views/CustomAppDetails/CustomAppDetails.tsx @@ -130,8 +130,8 @@ export const CustomAppDetails: React.FC = ({ onCompleted: onTokenDelete }); - const handleSubmit = (data: CustomAppDetailsPageFormData) => - updateApp({ + const handleSubmit = async (data: CustomAppDetailsPageFormData) => { + const result = await updateApp({ variables: { id, input: { @@ -144,6 +144,9 @@ export const CustomAppDetails: React.FC = ({ } }); + return result.data.appUpdate.errors; + }; + const handleTokenCreate = (name: string) => createToken({ variables: { diff --git a/src/attributes/components/AttributePage/AttributePage.tsx b/src/attributes/components/AttributePage/AttributePage.tsx index 2e4bb018a..611d9bbdb 100644 --- a/src/attributes/components/AttributePage/AttributePage.tsx +++ b/src/attributes/components/AttributePage/AttributePage.tsx @@ -126,7 +126,7 @@ const AttributePage: React.FC = ({ ? data.privateMetadata : undefined; - onSubmit({ + return onSubmit({ ...data, metadata, privateMetadata, diff --git a/src/categories/components/CategoryUpdatePage/CategoryUpdatePage.tsx b/src/categories/components/CategoryUpdatePage/CategoryUpdatePage.tsx index eb0d7280a..96c46e1c2 100644 --- a/src/categories/components/CategoryUpdatePage/CategoryUpdatePage.tsx +++ b/src/categories/components/CategoryUpdatePage/CategoryUpdatePage.tsx @@ -13,6 +13,7 @@ import SaveButtonBar from "@saleor/components/SaveButtonBar"; import SeoForm from "@saleor/components/SeoForm"; import { Tab, TabContainer } from "@saleor/components/Tab"; import { ProductErrorFragment } from "@saleor/fragments/types/ProductErrorFragment"; +import { SubmitPromise } from "@saleor/hooks/useForm"; import { sectionNames } from "@saleor/intl"; import { mapMetadataItemToInput } from "@saleor/utils/maps"; import useMetadataChangeTrigger from "@saleor/utils/metadata/useMetadataChangeTrigger"; @@ -61,7 +62,7 @@ export interface CategoryUpdatePageProps }; saveButtonBarState: ConfirmButtonTransitionState; onImageDelete: () => void; - onSubmit: (data: FormData) => void; + onSubmit: (data: FormData) => SubmitPromise; onImageUpload(file: File); onNextPage(); onPreviousPage(); @@ -139,7 +140,7 @@ export const CategoryUpdatePage: React.FC = ({ ? data.privateMetadata : undefined; - onSubmit({ + return onSubmit({ ...data, metadata, privateMetadata diff --git a/src/collections/components/CollectionDetailsPage/CollectionDetailsPage.tsx b/src/collections/components/CollectionDetailsPage/CollectionDetailsPage.tsx index d2661ed3a..0b37e6e51 100644 --- a/src/collections/components/CollectionDetailsPage/CollectionDetailsPage.tsx +++ b/src/collections/components/CollectionDetailsPage/CollectionDetailsPage.tsx @@ -81,7 +81,7 @@ const CollectionDetailsPage: React.FC = ({ ? data.privateMetadata : undefined; - onSubmit({ + return onSubmit({ ...data, isPublished: data.isPublished || !!data.publicationDate, metadata, diff --git a/src/collections/views/CollectionDetails.tsx b/src/collections/views/CollectionDetails.tsx index 9ca6d0798..e1e5a0af5 100644 --- a/src/collections/views/CollectionDetails.tsx +++ b/src/collections/views/CollectionDetails.tsx @@ -60,7 +60,7 @@ export const CollectionDetails: React.FC = ({ ); const paginate = usePaginator(); const intl = useIntl(); - const { search, result } = useProductSearch({ + const { search, loadMore, result } = useProductSearch({ variables: DEFAULT_INITIAL_SEARCH_DATA }); const [updateMetadata] = useMetadataUpdate({}); @@ -296,8 +296,10 @@ export const CollectionDetails: React.FC = ({ /> diff --git a/src/components/AppLayout/AppActionContext.tsx b/src/components/AppLayout/AppActionContext.tsx index 6083d883b..ad7e71990 100644 --- a/src/components/AppLayout/AppActionContext.tsx +++ b/src/components/AppLayout/AppActionContext.tsx @@ -1,7 +1,15 @@ import React from "react"; -const AppActionContext = React.createContext>( - undefined -); +interface AppAction { + anchor: React.RefObject; + docked: boolean; + setDocked: (docked: boolean) => void; +} +const AppActionContext = React.createContext({ + anchor: undefined, + docked: true, + setDocked: () => undefined +}); +export const useAppAction = () => React.useContext(AppActionContext); export default AppActionContext; diff --git a/src/components/AppLayout/AppLayout.tsx b/src/components/AppLayout/AppLayout.tsx index 128c20a95..c5fdf190d 100644 --- a/src/components/AppLayout/AppLayout.tsx +++ b/src/components/AppLayout/AppLayout.tsx @@ -50,6 +50,9 @@ const useStyles = makeStyles( position: "sticky", zIndex: 10 }, + appActionDocked: { + position: "static" + }, appLoader: { height: appLoaderHeight, marginBottom: theme.spacing(2), @@ -313,6 +316,7 @@ const AppLayout: React.FC = ({ children }) => { const [appState, dispatchAppState] = useAppState(); const { location } = useRouter(); const [isNavigatorVisible, setNavigatorVisibility] = React.useState(false); + const [docked, setDocked] = React.useState(true); const menuStructure = createMenuStructure(intl); const configurationMenu = createConfigurationMenu(intl); @@ -365,7 +369,13 @@ const AppLayout: React.FC = ({ children }) => { setVisibility={setNavigatorVisibility} /> - +
= ({ children }) => { : children}
-
+
diff --git a/src/components/AssignCategoryDialog/AssignCategoryDialog.tsx b/src/components/AssignCategoryDialog/AssignCategoryDialog.tsx index 7b3bc1ed5..f0077a198 100644 --- a/src/components/AssignCategoryDialog/AssignCategoryDialog.tsx +++ b/src/components/AssignCategoryDialog/AssignCategoryDialog.tsx @@ -1,185 +1,41 @@ -import Button from "@material-ui/core/Button"; -import CircularProgress from "@material-ui/core/CircularProgress"; -import Dialog from "@material-ui/core/Dialog"; -import DialogActions from "@material-ui/core/DialogActions"; -import DialogContent from "@material-ui/core/DialogContent"; -import DialogTitle from "@material-ui/core/DialogTitle"; -import { makeStyles } from "@material-ui/core/styles"; -import TableBody from "@material-ui/core/TableBody"; -import TableCell from "@material-ui/core/TableCell"; -import TableRow from "@material-ui/core/TableRow"; -import TextField from "@material-ui/core/TextField"; -import ConfirmButton, { - ConfirmButtonTransitionState -} from "@saleor/components/ConfirmButton"; -import FormSpacer from "@saleor/components/FormSpacer"; -import ResponsiveTable from "@saleor/components/ResponsiveTable"; -import useSearchQuery from "@saleor/hooks/useSearchQuery"; -import { buttonMessages } from "@saleor/intl"; import { SearchCategories_search_edges_node } from "@saleor/searches/types/SearchCategories"; import React from "react"; -import { FormattedMessage, useIntl } from "react-intl"; +import { useIntl } from "react-intl"; -import Checkbox from "../Checkbox"; +import AssignContainerDialog, { + AssignContainerDialogProps +} from "../AssignContainerDialog"; -export interface FormData { +interface AssignCategoryDialogProps + extends Omit { categories: SearchCategories_search_edges_node[]; - query: string; } -const useStyles = makeStyles( - { - avatar: { - "&:first-child": { - paddingLeft: 0 - } - }, - checkboxCell: { - paddingLeft: 0 - }, - overflow: { - overflowY: "visible" - }, - wideCell: { - width: "100%" - } - }, - { name: "AssignCategoryDialog" } -); - -interface AssignCategoriesDialogProps { - categories: SearchCategories_search_edges_node[]; - confirmButtonState: ConfirmButtonTransitionState; - open: boolean; - loading: boolean; - onClose: () => void; - onFetch: (value: string) => void; - onSubmit: (data: SearchCategories_search_edges_node[]) => void; -} - -function handleCategoryAssign( - product: SearchCategories_search_edges_node, - isSelected: boolean, - selectedCategories: SearchCategories_search_edges_node[], - setSelectedCategories: (data: SearchCategories_search_edges_node[]) => void -) { - if (isSelected) { - setSelectedCategories( - selectedCategories.filter( - selectedProduct => selectedProduct.id !== product.id - ) - ); - } else { - setSelectedCategories([...selectedCategories, product]); - } -} - -const AssignCategoriesDialog: React.FC = props => { - const { - confirmButtonState, - open, - loading, - categories: categories, - onClose, - onFetch, - onSubmit - } = props; - const classes = useStyles(props); - +const AssignCategoryDialog: React.FC = ({ + categories, + ...rest +}) => { const intl = useIntl(); - const [query, onQueryChange] = useSearchQuery(onFetch); - const [selectedCategories, setSelectedCategories] = React.useState< - SearchCategories_search_edges_node[] - >([]); - - const handleSubmit = () => onSubmit(selectedCategories); return ( - - - - - - - }} - /> - - - - {categories && - categories.map(category => { - const isSelected = !!selectedCategories.find( - selectedCategories => selectedCategories.id === category.id - ); - - return ( - - - - handleCategoryAssign( - category, - isSelected, - selectedCategories, - setSelectedCategories - ) - } - /> - - - {category.name} - - - ); - })} - - - - - - - - - - + ); }; -AssignCategoriesDialog.displayName = "AssignCategoriesDialog"; -export default AssignCategoriesDialog; + +AssignCategoryDialog.displayName = "AssignCategoryDialog"; +export default AssignCategoryDialog; diff --git a/src/components/AssignCollectionDialog/AssignCollectionDialog.tsx b/src/components/AssignCollectionDialog/AssignCollectionDialog.tsx index bf46d3de3..0ee4d7146 100644 --- a/src/components/AssignCollectionDialog/AssignCollectionDialog.tsx +++ b/src/components/AssignCollectionDialog/AssignCollectionDialog.tsx @@ -1,185 +1,41 @@ -import Button from "@material-ui/core/Button"; -import CircularProgress from "@material-ui/core/CircularProgress"; -import Dialog from "@material-ui/core/Dialog"; -import DialogActions from "@material-ui/core/DialogActions"; -import DialogContent from "@material-ui/core/DialogContent"; -import DialogTitle from "@material-ui/core/DialogTitle"; -import { makeStyles } from "@material-ui/core/styles"; -import TableBody from "@material-ui/core/TableBody"; -import TableCell from "@material-ui/core/TableCell"; -import TableRow from "@material-ui/core/TableRow"; -import TextField from "@material-ui/core/TextField"; -import ResponsiveTable from "@saleor/components/ResponsiveTable"; -import useSearchQuery from "@saleor/hooks/useSearchQuery"; -import { buttonMessages } from "@saleor/intl"; import { SearchCollections_search_edges_node } from "@saleor/searches/types/SearchCollections"; import React from "react"; -import { FormattedMessage, useIntl } from "react-intl"; +import { useIntl } from "react-intl"; -import Checkbox from "../Checkbox"; -import ConfirmButton, { - ConfirmButtonTransitionState -} from "../ConfirmButton/ConfirmButton"; -import FormSpacer from "../FormSpacer"; +import AssignContainerDialog, { + AssignContainerDialogProps +} from "../AssignContainerDialog"; -export interface FormData { +interface AssignCollectionDialogProps + extends Omit { collections: SearchCollections_search_edges_node[]; - query: string; } -const useStyles = makeStyles( - { - avatar: { - "&:first-child": { - paddingLeft: 0 - } - }, - checkboxCell: { - paddingLeft: 0 - }, - overflow: { - overflowY: "visible" - }, - wideCell: { - width: "100%" - } - }, - { name: "AssignCollectionDialog" } -); - -interface AssignCollectionDialogProps { - collections: SearchCollections_search_edges_node[]; - confirmButtonState: ConfirmButtonTransitionState; - open: boolean; - loading: boolean; - onClose: () => void; - onFetch: (value: string) => void; - onSubmit: (data: SearchCollections_search_edges_node[]) => void; -} - -function handleCollectionAssign( - product: SearchCollections_search_edges_node, - isSelected: boolean, - selectedCollections: SearchCollections_search_edges_node[], - setSelectedCollections: (data: SearchCollections_search_edges_node[]) => void -) { - if (isSelected) { - setSelectedCollections( - selectedCollections.filter( - selectedProduct => selectedProduct.id !== product.id - ) - ); - } else { - setSelectedCollections([...selectedCollections, product]); - } -} - -const AssignCollectionDialog: React.FC = props => { - const { - confirmButtonState, - open, - loading, - collections, - onClose, - onFetch, - onSubmit - } = props; - const classes = useStyles(props); - +const AssignCollectionDialog: React.FC = ({ + collections, + ...rest +}) => { const intl = useIntl(); - const [query, onQueryChange] = useSearchQuery(onFetch); - const [selectedCollections, setSelectedCollections] = React.useState< - SearchCollections_search_edges_node[] - >([]); - - const handleSubmit = () => onSubmit(selectedCollections); return ( - - - - - - - }} - /> - - - - {collections && - collections.map(collection => { - const isSelected = !!selectedCollections.find( - selectedCollection => selectedCollection.id === collection.id - ); - - return ( - - - - handleCollectionAssign( - collection, - isSelected, - selectedCollections, - setSelectedCollections - ) - } - /> - - - {collection.name} - - - ); - })} - - - - - - - - - - + ); }; + AssignCollectionDialog.displayName = "AssignCollectionDialog"; export default AssignCollectionDialog; diff --git a/src/components/AssignContainerDialog/AssignContainerDialog.tsx b/src/components/AssignContainerDialog/AssignContainerDialog.tsx new file mode 100644 index 000000000..23b3e29b4 --- /dev/null +++ b/src/components/AssignContainerDialog/AssignContainerDialog.tsx @@ -0,0 +1,204 @@ +import Button from "@material-ui/core/Button"; +import CircularProgress from "@material-ui/core/CircularProgress"; +import Dialog from "@material-ui/core/Dialog"; +import DialogActions from "@material-ui/core/DialogActions"; +import DialogContent from "@material-ui/core/DialogContent"; +import DialogTitle from "@material-ui/core/DialogTitle"; +import { makeStyles } from "@material-ui/core/styles"; +import TableBody from "@material-ui/core/TableBody"; +import TableCell from "@material-ui/core/TableCell"; +import TableRow from "@material-ui/core/TableRow"; +import TextField from "@material-ui/core/TextField"; +import ResponsiveTable from "@saleor/components/ResponsiveTable"; +import useSearchQuery from "@saleor/hooks/useSearchQuery"; +import { buttonMessages } from "@saleor/intl"; +import useScrollableDialogStyle from "@saleor/styles/useScrollableDialogStyle"; +import { FetchMoreProps, Node } from "@saleor/types"; +import React from "react"; +import InfiniteScroll from "react-infinite-scroller"; +import { FormattedMessage } from "react-intl"; + +import Checkbox from "../Checkbox"; +import ConfirmButton, { + ConfirmButtonTransitionState +} from "../ConfirmButton/ConfirmButton"; +import FormSpacer from "../FormSpacer"; + +export interface FormData { + containers: string[]; + query: string; +} + +const useStyles = makeStyles( + { + avatar: { + "&:first-child": { + paddingLeft: 0 + } + }, + checkboxCell: { + paddingLeft: 0 + }, + wideCell: { + width: "100%" + } + }, + { name: "AssignContainerDialog" } +); + +interface Container extends Node { + name: string; +} +export interface AssignContainerDialogProps extends FetchMoreProps { + confirmButtonState: ConfirmButtonTransitionState; + containers: Container[]; + loading: boolean; + open: boolean; + search: Record<"label" | "placeholder", string>; + title: string; + onClose: () => void; + onFetch: (value: string) => void; + onSubmit: (data: string[]) => void; +} + +function handleContainerAssign( + containerId: string, + isSelected: boolean, + selectedContainers: string[], + setSelectedContainers: (data: string[]) => void +) { + if (isSelected) { + setSelectedContainers( + selectedContainers.filter( + selectedContainer => selectedContainer !== containerId + ) + ); + } else { + setSelectedContainers([...selectedContainers, containerId]); + } +} + +const AssignContainerDialog: React.FC = props => { + const { + confirmButtonState, + containers, + hasMore, + loading, + open, + search, + title, + onClose, + onFetch, + onFetchMore, + onSubmit + } = props; + const classes = useStyles(props); + const scrollableDialogClasses = useScrollableDialogStyle({}); + + const [query, onQueryChange] = useSearchQuery(onFetch); + const [selectedContainers, setSelectedContainers] = React.useState( + [] + ); + const container = React.useRef(); + + const handleSubmit = () => onSubmit(selectedContainers); + + const containerHeight = container.current?.scrollHeight - 130; + + return ( + + {title} + + + }} + /> + +
+ + +
+ } + threshold={10} + > + + + {containers?.map(container => { + const isSelected = !!selectedContainers.find( + selectedContainer => selectedContainer === container.id + ); + + return ( + + + + handleContainerAssign( + container.id, + isSelected, + selectedContainers, + setSelectedContainers + ) + } + /> + + + {container.name} + + + ); + })} + + + +
+ + + + + + + + + ); +}; +AssignContainerDialog.displayName = "AssignContainerDialog"; +export default AssignContainerDialog; diff --git a/src/components/AssignContainerDialog/index.ts b/src/components/AssignContainerDialog/index.ts new file mode 100644 index 000000000..523a573e8 --- /dev/null +++ b/src/components/AssignContainerDialog/index.ts @@ -0,0 +1,2 @@ +export * from "./AssignContainerDialog"; +export { default } from "./AssignContainerDialog"; diff --git a/src/components/AssignProductDialog/AssignProductDialog.tsx b/src/components/AssignProductDialog/AssignProductDialog.tsx index 92a2a9a0c..10f1431de 100644 --- a/src/components/AssignProductDialog/AssignProductDialog.tsx +++ b/src/components/AssignProductDialog/AssignProductDialog.tsx @@ -19,7 +19,10 @@ import useSearchQuery from "@saleor/hooks/useSearchQuery"; import { buttonMessages } from "@saleor/intl"; import { maybe } from "@saleor/misc"; import { SearchProducts_search_edges_node } from "@saleor/searches/types/SearchProducts"; +import useScrollableDialogStyle from "@saleor/styles/useScrollableDialogStyle"; +import { FetchMoreProps } from "@saleor/types"; import React from "react"; +import InfiniteScroll from "react-infinite-scroller"; import { FormattedMessage, useIntl } from "react-intl"; import Checkbox from "../Checkbox"; @@ -43,18 +46,12 @@ const useStyles = makeStyles( }, colName: { paddingLeft: 0 - }, - overflow: { - overflowY: "visible" - }, - scrollArea: { - overflowY: "scroll" } }, { name: "AssignProductDialog" } ); -export interface AssignProductDialogProps { +export interface AssignProductDialogProps extends FetchMoreProps { confirmButtonState: ConfirmButtonTransitionState; open: boolean; products: SearchProducts_search_edges_node[]; @@ -84,28 +81,34 @@ function handleProductAssign( const AssignProductDialog: React.FC = props => { const { confirmButtonState, + hasMore, open, loading, products, onClose, onFetch, + onFetchMore, onSubmit } = props; const classes = useStyles(props); + const scrollableDialogClasses = useScrollableDialogStyle({}); const intl = useIntl(); const [query, onQueryChange] = useSearchQuery(onFetch); const [selectedProducts, setSelectedProducts] = React.useState< SearchProducts_search_edges_node[] >([]); + const container = React.useRef(); const handleSubmit = () => onSubmit(selectedProducts); + const containerHeight = container.current?.scrollHeight - 130; + return ( @@ -115,7 +118,10 @@ const AssignProductDialog: React.FC = props => { description="dialog header" /> - + = props => { }} /> -
- - - {products && - products.map(product => { - const isSelected = selectedProducts.some( - selectedProduct => selectedProduct.id === product.id - ); +
+ + +
+ } + threshold={10} + > + + + {products && + products.map(product => { + const isSelected = selectedProducts.some( + selectedProduct => selectedProduct.id === product.id + ); - return ( - - product.thumbnail.url)} - /> - - {product.name} - - - - handleProductAssign( - product, - isSelected, - selectedProducts, - setSelectedProducts - ) - } + return ( + + product.thumbnail.url)} /> - - - ); - })} - - + + {product.name} + + + + handleProductAssign( + product, + isSelected, + selectedProducts, + setSelectedProducts + ) + } + /> + + + ); + })} +
+
+
diff --git a/src/components/ConfirmButton/ConfirmButton.tsx b/src/components/ConfirmButton/ConfirmButton.tsx index 8a6d2f812..981131dbb 100644 --- a/src/components/ConfirmButton/ConfirmButton.tsx +++ b/src/components/ConfirmButton/ConfirmButton.tsx @@ -7,6 +7,7 @@ import { withStyles } from "@material-ui/core/styles"; import CheckIcon from "@material-ui/icons/Check"; +import { DEFAULT_NOTIFICATION_SHOW_TIME } from "@saleor/config"; import { buttonMessages } from "@saleor/intl"; import classNames from "classnames"; import React from "react"; @@ -61,6 +62,7 @@ export interface ConfirmButtonProps extends Omit, WithStyles { transitionState: ConfirmButtonTransitionState; + onTransitionToDefault?: () => void; } interface ConfirmButtonState { @@ -93,20 +95,21 @@ const ConfirmButton = withStyles(styles, { name: "ConfirmButton" })( } componentDidUpdate(prevProps: ConfirmButtonProps) { - const { transitionState } = this.props; + const { transitionState, onTransitionToDefault } = this.props; if (prevProps.transitionState !== transitionState) { if ( (["error", "success"] as ConfirmButtonTransitionState[]).includes( transitionState ) ) { - this.timeout = setTimeout( - () => - this.setState({ - displayCompletedActionState: false - }), - 2000 - ); + this.timeout = setTimeout(() => { + this.setState({ + displayCompletedActionState: false + }); + if (onTransitionToDefault) { + onTransitionToDefault(); + } + }, DEFAULT_NOTIFICATION_SHOW_TIME); } else if (transitionState === "loading") { clearTimeout(this.timeout); } @@ -125,6 +128,7 @@ const ConfirmButton = withStyles(styles, { name: "ConfirmButton" })( disabled, transitionState, onClick, + onTransitionToDefault: _, ...props } = this.props; const { displayCompletedActionState } = this.state; diff --git a/src/components/Form/Form.tsx b/src/components/Form/Form.tsx index 2721f678a..88d893e22 100644 --- a/src/components/Form/Form.tsx +++ b/src/components/Form/Form.tsx @@ -1,4 +1,4 @@ -import useForm, { UseFormResult } from "@saleor/hooks/useForm"; +import useForm, { SubmitPromise, UseFormResult } from "@saleor/hooks/useForm"; import React from "react"; export interface FormProps { @@ -6,7 +6,7 @@ export interface FormProps { confirmLeave?: boolean; initial?: T; resetOnSubmit?: boolean; - onSubmit?: (data: T) => void; + onSubmit?: (data: T) => SubmitPromise | void; } function Form(props: FormProps) { diff --git a/src/components/SaveButtonBar/SaveButtonBar.tsx b/src/components/SaveButtonBar/SaveButtonBar.tsx index d741cae2f..279436b79 100644 --- a/src/components/SaveButtonBar/SaveButtonBar.tsx +++ b/src/components/SaveButtonBar/SaveButtonBar.tsx @@ -8,8 +8,7 @@ import { buttonMessages } from "@saleor/intl"; import React from "react"; import { useIntl } from "react-intl"; -import { maybe } from "../../misc"; -import AppActionContext from "../AppLayout/AppActionContext"; +import { useAppAction } from "../AppLayout/AppActionContext"; import ConfirmButton, { ConfirmButtonTransitionState } from "../ConfirmButton/ConfirmButton"; @@ -41,8 +40,12 @@ const useStyles = makeStyles( backgroundColor: theme.palette.error.main, color: theme.palette.error.contrastText }, + paper: { + borderBottomLeftRadius: 0, + borderBottomRightRadius: 0 + }, root: { - height: 120 + height: 70 }, spacer: { flex: "1" @@ -76,67 +79,63 @@ export const SaveButtonBar: React.FC = props => { } = props; const classes = useStyles(props); + const appAction = useAppAction(); const intl = useIntl(); const scrollPosition = useWindowScroll(); + + React.useEffect(() => { + if (!disabled && state !== "loading") { + appAction.setDocked(false); + } + }, [disabled]); + React.useEffect(() => () => appAction.setDocked(true), []); + const scrolledToBottom = scrollPosition.y + window.innerHeight >= document.body.scrollHeight; - const shouldDisplay = onDelete || !disabled; - return ( - - {anchor => - anchor ? ( - - {shouldDisplay && ( -
- - - - {!!onDelete && ( - - )} -
- - - {maybe( - () => labels.save, - intl.formatMessage(buttonMessages.save) - )} - - - - -
- )} - - ) : null - } - - ); + return appAction.anchor ? ( + +
+ + + + {!!onDelete && ( + + )} +
+ + appAction.setDocked(true)} + > + {labels?.save || intl.formatMessage(buttonMessages.save)} + + + + +
+ + ) : null; }; SaveButtonBar.displayName = "SaveButtonBar"; export default SaveButtonBar; diff --git a/src/components/messages/MessageManagerProvider.tsx b/src/components/messages/MessageManagerProvider.tsx index 4f20b8af3..280ff0a5b 100644 --- a/src/components/messages/MessageManagerProvider.tsx +++ b/src/components/messages/MessageManagerProvider.tsx @@ -1,3 +1,4 @@ +import { DEFAULT_NOTIFICATION_SHOW_TIME } from "@saleor/config"; import React, { useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { TransitionGroup } from "react-transition-group"; @@ -43,32 +44,35 @@ const MessageManagerProvider = ({ children }) => { ); }, []); - const show = useCallback((message = {}, timeout = 3000) => { - const id = Date.now(); - const notification = { - close: () => remove(id), - id, - message, - timeout - }; - if (timeout !== null) { - const timeoutId = window.setTimeout(() => { - timerCallback(notification); - }, timeout); + const show = useCallback( + (message = {}, timeout = DEFAULT_NOTIFICATION_SHOW_TIME) => { + const id = Date.now(); + const notification = { + close: () => remove(id), + id, + message, + timeout + }; + if (timeout !== null) { + const timeoutId = window.setTimeout(() => { + timerCallback(notification); + }, timeout); - timersArr.current.push({ - id: notification.id, - notification, - remaining: timeout, - start: new Date().getTime(), - timeoutId - }); - } + timersArr.current.push({ + id: notification.id, + notification, + remaining: timeout, + start: new Date().getTime(), + timeoutId + }); + } - setNotifications(state => [notification, ...state]); + setNotifications(state => [notification, ...state]); - return notification; - }, []); + return notification; + }, + [] + ); const getCurrentTimer = (notification: INotification) => { const currentTimerIndex = timersArr.current.findIndex( diff --git a/src/config.ts b/src/config.ts index 7a9e4ef0c..fb0bd87d4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -96,3 +96,5 @@ export const defaultListSettings: AppListViewSettings = { export const APP_VERSION = packageInfo.version; export const DEMO_MODE = process.env.DEMO_MODE === "true"; export const GTM_ID = process.env.GTM_ID; + +export const DEFAULT_NOTIFICATION_SHOW_TIME = 3000; diff --git a/src/customers/components/CustomerCreatePage/CustomerCreatePage.tsx b/src/customers/components/CustomerCreatePage/CustomerCreatePage.tsx index 554e96456..dd421fcb2 100644 --- a/src/customers/components/CustomerCreatePage/CustomerCreatePage.tsx +++ b/src/customers/components/CustomerCreatePage/CustomerCreatePage.tsx @@ -76,7 +76,7 @@ const CustomerCreatePage: React.FC = ({ const { errors: validationErrors, submit: handleSubmitWithAddress - } = useAddressValidation(formData => + } = useAddressValidation(formData => onSubmit({ address: { city: formData.city, diff --git a/src/customers/components/CustomerDetailsPage/CustomerDetailsPage.tsx b/src/customers/components/CustomerDetailsPage/CustomerDetailsPage.tsx index 44081fc76..0cc6b19c5 100644 --- a/src/customers/components/CustomerDetailsPage/CustomerDetailsPage.tsx +++ b/src/customers/components/CustomerDetailsPage/CustomerDetailsPage.tsx @@ -7,6 +7,7 @@ import Grid from "@saleor/components/Grid"; import PageHeader from "@saleor/components/PageHeader"; import SaveButtonBar from "@saleor/components/SaveButtonBar"; import { AccountErrorFragment } from "@saleor/fragments/types/AccountErrorFragment"; +import { SubmitPromise } from "@saleor/hooks/useForm"; import { sectionNames } from "@saleor/intl"; import React from "react"; import { useIntl } from "react-intl"; @@ -33,7 +34,7 @@ export interface CustomerDetailsPageProps { errors: AccountErrorFragment[]; saveButtonBar: ConfirmButtonTransitionState; onBack: () => void; - onSubmit: (data: CustomerDetailsPageFormData) => void; + onSubmit: (data: CustomerDetailsPageFormData) => SubmitPromise; onViewAllOrdersClick: () => void; onRowClick: (id: string) => void; onAddressManageClick: () => void; diff --git a/src/customers/views/CustomerDetails.tsx b/src/customers/views/CustomerDetails.tsx index c7227e803..765c471e0 100644 --- a/src/customers/views/CustomerDetails.tsx +++ b/src/customers/views/CustomerDetails.tsx @@ -10,7 +10,9 @@ import { FormattedMessage, useIntl } from "react-intl"; import { maybe } from "../../misc"; import { orderListUrl, orderUrl } from "../../orders/urls"; -import CustomerDetailsPage from "../components/CustomerDetailsPage/CustomerDetailsPage"; +import CustomerDetailsPage, { + CustomerDetailsPageFormData +} from "../components/CustomerDetailsPage/CustomerDetailsPage"; import { TypedRemoveCustomerMutation, TypedUpdateCustomerMutation @@ -76,6 +78,25 @@ export const CustomerDetailsView: React.FC = ({ return ; } + const handleSubmit = async ( + data: CustomerDetailsPageFormData + ) => { + const result = await updateCustomer({ + variables: { + id, + input: { + email: data.email, + firstName: data.firstName, + isActive: data.isActive, + lastName: data.lastName, + note: data.note + } + } + }); + + return result.data.customerUpdate.errors; + }; + return ( <> = ({ } onBack={handleBack} onRowClick={id => navigate(orderUrl(id))} - onSubmit={formData => - updateCustomer({ - variables: { - id, - input: { - email: formData.email, - firstName: formData.firstName, - isActive: formData.isActive, - lastName: formData.lastName, - note: formData.note - } - } - }) - } + onSubmit={handleSubmit} onDelete={() => navigate( customerUrl(id, { diff --git a/src/discounts/components/SaleDetailsPage/SaleDetailsPage.tsx b/src/discounts/components/SaleDetailsPage/SaleDetailsPage.tsx index 46df77189..d831737fc 100644 --- a/src/discounts/components/SaleDetailsPage/SaleDetailsPage.tsx +++ b/src/discounts/components/SaleDetailsPage/SaleDetailsPage.tsx @@ -25,7 +25,7 @@ import SaleSummary from "../SaleSummary"; import SaleType from "../SaleType"; import SaleValue from "../SaleValue"; -export interface FormData { +export interface SaleDetailsPageFormData { endDate: string; endTime: string; hasEndDate: boolean; @@ -70,7 +70,7 @@ export interface SaleDetailsPageProps onProductUnassign: (id: string) => void; onProductClick: (id: string) => () => void; onRemove: () => void; - onSubmit: (data: FormData) => void; + onSubmit: (data: SaleDetailsPageFormData) => void; onTabClick: (index: SaleDetailsPageTab) => void; } @@ -111,7 +111,7 @@ const SaleDetailsPage: React.FC = ({ }) => { const intl = useIntl(); - const initialForm: FormData = { + const initialForm: SaleDetailsPageFormData = { endDate: splitDateTime(maybe(() => sale.endDate, "")).date, endTime: splitDateTime(maybe(() => sale.endDate, "")).time, hasEndDate: maybe(() => !!sale.endDate), diff --git a/src/discounts/components/SaleInfo/SaleInfo.tsx b/src/discounts/components/SaleInfo/SaleInfo.tsx index 94a1baa1e..dad0ffc23 100644 --- a/src/discounts/components/SaleInfo/SaleInfo.tsx +++ b/src/discounts/components/SaleInfo/SaleInfo.tsx @@ -9,10 +9,10 @@ import getDiscountErrorMessage from "@saleor/utils/errors/discounts"; import React from "react"; import { useIntl } from "react-intl"; -import { FormData } from "../SaleDetailsPage"; +import { SaleDetailsPageFormData } from "../SaleDetailsPage"; export interface SaleInfoProps { - data: FormData; + data: SaleDetailsPageFormData; disabled: boolean; errors: DiscountErrorFragment[]; onChange: (event: React.ChangeEvent) => void; @@ -38,7 +38,7 @@ const SaleInfo: React.FC = ({ disabled={disabled} error={!!formErrors.name} helperText={getDiscountErrorMessage(formErrors.name, intl)} - name={"name" as keyof FormData} + name={"name" as keyof SaleDetailsPageFormData} onChange={onChange} label={intl.formatMessage({ defaultMessage: "Name", diff --git a/src/discounts/components/SaleType/SaleType.tsx b/src/discounts/components/SaleType/SaleType.tsx index a5f782f37..ebec25615 100644 --- a/src/discounts/components/SaleType/SaleType.tsx +++ b/src/discounts/components/SaleType/SaleType.tsx @@ -10,10 +10,10 @@ import { SaleType as SaleTypeEnum } from "@saleor/types/globalTypes"; import React from "react"; import { IntlShape, useIntl } from "react-intl"; -import { FormData } from "../SaleDetailsPage"; +import { SaleDetailsPageFormData } from "../SaleDetailsPage"; export interface SaleTypeProps { - data: FormData; + data: SaleDetailsPageFormData; disabled: boolean; onChange: FormChange; } @@ -70,7 +70,7 @@ const SaleType: React.FC = props => { diff --git a/src/discounts/components/SaleValue/SaleValue.tsx b/src/discounts/components/SaleValue/SaleValue.tsx index 34312ac2c..1112365f6 100644 --- a/src/discounts/components/SaleValue/SaleValue.tsx +++ b/src/discounts/components/SaleValue/SaleValue.tsx @@ -10,11 +10,11 @@ import getDiscountErrorMessage from "@saleor/utils/errors/discounts"; import React from "react"; import { useIntl } from "react-intl"; -import { FormData } from "../SaleDetailsPage"; +import { SaleDetailsPageFormData } from "../SaleDetailsPage"; export interface SaleValueProps { currencySymbol: string; - data: FormData; + data: SaleDetailsPageFormData; disabled: boolean; errors: DiscountErrorFragment[]; onChange: FormChange; diff --git a/src/discounts/components/VoucherDates/VoucherDates.tsx b/src/discounts/components/VoucherDates/VoucherDates.tsx index be804c2e6..3589aa142 100644 --- a/src/discounts/components/VoucherDates/VoucherDates.tsx +++ b/src/discounts/components/VoucherDates/VoucherDates.tsx @@ -11,10 +11,10 @@ import getDiscountErrorMessage from "@saleor/utils/errors/discounts"; import React from "react"; import { useIntl } from "react-intl"; -import { FormData } from "../VoucherDetailsPage"; +import { VoucherDetailsPageFormData } from "../VoucherDetailsPage"; interface VoucherDatesProps { - data: FormData; + data: VoucherDetailsPageFormData; defaultCurrency: string; disabled: boolean; errors: DiscountErrorFragment[]; @@ -45,7 +45,7 @@ const VoucherDates = ({ disabled={disabled} error={!!formErrors.startDate} helperText={getDiscountErrorMessage(formErrors.startDate, intl)} - name={"startDate" as keyof FormData} + name={"startDate" as keyof VoucherDetailsPageFormData} onChange={onChange} label={intl.formatMessage(commonMessages.startDate)} value={data.startDate} @@ -59,7 +59,7 @@ const VoucherDates = ({ disabled={disabled} error={!!formErrors.startDate} helperText={getDiscountErrorMessage(formErrors.startDate, intl)} - name={"startTime" as keyof FormData} + name={"startTime" as keyof VoucherDetailsPageFormData} onChange={onChange} label={intl.formatMessage(commonMessages.startHour)} value={data.startTime} @@ -76,7 +76,7 @@ const VoucherDates = ({ defaultMessage: "Set end date", description: "voucher end date, switch button" })} - name={"hasEndDate" as keyof FormData} + name={"hasEndDate" as keyof VoucherDetailsPageFormData} onChange={onChange} /> {data.hasEndDate && ( @@ -85,7 +85,7 @@ const VoucherDates = ({ disabled={disabled} error={!!formErrors.endDate} helperText={getDiscountErrorMessage(formErrors.endDate, intl)} - name={"endDate" as keyof FormData} + name={"endDate" as keyof VoucherDetailsPageFormData} onChange={onChange} label={intl.formatMessage(commonMessages.endDate)} value={data.endDate} @@ -99,7 +99,7 @@ const VoucherDates = ({ disabled={disabled} error={!!formErrors.endDate} helperText={getDiscountErrorMessage(formErrors.endDate, intl)} - name={"endTime" as keyof FormData} + name={"endTime" as keyof VoucherDetailsPageFormData} onChange={onChange} label={intl.formatMessage(commonMessages.endHour)} value={data.endTime} diff --git a/src/discounts/components/VoucherDetailsPage/VoucherDetailsPage.tsx b/src/discounts/components/VoucherDetailsPage/VoucherDetailsPage.tsx index db418d3e6..660a8196c 100644 --- a/src/discounts/components/VoucherDetailsPage/VoucherDetailsPage.tsx +++ b/src/discounts/components/VoucherDetailsPage/VoucherDetailsPage.tsx @@ -47,7 +47,7 @@ export function voucherDetailsPageTab(tab: string): VoucherDetailsPageTab { : VoucherDetailsPageTab.categories; } -export interface FormData { +export interface VoucherDetailsPageFormData { applyOncePerCustomer: boolean; applyOncePerOrder: boolean; code: string; @@ -89,7 +89,7 @@ export interface VoucherDetailsPageProps onProductUnassign: (id: string) => void; onProductClick: (id: string) => () => void; onRemove: () => void; - onSubmit: (data: FormData) => void; + onSubmit: (data: VoucherDetailsPageFormData) => void; onTabClick: (index: VoucherDetailsPageTab) => void; } @@ -141,7 +141,7 @@ const VoucherDetailsPage: React.FC = ({ requirementsPickerInitValue = RequirementsPicker.NONE; } - const initialForm: FormData = { + const initialForm: VoucherDetailsPageFormData = { applyOncePerCustomer: maybe(() => voucher.applyOncePerCustomer, false), applyOncePerOrder: maybe(() => voucher.applyOncePerOrder, false), code: maybe(() => voucher.code, ""), diff --git a/src/discounts/components/VoucherInfo/VoucherInfo.tsx b/src/discounts/components/VoucherInfo/VoucherInfo.tsx index 3b227980f..e773d17a4 100644 --- a/src/discounts/components/VoucherInfo/VoucherInfo.tsx +++ b/src/discounts/components/VoucherInfo/VoucherInfo.tsx @@ -11,10 +11,10 @@ import React from "react"; import { FormattedMessage, useIntl } from "react-intl"; import { generateCode } from "../../../misc"; -import { FormData } from "../VoucherDetailsPage"; +import { VoucherDetailsPageFormData } from "../VoucherDetailsPage"; interface VoucherInfoProps { - data: FormData; + data: VoucherDetailsPageFormData; errors: DiscountErrorFragment[]; disabled: boolean; variant: "create" | "update"; @@ -61,7 +61,7 @@ const VoucherInfo = ({ error={!!formErrors.code} fullWidth helperText={getDiscountErrorMessage(formErrors.code, intl)} - name={"code" as keyof FormData} + name={"code" as keyof VoucherDetailsPageFormData} label={intl.formatMessage({ defaultMessage: "Discount Code" })} diff --git a/src/discounts/components/VoucherLimits/VoucherLimits.tsx b/src/discounts/components/VoucherLimits/VoucherLimits.tsx index 30a377641..35a0bb0c3 100644 --- a/src/discounts/components/VoucherLimits/VoucherLimits.tsx +++ b/src/discounts/components/VoucherLimits/VoucherLimits.tsx @@ -9,10 +9,10 @@ import getDiscountErrorMessage from "@saleor/utils/errors/discounts"; import React from "react"; import { useIntl } from "react-intl"; -import { FormData } from "../VoucherDetailsPage"; +import { VoucherDetailsPageFormData } from "../VoucherDetailsPage"; interface VoucherLimitsProps { - data: FormData; + data: VoucherDetailsPageFormData; defaultCurrency: string; disabled: boolean; errors: DiscountErrorFragment[]; @@ -44,7 +44,7 @@ const VoucherLimits = ({ defaultMessage: "Limit number of times this discount can be used in total" })} - name={"hasUsageLimit" as keyof FormData} + name={"hasUsageLimit" as keyof VoucherDetailsPageFormData} onChange={onChange} /> {data.hasUsageLimit && ( @@ -56,7 +56,7 @@ const VoucherLimits = ({ defaultMessage: "Limit of Uses", description: "voucher" })} - name={"usageLimit" as keyof FormData} + name={"usageLimit" as keyof VoucherDetailsPageFormData} value={data.usageLimit} onChange={onChange} type="number" @@ -72,7 +72,7 @@ const VoucherLimits = ({ defaultMessage: "Limit to one use per customer", description: "limit voucher" })} - name={"applyOncePerCustomer" as keyof FormData} + name={"applyOncePerCustomer" as keyof VoucherDetailsPageFormData} onChange={onChange} />
diff --git a/src/discounts/components/VoucherRequirements/VoucherRequirements.tsx b/src/discounts/components/VoucherRequirements/VoucherRequirements.tsx index da7d8e563..64255c4a0 100644 --- a/src/discounts/components/VoucherRequirements/VoucherRequirements.tsx +++ b/src/discounts/components/VoucherRequirements/VoucherRequirements.tsx @@ -11,10 +11,10 @@ import getDiscountErrorMessage from "@saleor/utils/errors/discounts"; import React from "react"; import { useIntl } from "react-intl"; -import { FormData } from "../VoucherDetailsPage"; +import { VoucherDetailsPageFormData } from "../VoucherDetailsPage"; interface VoucherRequirementsProps { - data: FormData; + data: VoucherDetailsPageFormData; defaultCurrency: string; disabled: boolean; errors: DiscountErrorFragment[]; @@ -73,7 +73,7 @@ const VoucherRequirements = ({ @@ -86,7 +86,7 @@ const VoucherRequirements = ({ error={!!formErrors.minSpent} helperText={getDiscountErrorMessage(formErrors.minSpent, intl)} label={minimalOrderValueText} - name={"minSpent" as keyof FormData} + name={"minSpent" as keyof VoucherDetailsPageFormData} value={data.minSpent} onChange={onChange} fullWidth @@ -100,7 +100,9 @@ const VoucherRequirements = ({ intl )} label={minimalQuantityText} - name={"minCheckoutItemsQuantity" as keyof FormData} + name={ + "minCheckoutItemsQuantity" as keyof VoucherDetailsPageFormData + } value={data.minCheckoutItemsQuantity} onChange={onChange} fullWidth diff --git a/src/discounts/components/VoucherTypes/VoucherTypes.tsx b/src/discounts/components/VoucherTypes/VoucherTypes.tsx index f64e9b4a8..f2e5d7591 100644 --- a/src/discounts/components/VoucherTypes/VoucherTypes.tsx +++ b/src/discounts/components/VoucherTypes/VoucherTypes.tsx @@ -10,10 +10,10 @@ import React from "react"; import { useIntl } from "react-intl"; import { DiscountValueTypeEnum } from "../../../types/globalTypes"; -import { FormData } from "../VoucherDetailsPage"; +import { VoucherDetailsPageFormData } from "../VoucherDetailsPage"; interface VoucherTypesProps { - data: FormData; + data: VoucherDetailsPageFormData; errors: DiscountErrorFragment[]; disabled: boolean; onChange: (event: React.ChangeEvent) => void; @@ -68,7 +68,7 @@ const VoucherTypes = ({ disabled={disabled} error={!!formErrors.discountType} hint={getDiscountErrorMessage(formErrors.discountType, intl)} - name={"discountType" as keyof FormData} + name={"discountType" as keyof VoucherDetailsPageFormData} value={data.discountType} onChange={onChange} /> diff --git a/src/discounts/components/VoucherValue/VoucherValue.tsx b/src/discounts/components/VoucherValue/VoucherValue.tsx index 6c3e1ecf8..b3123cfd5 100644 --- a/src/discounts/components/VoucherValue/VoucherValue.tsx +++ b/src/discounts/components/VoucherValue/VoucherValue.tsx @@ -16,10 +16,10 @@ import { FormattedMessage, useIntl } from "react-intl"; import { DiscountValueTypeEnum } from "../../../types/globalTypes"; import { translateVoucherTypes } from "../../translations"; -import { FormData } from "../VoucherDetailsPage"; +import { VoucherDetailsPageFormData } from "../VoucherDetailsPage"; interface VoucherValueProps { - data: FormData; + data: VoucherDetailsPageFormData; defaultCurrency: string; errors: DiscountErrorFragment[]; disabled: boolean; @@ -74,11 +74,11 @@ const VoucherValue: React.FC = props => { data.discountType === DiscountValueTypeEnum.FIXED ? defaultCurrency : "%", - name: "discountType" as keyof FormData, + name: "discountType" as keyof VoucherDetailsPageFormData, values: null }} helperText={getDiscountErrorMessage(formErrors.discountValue, intl)} - name={"value" as keyof FormData} + name={"value" as keyof VoucherDetailsPageFormData} onChange={onChange} label={intl.formatMessage({ defaultMessage: "Discount Value" @@ -102,7 +102,7 @@ const VoucherValue: React.FC = props => { label={intl.formatMessage({ defaultMessage: "Voucher Specific Information" })} - name={"type" as keyof FormData} + name={"type" as keyof VoucherDetailsPageFormData} value={data.type} onChange={onChange} /> @@ -111,7 +111,7 @@ const VoucherValue: React.FC = props => {
= ({ id, params }) => { ); const intl = useIntl(); const { + loadMore: loadMoreCategories, search: searchCategories, result: searchCategoriesOpts } = useCategorySearch({ variables: DEFAULT_INITIAL_SEARCH_DATA }); const { + loadMore: loadMoreCollections, search: searchCollections, result: searchCollectionsOpts } = useCollectionSearch({ variables: DEFAULT_INITIAL_SEARCH_DATA }); const { + loadMore: loadMoreProducts, search: searchProducts, result: searchProductsOpts } = useProductSearch({ @@ -192,6 +196,30 @@ export const SaleDetails: React.FC = ({ id, params }) => { } }); + const handleSubmit = async ( + data: SaleDetailsPageFormData + ) => { + const result = await saleUpdate({ + variables: { + id, + input: { + endDate: data.hasEndDate + ? joinDateTime(data.endDate, data.endTime) + : null, + name: data.name, + startDate: joinDateTime( + data.startDate, + data.startTime + ), + type: discountValueTypeEnum(data.type), + value: decimal(data.value) + } + } + }); + + return result.data.saleUpdate.errors; + }; + const { loadNextPage, loadPreviousPage, @@ -244,30 +272,7 @@ export const SaleDetails: React.FC = ({ id, params }) => { activeTab={params.activeTab} onBack={() => navigate(saleListUrl())} onTabClick={changeTab} - onSubmit={formData => - saleUpdate({ - variables: { - id, - input: { - endDate: formData.hasEndDate - ? joinDateTime( - formData.endDate, - formData.endTime - ) - : null, - name: formData.name, - startDate: joinDateTime( - formData.startDate, - formData.startTime - ), - type: discountValueTypeEnum( - formData.type - ), - value: decimal(formData.value) - } - } - }) - } + onSubmit={handleSubmit} onRemove={() => openModal("remove")} saveButtonBarState={saleUpdateOpts.status} categoryListToolbar={ @@ -325,8 +330,13 @@ export const SaleDetails: React.FC = ({ id, params }) => { /> @@ -359,8 +369,13 @@ export const SaleDetails: React.FC = ({ id, params }) => { ) )} confirmButtonState={saleCataloguesAddOpts.status} + hasMore={ + searchCategoriesOpts.data?.search.pageInfo + .hasNextPage + } open={params.action === "assign-category"} onFetch={searchCategories} + onFetchMore={loadMoreCategories} loading={searchCategoriesOpts.loading} onClose={closeModal} onSubmit={categories => @@ -369,9 +384,7 @@ export const SaleDetails: React.FC = ({ id, params }) => { ...paginationState, id, input: { - categories: categories.map( - product => product.id - ) + categories } } }) @@ -386,8 +399,13 @@ export const SaleDetails: React.FC = ({ id, params }) => { ) )} confirmButtonState={saleCataloguesAddOpts.status} + hasMore={ + searchCollectionsOpts.data?.search.pageInfo + .hasNextPage + } open={params.action === "assign-collection"} onFetch={searchCollections} + onFetchMore={loadMoreCollections} loading={searchCollectionsOpts.loading} onClose={closeModal} onSubmit={collections => @@ -396,9 +414,7 @@ export const SaleDetails: React.FC = ({ id, params }) => { ...paginationState, id, input: { - collections: collections.map( - product => product.id - ) + collections } } }) diff --git a/src/discounts/views/VoucherDetails.tsx b/src/discounts/views/VoucherDetails.tsx index 866073fde..d227497a2 100644 --- a/src/discounts/views/VoucherDetails.tsx +++ b/src/discounts/views/VoucherDetails.tsx @@ -31,6 +31,7 @@ import { } from "../../types/globalTypes"; import DiscountCountrySelectDialog from "../components/DiscountCountrySelectDialog"; import VoucherDetailsPage, { + VoucherDetailsPageFormData, VoucherDetailsPageTab } from "../components/VoucherDetailsPage"; import { @@ -70,18 +71,21 @@ export const VoucherDetails: React.FC = ({ ); const intl = useIntl(); const { + loadMore: loadMoreCategories, search: searchCategories, result: searchCategoriesOpts } = useCategorySearch({ variables: DEFAULT_INITIAL_SEARCH_DATA }); const { + loadMore: loadMoreCollections, search: searchCollections, result: searchCollectionsOpts } = useCollectionSearch({ variables: DEFAULT_INITIAL_SEARCH_DATA }); const { + loadMore: loadMoreProducts, search: searchProducts, result: searchProductsOpts } = useProductSearch({ @@ -195,6 +199,54 @@ export const VoucherDetails: React.FC = ({ } }); + const handleSubmit = async ( + data: VoucherDetailsPageFormData + ) => { + const result = await voucherUpdate({ + variables: { + id, + input: { + applyOncePerCustomer: data.applyOncePerCustomer, + applyOncePerOrder: data.applyOncePerOrder, + discountValue: + data.discountType.toString() === "SHIPPING" + ? 100 + : decimal(data.value), + discountValueType: + data.discountType.toString() === "SHIPPING" + ? DiscountValueTypeEnum.PERCENTAGE + : data.discountType, + endDate: data.hasEndDate + ? joinDateTime(data.endDate, data.endTime) + : null, + minAmountSpent: + data.requirementsPicker !== + RequirementsPicker.ORDER + ? 0 + : parseFloat(data.minSpent), + minCheckoutItemsQuantity: + data.requirementsPicker !== + RequirementsPicker.ITEM + ? 0 + : parseFloat(data.minCheckoutItemsQuantity), + startDate: joinDateTime( + data.startDate, + data.startTime + ), + type: + data.discountType.toString() === "SHIPPING" + ? VoucherTypeEnum.SHIPPING + : data.type, + usageLimit: data.hasUsageLimit + ? parseInt(data.usageLimit, 10) + : null + } + } + }); + + return result.data.voucherUpdate.errors; + }; + const { loadNextPage, loadPreviousPage, @@ -291,59 +343,7 @@ export const VoucherDetails: React.FC = ({ activeTab={params.activeTab} onBack={() => navigate(voucherListUrl())} onTabClick={changeTab} - onSubmit={formData => - voucherUpdate({ - variables: { - id, - input: { - applyOncePerCustomer: - formData.applyOncePerCustomer, - applyOncePerOrder: - formData.applyOncePerOrder, - discountValue: - formData.discountType.toString() === - "SHIPPING" - ? 100 - : decimal(formData.value), - discountValueType: - formData.discountType.toString() === - "SHIPPING" - ? DiscountValueTypeEnum.PERCENTAGE - : formData.discountType, - endDate: formData.hasEndDate - ? joinDateTime( - formData.endDate, - formData.endTime - ) - : null, - minAmountSpent: - formData.requirementsPicker !== - RequirementsPicker.ORDER - ? 0 - : parseFloat(formData.minSpent), - minCheckoutItemsQuantity: - formData.requirementsPicker !== - RequirementsPicker.ITEM - ? 0 - : parseFloat( - formData.minCheckoutItemsQuantity - ), - startDate: joinDateTime( - formData.startDate, - formData.startTime - ), - type: - formData.discountType.toString() === - "SHIPPING" - ? VoucherTypeEnum.SHIPPING - : formData.type, - usageLimit: formData.hasUsageLimit - ? parseInt(formData.usageLimit, 10) - : null - } - } - }) - } + onSubmit={handleSubmit} onRemove={() => openModal("remove")} saveButtonBarState={voucherUpdateOpts.status} categoryListToolbar={ @@ -410,8 +410,13 @@ export const VoucherDetails: React.FC = ({ confirmButtonState={ voucherCataloguesAddOpts.status } + hasMore={ + searchCategoriesOpts.data?.search.pageInfo + .hasNextPage + } open={params.action === "assign-category"} onFetch={searchCategories} + onFetchMore={loadMoreCategories} loading={searchCategoriesOpts.loading} onClose={closeModal} onSubmit={categories => @@ -420,9 +425,7 @@ export const VoucherDetails: React.FC = ({ ...paginationState, id, input: { - categories: categories.map( - product => product.id - ) + categories } } }) @@ -439,8 +442,13 @@ export const VoucherDetails: React.FC = ({ confirmButtonState={ voucherCataloguesAddOpts.status } + hasMore={ + searchCollectionsOpts.data?.search.pageInfo + .hasNextPage + } open={params.action === "assign-collection"} onFetch={searchCollections} + onFetchMore={loadMoreCollections} loading={searchCollectionsOpts.loading} onClose={closeModal} onSubmit={collections => @@ -449,9 +457,7 @@ export const VoucherDetails: React.FC = ({ ...paginationState, id, input: { - collections: collections.map( - product => product.id - ) + collections } } }) @@ -484,8 +490,13 @@ export const VoucherDetails: React.FC = ({ confirmButtonState={ voucherCataloguesAddOpts.status } + hasMore={ + searchProductsOpts.data?.search.pageInfo + .hasNextPage + } open={params.action === "assign-product"} onFetch={searchProducts} + onFetchMore={loadMoreProducts} loading={searchProductsOpts.loading} onClose={closeModal} onSubmit={products => diff --git a/src/hooks/useAddressValidation.ts b/src/hooks/useAddressValidation.ts index de9f81bad..8a042bed8 100644 --- a/src/hooks/useAddressValidation.ts +++ b/src/hooks/useAddressValidation.ts @@ -5,14 +5,14 @@ import { AccountErrorCode, AddressInput } from "@saleor/types/globalTypes"; import { add, remove } from "@saleor/utils/lists"; import { useState } from "react"; -interface UseAddressValidation { +interface UseAddressValidation { errors: AccountErrorFragment[]; - submit: (data: T & AddressTypeInput) => void; + submit: (data: TInput & AddressTypeInput) => TOutput; } -function useAddressValidation( - onSubmit: (address: T & AddressInput) => void -): UseAddressValidation { +function useAddressValidation( + onSubmit: (address: TInput & AddressInput) => TOutput +): UseAddressValidation { const [validationErrors, setValidationErrors] = useState< AccountErrorFragment[] >([]); @@ -25,7 +25,7 @@ function useAddressValidation( return { errors: validationErrors, - submit: (data: T & AddressTypeInput) => { + submit: (data: TInput & AddressTypeInput) => { try { setValidationErrors( remove( @@ -34,7 +34,7 @@ function useAddressValidation( (a, b) => a.field === b.field ) ); - onSubmit(transformFormToAddress(data)); + return onSubmit(transformFormToAddress(data)); } catch { setValidationErrors(add(countryRequiredError, validationErrors)); } diff --git a/src/hooks/useForm.ts b/src/hooks/useForm.ts index 752030949..5475777e3 100644 --- a/src/hooks/useForm.ts +++ b/src/hooks/useForm.ts @@ -10,6 +10,7 @@ export interface ChangeEvent { value: TData; }; } +export type SubmitPromise = Promise; export type FormChange = (event: ChangeEvent, cb?: () => void) => void; @@ -51,7 +52,7 @@ function handleRefresh( function useForm( initial: T, - onSubmit: (data: T) => void + onSubmit?: (data: T) => SubmitPromise | void ): UseFormResult { const [hasChanged, setChanged] = useState(false); const [data, setData] = useStateFromProps(initial, { @@ -106,8 +107,16 @@ function useForm( })); } - function submit() { - return onSubmit(data); + async function submit() { + if (typeof onSubmit === "function") { + const result = onSubmit(data); + if (result) { + const errors = await result; + if (errors.length === 0) { + setChanged(false); + } + } + } } function triggerChange() { diff --git a/src/navigation/components/MenuDetailsPage/MenuDetailsPage.tsx b/src/navigation/components/MenuDetailsPage/MenuDetailsPage.tsx index 93c093ff8..9d0a6ae06 100644 --- a/src/navigation/components/MenuDetailsPage/MenuDetailsPage.tsx +++ b/src/navigation/components/MenuDetailsPage/MenuDetailsPage.tsx @@ -7,6 +7,7 @@ import Form from "@saleor/components/Form"; import Grid from "@saleor/components/Grid"; import SaveButtonBar from "@saleor/components/SaveButtonBar"; import { MenuErrorFragment } from "@saleor/fragments/types/MenuErrorFragment"; +import { SubmitPromise } from "@saleor/hooks/useForm"; import { sectionNames } from "@saleor/intl"; import React from "react"; import { FormattedMessage, useIntl } from "react-intl"; @@ -36,7 +37,7 @@ export interface MenuDetailsPageProps { onItemAdd: () => void; onItemClick: (id: string, type: MenuItemType) => void; onItemEdit: (id: string) => void; - onSubmit: (data: MenuDetailsSubmitData) => Promise; + onSubmit: (data: MenuDetailsSubmitData) => SubmitPromise; } const MenuDetailsPage: React.FC = ({ @@ -62,14 +63,16 @@ const MenuDetailsPage: React.FC = ({ ); const handleSubmit = async (data: MenuDetailsFormData) => { - if ( - await onSubmit({ - name: data.name, - operations: treeOperations - }) - ) { + const result = await onSubmit({ + name: data.name, + operations: treeOperations + }); + + if (result) { setTreeOperations([]); } + + return result; }; const handleChange = (operation: TreeOperation) => { diff --git a/src/navigation/views/MenuDetails/index.tsx b/src/navigation/views/MenuDetails/index.tsx index d33d5ddcc..4f9756082 100644 --- a/src/navigation/views/MenuDetails/index.tsx +++ b/src/navigation/views/MenuDetails/index.tsx @@ -141,28 +141,20 @@ const MenuDetails: React.FC = ({ id, params }) => { // that it should clean operation stack if mutations // were successful const handleSubmit = async (data: MenuDetailsSubmitData) => { - try { - const result = await menuUpdate({ - variables: { - id, - moves: getMoves(data), - name: data.name, - removeIds: getRemoveIds(data) - } - }); - if (result) { - if ( - result.data.menuItemBulkDelete.errors.length > 0 || - result.data.menuItemMove.errors.length > 0 || - result.data.menuUpdate.errors.length > 0 - ) { - return false; - } + const result = await menuUpdate({ + variables: { + id, + moves: getMoves(data), + name: data.name, + removeIds: getRemoveIds(data) } - return true; - } catch { - return false; - } + }); + + return [ + ...result.data.menuItemBulkDelete.errors, + ...result.data.menuItemMove.errors, + ...result.data.menuUpdate.errors + ]; }; return ( diff --git a/src/orders/components/OrderDetailsPage/OrderDetailsPage.tsx b/src/orders/components/OrderDetailsPage/OrderDetailsPage.tsx index eb5ad5d68..732e3970f 100644 --- a/src/orders/components/OrderDetailsPage/OrderDetailsPage.tsx +++ b/src/orders/components/OrderDetailsPage/OrderDetailsPage.tsx @@ -12,6 +12,7 @@ import Metadata, { MetadataFormData } from "@saleor/components/Metadata"; import PageHeader from "@saleor/components/PageHeader"; import SaveButtonBar from "@saleor/components/SaveButtonBar"; import Skeleton from "@saleor/components/Skeleton"; +import { SubmitPromise } from "@saleor/hooks/useForm"; import { sectionNames } from "@saleor/intl"; import { UserPermissionProps } from "@saleor/types"; import { mapMetadataItemToInput } from "@saleor/utils/maps"; @@ -74,7 +75,7 @@ export interface OrderDetailsPageProps extends UserPermissionProps { onInvoiceClick(invoiceId: string); onInvoiceGenerate(); onInvoiceSend(invoiceId: string); - onSubmit(data: MetadataFormData); + onSubmit(data: MetadataFormData): SubmitPromise; } const OrderDetailsPage: React.FC = props => { @@ -123,7 +124,7 @@ const OrderDetailsPage: React.FC = props => { ? data.privateMetadata : undefined; - onSubmit({ + return onSubmit({ metadata, privateMetadata }); diff --git a/src/orders/views/OrderDetails/index.tsx b/src/orders/views/OrderDetails/index.tsx index 89482df93..4d843b793 100644 --- a/src/orders/views/OrderDetails/index.tsx +++ b/src/orders/views/OrderDetails/index.tsx @@ -122,6 +122,8 @@ export const OrderDetails: React.FC = ({ id, params }) => { text: intl.formatMessage(commonMessages.savedChanges) }); } + + return result; }; return ( diff --git a/src/pages/components/PageDetailsPage/PageDetailsPage.tsx b/src/pages/components/PageDetailsPage/PageDetailsPage.tsx index 8350b9b99..739433f0c 100644 --- a/src/pages/components/PageDetailsPage/PageDetailsPage.tsx +++ b/src/pages/components/PageDetailsPage/PageDetailsPage.tsx @@ -11,6 +11,7 @@ import SeoForm from "@saleor/components/SeoForm"; import VisibilityCard from "@saleor/components/VisibilityCard"; import { PageErrorFragment } from "@saleor/fragments/types/PageErrorFragment"; import useDateLocalize from "@saleor/hooks/useDateLocalize"; +import { SubmitPromise } from "@saleor/hooks/useForm"; import { sectionNames } from "@saleor/intl"; import { mapMetadataItemToInput } from "@saleor/utils/maps"; import useMetadataChangeTrigger from "@saleor/utils/metadata/useMetadataChangeTrigger"; @@ -27,7 +28,7 @@ import { maybe } from "../../../misc"; import { PageDetails_page } from "../../types/PageDetails"; import PageInfo from "../PageInfo"; -export interface FormData extends MetadataFormData { +export interface PageDetailsPageFormData extends MetadataFormData { content: RawDraftContentState; isPublished: boolean; publicationDate: string; @@ -45,7 +46,7 @@ export interface PageDetailsPageProps { saveButtonBarState: ConfirmButtonTransitionState; onBack: () => void; onRemove: () => void; - onSubmit: (data: FormData) => void; + onSubmit: (data: PageDetailsPageFormData) => SubmitPromise; } const PageDetailsPage: React.FC = ({ @@ -67,7 +68,7 @@ const PageDetailsPage: React.FC = ({ const pageExists = page !== null; - const initialForm: FormData = { + const initialForm: PageDetailsPageFormData = { content: maybe( () => JSON.parse(page.contentJson), convertToRaw(ContentState.createFromText("")) @@ -84,13 +85,13 @@ const PageDetailsPage: React.FC = ({ title: page?.title || "" }; - const handleSubmit = (data: FormData) => { + const handleSubmit = (data: PageDetailsPageFormData) => { const metadata = isMetadataModified ? data.metadata : undefined; const privateMetadata = isPrivateMetadataModified ? data.privateMetadata : undefined; - onSubmit({ + return onSubmit({ ...data, isPublished: data.isPublished || !!data.publicationDate, metadata, diff --git a/src/pages/components/PageInfo/PageInfo.tsx b/src/pages/components/PageInfo/PageInfo.tsx index c89a2883f..12c2ecdeb 100644 --- a/src/pages/components/PageInfo/PageInfo.tsx +++ b/src/pages/components/PageInfo/PageInfo.tsx @@ -14,10 +14,10 @@ import { useIntl } from "react-intl"; import { maybe } from "../../../misc"; import { PageDetails_page } from "../../types/PageDetails"; -import { FormData } from "../PageDetailsPage"; +import { PageDetailsPageFormData } from "../PageDetailsPage"; export interface PageInfoProps { - data: FormData; + data: PageDetailsPageFormData; disabled: boolean; errors: PageErrorFragment[]; page: PageDetails_page; @@ -56,7 +56,7 @@ const PageInfo: React.FC = props => { defaultMessage: "Title", description: "page title" })} - name={"title" as keyof FormData} + name={"title" as keyof PageDetailsPageFormData} value={data.title} onChange={onChange} /> @@ -70,7 +70,7 @@ const PageInfo: React.FC = props => { defaultMessage: "Content", description: "page content" })} - name={"content" as keyof FormData} + name={"content" as keyof PageDetailsPageFormData} onChange={onChange} /> diff --git a/src/pages/views/PageCreate.tsx b/src/pages/views/PageCreate.tsx index 3abf998d9..4c5fb0fb8 100644 --- a/src/pages/views/PageCreate.tsx +++ b/src/pages/views/PageCreate.tsx @@ -9,7 +9,9 @@ import { import React from "react"; import { useIntl } from "react-intl"; -import PageDetailsPage, { FormData } from "../components/PageDetailsPage"; +import PageDetailsPage, { + PageDetailsPageFormData +} from "../components/PageDetailsPage"; import { TypedPageCreate } from "../mutations"; import { PageCreate as PageCreateData } from "../types/PageCreate"; import { pageListUrl, pageUrl } from "../urls"; @@ -40,7 +42,7 @@ export const PageCreate: React.FC = () => { return ( {(pageCreate, pageCreateOpts) => { - const handleCreate = async (formData: FormData) => { + const handleCreate = async (formData: PageDetailsPageFormData) => { const result = await pageCreate({ variables: { input: { diff --git a/src/pages/views/PageDetails.tsx b/src/pages/views/PageDetails.tsx index ded216691..7b159d7de 100644 --- a/src/pages/views/PageDetails.tsx +++ b/src/pages/views/PageDetails.tsx @@ -14,7 +14,9 @@ import { FormattedMessage, useIntl } from "react-intl"; import { getStringOrPlaceholder, maybe } from "../../misc"; import { PageInput } from "../../types/globalTypes"; -import PageDetailsPage, { FormData } from "../components/PageDetailsPage"; +import PageDetailsPage, { + PageDetailsPageFormData +} from "../components/PageDetailsPage"; import { TypedPageRemove, TypedPageUpdate } from "../mutations"; import { TypedPageDetailsQuery } from "../queries"; import { PageRemove } from "../types/PageRemove"; @@ -25,7 +27,7 @@ export interface PageDetailsProps { params: PageUrlQueryParams; } -const createPageInput = (data: FormData): PageInput => ({ +const createPageInput = (data: PageDetailsPageFormData): PageInput => ({ contentJson: JSON.stringify(data.content), isPublished: data.isPublished, publicationDate: data.publicationDate, @@ -61,7 +63,7 @@ export const PageDetails: React.FC = ({ id, params }) => { {(pageUpdate, pageUpdateOpts) => ( {pageDetails => { - const handleUpdate = async (data: FormData) => { + const handleUpdate = async (data: PageDetailsPageFormData) => { const result = await pageUpdate({ variables: { id, diff --git a/src/permissionGroups/components/PermissionGroupDetailsPage/PermissionGroupDetailsPage.tsx b/src/permissionGroups/components/PermissionGroupDetailsPage/PermissionGroupDetailsPage.tsx index 996afa327..fc5c41538 100644 --- a/src/permissionGroups/components/PermissionGroupDetailsPage/PermissionGroupDetailsPage.tsx +++ b/src/permissionGroups/components/PermissionGroupDetailsPage/PermissionGroupDetailsPage.tsx @@ -9,6 +9,7 @@ import PageHeader from "@saleor/components/PageHeader"; import SaveButtonBar from "@saleor/components/SaveButtonBar"; import { ShopInfo_shop_permissions } from "@saleor/components/Shop/types/ShopInfo"; import { PermissionGroupErrorFragment } from "@saleor/fragments/types/PermissionGroupErrorFragment"; +import { SubmitPromise } from "@saleor/hooks/useForm"; import { sectionNames } from "@saleor/intl"; import { MembersListUrlSortField } from "@saleor/permissionGroups/urls"; import { @@ -56,7 +57,7 @@ export interface PermissionGroupDetailsPageProps onAssign: () => void; onBack: () => void; onUnassign: (ids: string[]) => void; - onSubmit(data: PermissionGroupDetailsPageFormData); + onSubmit: (data: PermissionGroupDetailsPageFormData) => SubmitPromise; } const PermissionGroupDetailsPage: React.FC = ({ diff --git a/src/permissionGroups/views/PermissionGroupDetails/PermissionGroupDetails.tsx b/src/permissionGroups/views/PermissionGroupDetails/PermissionGroupDetails.tsx index 6294c993b..7bdd746d1 100644 --- a/src/permissionGroups/views/PermissionGroupDetails/PermissionGroupDetails.tsx +++ b/src/permissionGroups/views/PermissionGroupDetails/PermissionGroupDetails.tsx @@ -21,7 +21,9 @@ import React, { useState } from "react"; import { useIntl } from "react-intl"; import AssignMembersDialog from "../../components/AssignMembersDialog"; -import PermissionGroupDetailsPage from "../../components/PermissionGroupDetailsPage"; +import PermissionGroupDetailsPage, { + PermissionGroupDetailsPageFormData +} from "../../components/PermissionGroupDetailsPage"; import UnassignMembersDialog from "../../components/UnassignMembersDialog"; import { usePermissionGroupUpdate } from "../../mutations"; import { usePermissionGroupDetailsQuery } from "../../queries"; @@ -131,6 +133,21 @@ export const PermissionGroupDetails: React.FC = ({ ); const disabled = loading || !isGroupEditable || permissionsExceeded; + const handleSubmit = async (formData: PermissionGroupDetailsPageFormData) => { + const result = await permissionGroupUpdate({ + variables: { + id, + input: { + name: formData.name, + ...permissionsDiff(data?.permissionGroup, formData), + ...usersDiff(data?.permissionGroup, formData) + } + } + }); + + return result.data.permissionGroupUpdate.errors; + }; + return ( <> = ({ errors={ permissionGroupUpdateResult?.data?.permissionGroupUpdate.errors || [] } - onSubmit={formData => - permissionGroupUpdate({ - variables: { - id, - input: { - name: formData.name, - ...permissionsDiff(data?.permissionGroup, formData), - ...usersDiff(data?.permissionGroup, formData) - } - } - }) - } + onSubmit={handleSubmit} permissions={permissions} saveButtonBarState={permissionGroupUpdateResult.status} disabled={disabled} diff --git a/src/plugins/components/PluginInfo/PluginInfo.tsx b/src/plugins/components/PluginInfo/PluginInfo.tsx index 861c6c8eb..9e8f0ce50 100644 --- a/src/plugins/components/PluginInfo/PluginInfo.tsx +++ b/src/plugins/components/PluginInfo/PluginInfo.tsx @@ -13,10 +13,10 @@ import getPluginErrorMessage from "@saleor/utils/errors/plugins"; import React from "react"; import { FormattedMessage, useIntl } from "react-intl"; -import { FormData } from "../PluginsDetailsPage"; +import { PluginDetailsPageFormData } from "../PluginsDetailsPage"; interface PluginInfoProps { - data: FormData; + data: PluginDetailsPageFormData; description: string; errors: PluginErrorFragment[]; name: string; @@ -83,7 +83,7 @@ const PluginInfo: React.FC = ({ })} ) => void; diff --git a/src/plugins/components/PluginsDetailsPage/PluginsDetailsPage.tsx b/src/plugins/components/PluginsDetailsPage/PluginsDetailsPage.tsx index 329e94871..be1e1d2c7 100644 --- a/src/plugins/components/PluginsDetailsPage/PluginsDetailsPage.tsx +++ b/src/plugins/components/PluginsDetailsPage/PluginsDetailsPage.tsx @@ -23,7 +23,7 @@ import PluginAuthorization from "../PluginAuthorization"; import PluginInfo from "../PluginInfo"; import PluginSettings from "../PluginSettings"; -export interface FormData { +export interface PluginDetailsPageFormData { active: boolean; configuration: ConfigurationItemInput[]; } @@ -36,7 +36,7 @@ export interface PluginsDetailsPageProps { onBack: () => void; onClear: (field: string) => void; onEdit: (field: string) => void; - onSubmit: (data: FormData) => void; + onSubmit: (data: PluginDetailsPageFormData) => void; } const useStyles = makeStyles( @@ -64,7 +64,7 @@ const PluginsDetailsPage: React.FC = props => { const classes = useStyles(props); const intl = useIntl(); - const initialForm: FormData = { + const initialForm: PluginDetailsPageFormData = { active: plugin?.active || false, configuration: plugin?.configuration ?.filter(field => !isSecretField(plugin?.configuration || [], field.name)) diff --git a/src/plugins/views/PluginsDetails.tsx b/src/plugins/views/PluginsDetails.tsx index 74b719024..56cf7f072 100644 --- a/src/plugins/views/PluginsDetails.tsx +++ b/src/plugins/views/PluginsDetails.tsx @@ -9,7 +9,9 @@ import createDialogActionHandlers from "@saleor/utils/handlers/dialogActionHandl import React from "react"; import { FormattedMessage, useIntl } from "react-intl"; -import PluginsDetailsPage from "../components/PluginsDetailsPage"; +import PluginsDetailsPage, { + PluginDetailsPageFormData +} from "../components/PluginsDetailsPage"; import PluginSecretFieldDialog from "../components/PluginSecretFieldDialog"; import { TypedPluginUpdate } from "../mutations"; import { TypedPluginsDetailsQuery } from "../queries"; @@ -89,6 +91,25 @@ export const PluginsDetails: React.FC = ({ } }); + const handleSubmit = async ( + formData: PluginDetailsPageFormData + ) => { + const result = await pluginUpdate({ + variables: { + id, + input: { + active: formData.active, + configuration: getConfigurationInput( + pluginDetails.data.plugin.configuration, + formData.configuration + ) + } + } + }); + + return result.data.pluginUpdate.errors; + }; + return ( <> @@ -110,20 +131,7 @@ export const PluginsDetails: React.FC = ({ id }) } - onSubmit={formData => - pluginUpdate({ - variables: { - id, - input: { - active: formData.active, - configuration: getConfigurationInput( - pluginDetails.data.plugin.configuration, - formData.configuration - ) - } - } - }) - } + onSubmit={handleSubmit} /> {pluginDetails.data?.plugin?.configuration && ( <> diff --git a/src/productTypes/components/ProductTypeDetailsPage/ProductTypeDetailsPage.tsx b/src/productTypes/components/ProductTypeDetailsPage/ProductTypeDetailsPage.tsx index a4a5391df..e5e8bbf48 100644 --- a/src/productTypes/components/ProductTypeDetailsPage/ProductTypeDetailsPage.tsx +++ b/src/productTypes/components/ProductTypeDetailsPage/ProductTypeDetailsPage.tsx @@ -9,7 +9,7 @@ import Metadata from "@saleor/components/Metadata/Metadata"; import { MetadataFormData } from "@saleor/components/Metadata/types"; import PageHeader from "@saleor/components/PageHeader"; import SaveButtonBar from "@saleor/components/SaveButtonBar"; -import { ChangeEvent, FormChange } from "@saleor/hooks/useForm"; +import { ChangeEvent, FormChange, SubmitPromise } from "@saleor/hooks/useForm"; import useStateFromProps from "@saleor/hooks/useStateFromProps"; import { sectionNames } from "@saleor/intl"; import { maybe } from "@saleor/misc"; @@ -61,7 +61,7 @@ export interface ProductTypeDetailsPageProps { onBack: () => void; onDelete: () => void; onHasVariantsToggle: (hasVariants: boolean) => void; - onSubmit: (data: ProductTypeForm) => void; + onSubmit: (data: ProductTypeForm) => SubmitPromise; } function handleTaxTypeChange( @@ -141,7 +141,7 @@ const ProductTypeDetailsPage: React.FC = ({ ? data.privateMetadata : undefined; - onSubmit({ + return onSubmit({ ...data, metadata, privateMetadata diff --git a/src/products/components/ProductCreatePage/ProductCreatePage.tsx b/src/products/components/ProductCreatePage/ProductCreatePage.tsx index b7ace4373..ffebe8145 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,50 +114,23 @@ 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); + + {({ change, data, handlers, hasChanged, submit }) => { + // Comparing explicitly to false because `hasVariants` can be undefined + const isSimpleProduct = data.productType?.hasVariants === false; return ( @@ -278,17 +148,17 @@ export const ProductCreatePage: React.FC = ({ onChange={change} /> - {attributes.length > 0 && ( + {data.attributes.length > 0 && ( )} - {!!productType && !productType.hasVariants && ( + {isSimpleProduct && ( <> = ({ hasVariants={false} onFormDataChange={change} errors={errors} - stocks={stocks} + stocks={data.stocks} warehouses={warehouses} - onChange={(id, value) => { - 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); - }} + onChange={handlers.changeStock} + onWarehouseStockAdd={handlers.addStock} + onWarehouseStockDelete={handlers.deleteStock} onWarehouseConfigure={onWarehouseConfigure} /> @@ -353,7 +207,7 @@ export const ProductCreatePage: React.FC = ({ onChange={change} /> - +
= ({ fetchMoreCollections={fetchMoreCollections} fetchMoreProductTypes={fetchMoreProductTypes} fetchProductTypes={fetchProductTypes} - productType={productType} - productTypeInputDisplayValue={productType?.name || ""} + productType={data.productType} + productTypeInputDisplayValue={data.productType?.name || ""} productTypes={productTypes} - onCategoryChange={handleCategorySelect} - onCollectionChange={handleCollectionSelect} - onProductTypeChange={handleProductTypeSelect} + onCategoryChange={handlers.selectCategory} + onCollectionChange={handlers.selectCollection} + onProductTypeChange={handlers.selectProductType} collectionsInputDisplayValue={selectedCollections} /> @@ -409,7 +263,7 @@ export const ProductCreatePage: React.FC = ({ data={data} disabled={disabled} onChange={change} - onTaxTypeChange={handleTaxTypeSelect} + onTaxTypeChange={handlers.selectTaxRate} selectedTaxTypeDisplayName={selectedTaxType} taxTypes={taxTypes} /> @@ -424,7 +278,7 @@ export const ProductCreatePage: React.FC = ({ ); }} - + ); }; 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..22dc9a58a --- /dev/null +++ b/src/products/components/ProductCreatePage/form.tsx @@ -0,0 +1,254 @@ +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[]; +} + +type ProductCreateHandlers = Record< + | "changeMetadata" + | "selectCategory" + | "selectCollection" + | "selectProductType" + | "selectTaxRate", + FormChange +> & + Record< + "changeStock" | "selectAttribute" | "selectAttributeMultiple", + FormsetChange + > & + Record<"addStock" | "deleteStock", (id: string) => void>; +export interface UseProductCreateFormResult { + change: FormChange; + data: ProductCreateData; + handlers: ProductCreateHandlers; + 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 handleChange: FormChange = (event, cb) => { + form.change(event, cb); + triggerChange(); + }; + const handleCollectionSelect = createMultiAutocompleteSelectHandler( + form.toggleValue, + opts.setSelectedCollections, + opts.selectedCollections, + opts.collections + ); + const handleCategorySelect = createSingleAutocompleteSelectHandler( + handleChange, + 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( + handleChange, + opts.setSelectedTaxType, + opts.taxTypes + ); + const changeMetadata = makeMetadataChangeHandler(handleChange); + + const data: ProductCreateData = { + ...form.data, + attributes: attributes.data, + productType, + stocks: stocks.data + }; + const submit = () => onSubmit(data); + + return { + change: handleChange, + 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/ProductCreatePage/index.ts b/src/products/components/ProductCreatePage/index.ts index 7174a4e9d..ba27080d3 100644 --- a/src/products/components/ProductCreatePage/index.ts +++ b/src/products/components/ProductCreatePage/index.ts @@ -1,2 +1,3 @@ export { default } from "./ProductCreatePage"; export * from "./ProductCreatePage"; +export * from "./form"; 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/components/ProductStocks/ProductStocks.tsx b/src/products/components/ProductStocks/ProductStocks.tsx index f06c7bd1d..4484b6840 100644 --- a/src/products/components/ProductStocks/ProductStocks.tsx +++ b/src/products/components/ProductStocks/ProductStocks.tsx @@ -127,9 +127,10 @@ const ProductStocks: React.FC = ({ const anchor = React.useRef(); const [isExpanded, setExpansionState] = React.useState(false); - const warehousesToAssign = warehouses.filter( - warehouse => !stocks.some(stock => stock.id === warehouse.id) - ); + const warehousesToAssign = + warehouses?.filter( + warehouse => !stocks.some(stock => stock.id === warehouse.id) + ) || []; const formErrors = getFormErrors(["sku"], errors); return ( @@ -187,7 +188,7 @@ const ProductStocks: React.FC = ({
- {!warehouses.length && ( + {!warehouses?.length && ( {hasVariants ? ( <> @@ -219,7 +220,7 @@ const ProductStocks: React.FC = ({ )}
- {warehouses.length > 0 && ( + {warehouses?.length > 0 && ( diff --git a/src/products/components/ProductUpdatePage/ProductUpdatePage.tsx b/src/products/components/ProductUpdatePage/ProductUpdatePage.tsx index 623da6239..3e99347e2 100644 --- a/src/products/components/ProductUpdatePage/ProductUpdatePage.tsx +++ b/src/products/components/ProductUpdatePage/ProductUpdatePage.tsx @@ -3,7 +3,6 @@ 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 from "@saleor/components/Metadata/Metadata"; import PageHeader from "@saleor/components/PageHeader"; @@ -13,18 +12,14 @@ import { ProductErrorWithAttributesFragment } from "@saleor/fragments/types/Prod import { TaxTypeFragment } from "@saleor/fragments/types/TaxTypeFragment"; import { WarehouseFragment } from "@saleor/fragments/types/WarehouseFragment"; import useDateLocalize from "@saleor/hooks/useDateLocalize"; -import useFormset from "@saleor/hooks/useFormset"; +import { SubmitPromise } from "@saleor/hooks/useForm"; import useStateFromProps from "@saleor/hooks/useStateFromProps"; import { sectionNames } from "@saleor/intl"; import { maybe } from "@saleor/misc"; import { SearchCategories_search_edges_node } from "@saleor/searches/types/SearchCategories"; import { SearchCollections_search_edges_node } from "@saleor/searches/types/SearchCollections"; import { FetchMoreProps, ListActions, ReorderAction } from "@saleor/types"; -import createMultiAutocompleteSelectHandler from "@saleor/utils/handlers/multiAutocompleteSelectChangeHandler"; -import createSingleAutocompleteSelectHandler from "@saleor/utils/handlers/singleAutocompleteSelectChangeHandler"; -import useMetadataChangeTrigger from "@saleor/utils/metadata/useMetadataChangeTrigger"; import { convertFromRaw, RawDraftContentState } from "draft-js"; -import { diff } from "fast-array-diff"; import React from "react"; import { useIntl } from "react-intl"; @@ -33,17 +28,7 @@ import { ProductDetails_product_images, ProductDetails_product_variants } from "../../types/ProductDetails"; -import { - getAttributeInputFromProduct, - getChoices, - getProductUpdatePageFormData, - getStockInputFromProduct, - ProductUpdatePageFormData -} from "../../utils/data"; -import { - createAttributeChangeHandler, - createAttributeMultiChangeHandler -} from "../../utils/handlers"; +import { getChoices, ProductUpdatePageFormData } from "../../utils/data"; import ProductAttributes, { ProductAttributeInput } from "../ProductAttributes"; import ProductDetailsForm from "../ProductDetailsForm"; import ProductImages from "../ProductImages"; @@ -53,6 +38,7 @@ import ProductShipping from "../ProductShipping/ProductShipping"; import ProductStocks, { ProductStockInput } from "../ProductStocks"; import ProductTaxes from "../ProductTaxes"; import ProductVariants from "../ProductVariants"; +import ProductUpdateForm from "./form"; export interface ProductUpdatePageProps extends ListActions { defaultWeightUnit: string; @@ -76,13 +62,13 @@ export interface ProductUpdatePageProps extends ListActions { onVariantShow: (id: string) => () => void; onVariantReorder: ReorderAction; onImageDelete: (id: string) => () => void; + onSubmit: (data: ProductUpdatePageSubmitData) => SubmitPromise; onBack?(); onDelete(); onImageEdit?(id: string); onImageReorder?(event: { oldIndex: number; newIndex: number }); onImageUpload(file: File); onSeoClick?(); - onSubmit?(data: ProductUpdatePageSubmitData); onVariantAdd?(); onSetDefaultVariant(variant: ProductDetails_product_variants); onWarehouseConfigure(); @@ -136,25 +122,9 @@ export const ProductUpdatePage: React.FC = ({ }) => { const intl = useIntl(); const localizeDate = useDateLocalize(); - const attributeInput = React.useMemo( - () => getAttributeInputFromProduct(product), - [product] - ); - const stockInput = React.useMemo(() => getStockInputFromProduct(product), [ - product - ]); - const { change: changeAttributeData, data: attributes } = useFormset( - attributeInput - ); - const { - add: addStock, - change: changeStockData, - data: stocks, - remove: removeStock - } = useFormset(stockInput); const [selectedCategory, setSelectedCategory] = useStateFromProps( - maybe(() => product.category.name, "") + product?.category?.name || "" ); const [selectedCollections, setSelectedCollections] = useStateFromProps( @@ -165,309 +135,217 @@ export const ProductUpdatePage: React.FC = ({ product?.taxType.description ); - const { - isMetadataModified, - isPrivateMetadataModified, - makeChangeHandler: makeMetadataChangeHandler - } = useMetadataChangeTrigger(); - - const initialData = getProductUpdatePageFormData(product, variants); const initialDescription = maybe(() => JSON.parse(product.descriptionJson) ); const categories = getChoices(categoryChoiceList); const collections = getChoices(collectionChoiceList); - const currency = - product?.variants?.length && product.variants[0].price.currency; - const hasVariants = maybe(() => product.productType.hasVariants, false); + const currency = product?.variants[0]?.price.currency; + const hasVariants = product?.productType?.hasVariants; const taxTypeChoices = taxTypes?.map(taxType => ({ label: taxType.description, value: taxType.taxCode })) || []; - const getAvailabilityData = ({ - availableForPurchase, - isAvailableForPurchase, - isPublished, - publicationDate - }: ProductUpdatePageFormData) => ({ - isAvailableForPurchase: isAvailableForPurchase || !!availableForPurchase, - isPublished: isPublished || !!publicationDate - }); - - const getStocksData = () => { - if (product.productType.hasVariants) { - return { addStocks: [], removeStocks: [], updateStocks: [] }; - } - - const dataStocks = stocks.map(stock => stock.id); - const variantStocks = product.variants[0]?.stocks.map( - stock => stock.warehouse.id - ); - const stockDiff = diff(variantStocks, dataStocks); - - return { - addStocks: stocks.filter(stock => - stockDiff.added.some(addedStock => addedStock === stock.id) - ), - removeStocks: stockDiff.removed, - updateStocks: stocks.filter( - stock => !stockDiff.added.some(addedStock => addedStock === stock.id) - ) - }; - }; - - const getMetadata = (data: ProductUpdatePageFormData) => ({ - metadata: isMetadataModified ? data.metadata : undefined, - privateMetadata: isPrivateMetadataModified - ? data.privateMetadata - : undefined - }); - - const getParsedData = (data: ProductUpdatePageFormData) => ({ - ...data, - ...getAvailabilityData(data), - ...getStocksData(), - ...getMetadata(data), - attributes - }); - - const handleSubmit = (data: ProductUpdatePageFormData) => - onSubmit(getParsedData(data)); - 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 changeMetadata = makeMetadataChangeHandler(change); - const handleTaxTypeSelect = createSingleAutocompleteSelectHandler( - change, - setSelectedTaxType, - taxTypeChoices - ); - - return ( - <> - - - {intl.formatMessage(sectionNames.products)} - - - -
- + {({ change, data, handlers, hasChanged, submit }) => ( + <> + + + {intl.formatMessage(sectionNames.products)} + + + +
+ + + + + {data.attributes.length > 0 && ( + - - - - {attributes.length > 0 && ( - + {!!product?.productType && !hasVariants && ( + <> + - )} - - {!!product?.productType && !hasVariants && ( - <> - - - - )} - {hasVariants ? ( - + + )} + {hasVariants ? ( + + ) : ( + <> + + + + + )} + + + convertFromRaw(data.description) + .getPlainText() + .slice(0, 300) + )} + slug={data.slug} + slugPlaceholder={data.name} + loading={disabled} + onClick={onSeoClick} + onChange={change} + helperText={intl.formatMessage({ + defaultMessage: + "Add search engine title and description to make this product easier to find" + })} + /> + + +
+
+ + + - ) : ( - <> - - - { - triggerChange(); - changeStockData(id, value); - }} - onFormDataChange={change} - onWarehouseStockAdd={id => { - triggerChange(); - addStock({ - data: null, - id, - label: warehouses.find( - warehouse => warehouse.id === id - ).name, - value: "0" - }); - }} - onWarehouseStockDelete={id => { - triggerChange(); - removeStock(id); - }} - onWarehouseConfigure={onWarehouseConfigure} - /> - - )} - - - convertFromRaw(data.description) - .getPlainText() - .slice(0, 300) - )} - slug={data.slug} - slugPlaceholder={data.name} - loading={disabled} - onClick={onSeoClick} - onChange={change} - helperText={intl.formatMessage({ - defaultMessage: - "Add search engine title and description to make this product easier to find" - })} - /> - - -
-
- product.productType)} - onCategoryChange={handleCategorySelect} - onCollectionChange={handleCollectionSelect} - /> - - - - -
-
- -
- - ); - }} - + ), + visibleLabel: intl.formatMessage({ + defaultMessage: "Published", + description: "product label" + }) + }} + onChange={change} + /> + + +
+
+ +
+ + )} + ); }; ProductUpdatePage.displayName = "ProductUpdatePage"; diff --git a/src/products/components/ProductUpdatePage/form.tsx b/src/products/components/ProductUpdatePage/form.tsx new file mode 100644 index 000000000..009b8b27a --- /dev/null +++ b/src/products/components/ProductUpdatePage/form.tsx @@ -0,0 +1,260 @@ +import { MetadataFormData } from "@saleor/components/Metadata"; +import { MultiAutocompleteChoiceType } from "@saleor/components/MultiAutocompleteSelectField"; +import { SingleAutocompleteChoiceType } from "@saleor/components/SingleAutocompleteSelectField"; +import useForm, { FormChange, SubmitPromise } from "@saleor/hooks/useForm"; +import useFormset, { + FormsetChange, + FormsetData +} from "@saleor/hooks/useFormset"; +import { ProductDetails_product } from "@saleor/products/types/ProductDetails"; +import { + getAttributeInputFromProduct, + getProductUpdatePageFormData, + getStockInputFromProduct +} from "@saleor/products/utils/data"; +import { + createAttributeChangeHandler, + createAttributeMultiChangeHandler +} from "@saleor/products/utils/handlers"; +import { SearchWarehouses_search_edges_node } from "@saleor/searches/types/SearchWarehouses"; +import handleFormSubmit from "@saleor/utils/handlers/handleFormSubmit"; +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 { diff } from "fast-array-diff"; +import React from "react"; + +import { ProductAttributeInput } from "../ProductAttributes"; +import { ProductStockInput } from "../ProductStocks"; + +export interface ProductUpdateFormData extends MetadataFormData { + availableForPurchase: string; + basePrice: number; + category: string | null; + changeTaxCode: boolean; + chargeTaxes: boolean; + collections: string[]; + description: RawDraftContentState; + isAvailable: boolean; + isAvailableForPurchase: boolean; + isPublished: boolean; + name: string; + slug: string; + publicationDate: string; + seoDescription: string; + seoTitle: string; + sku: string; + taxCode: string; + trackInventory: boolean; + visibleInListings: boolean; + weight: string; +} +export interface ProductUpdateData extends ProductUpdateFormData { + attributes: ProductAttributeInput[]; + stocks: ProductStockInput[]; +} +export interface ProductUpdateSubmitData extends ProductUpdateFormData { + attributes: ProductAttributeInput[]; + collections: string[]; + addStocks: ProductStockInput[]; + updateStocks: ProductStockInput[]; + removeStocks: string[]; +} + +type ProductUpdateHandlers = Record< + "changeMetadata" | "selectCategory" | "selectCollection" | "selectTaxRate", + FormChange +> & + Record< + "changeStock" | "selectAttribute" | "selectAttributeMultiple", + FormsetChange + > & + Record<"addStock" | "deleteStock", (id: string) => void>; +export interface UseProductUpdateFormResult { + change: FormChange; + data: ProductUpdateData; + handlers: ProductUpdateHandlers; + hasChanged: boolean; + submit: () => Promise; +} + +export interface UseProductUpdateFormOpts + extends Record< + "categories" | "collections" | "taxTypes", + SingleAutocompleteChoiceType[] + > { + setSelectedCategory: React.Dispatch>; + setSelectedCollections: React.Dispatch< + React.SetStateAction + >; + setSelectedTaxType: React.Dispatch>; + selectedCollections: MultiAutocompleteChoiceType[]; + warehouses: SearchWarehouses_search_edges_node[]; +} + +export interface ProductUpdateFormProps extends UseProductUpdateFormOpts { + children: (props: UseProductUpdateFormResult) => React.ReactNode; + product: ProductDetails_product; + onSubmit: (data: ProductUpdateSubmitData) => SubmitPromise; +} + +const getAvailabilityData = ({ + availableForPurchase, + isAvailableForPurchase, + isPublished, + publicationDate +}: ProductUpdateFormData) => ({ + isAvailableForPurchase: isAvailableForPurchase || !!availableForPurchase, + isPublished: isPublished || !!publicationDate +}); + +const getStocksData = ( + product: ProductDetails_product, + stocks: FormsetData +) => { + if (product?.productType?.hasVariants) { + return { addStocks: [], removeStocks: [], updateStocks: [] }; + } + + const dataStocks = stocks.map(stock => stock.id); + const variantStocks = + product?.variants[0]?.stocks.map(stock => stock.warehouse.id) || []; + const stockDiff = diff(variantStocks, dataStocks); + + return { + addStocks: stocks.filter(stock => + stockDiff.added.some(addedStock => addedStock === stock.id) + ), + removeStocks: stockDiff.removed, + updateStocks: stocks.filter( + stock => !stockDiff.added.some(addedStock => addedStock === stock.id) + ) + }; +}; + +const getMetadata = ( + data: ProductUpdateFormData, + isMetadataModified: boolean, + isPrivateMetadataModified: boolean +) => ({ + metadata: isMetadataModified ? data.metadata : undefined, + privateMetadata: isPrivateMetadataModified ? data.privateMetadata : undefined +}); + +function useProductUpdateForm( + product: ProductDetails_product, + onSubmit: (data: ProductUpdateSubmitData) => SubmitPromise, + opts: UseProductUpdateFormOpts +): UseProductUpdateFormResult { + const [changed, setChanged] = React.useState(false); + const triggerChange = () => setChanged(true); + + const form = useForm( + getProductUpdatePageFormData(product, product?.variants) + ); + const attributes = useFormset(getAttributeInputFromProduct(product)); + const stocks = useFormset(getStockInputFromProduct(product)); + + const { + isMetadataModified, + isPrivateMetadataModified, + makeChangeHandler: makeMetadataChangeHandler + } = useMetadataChangeTrigger(); + + const handleChange: FormChange = (event, cb) => { + form.change(event, cb); + triggerChange(); + }; + const handleCollectionSelect = createMultiAutocompleteSelectHandler( + form.toggleValue, + opts.setSelectedCollections, + opts.selectedCollections, + opts.collections + ); + const handleCategorySelect = createSingleAutocompleteSelectHandler( + handleChange, + opts.setSelectedCategory, + opts.categories + ); + const handleAttributeChange = createAttributeChangeHandler( + attributes.change, + triggerChange + ); + const handleAttributeMultiChange = createAttributeMultiChangeHandler( + attributes.change, + attributes.data, + 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( + handleChange, + opts.setSelectedTaxType, + opts.taxTypes + ); + const changeMetadata = makeMetadataChangeHandler(handleChange); + + const data: ProductUpdateData = { + ...form.data, + attributes: attributes.data, + stocks: stocks.data + }; + const submitData: ProductUpdateSubmitData = { + ...data, + ...getAvailabilityData(data), + ...getStocksData(product, stocks.data), + ...getMetadata(data, isMetadataModified, isPrivateMetadataModified), + addStocks: [], + attributes: attributes.data + }; + + const submit = () => handleFormSubmit(submitData, onSubmit, setChanged); + + return { + change: handleChange, + data, + handlers: { + addStock: handleStockAdd, + changeMetadata, + changeStock: handleStockChange, + deleteStock: handleStockDelete, + selectAttribute: handleAttributeChange, + selectAttributeMultiple: handleAttributeMultiChange, + selectCategory: handleCategorySelect, + selectCollection: handleCollectionSelect, + selectTaxRate: handleTaxTypeSelect + }, + hasChanged: changed, + submit + }; +} + +const ProductUpdateForm: React.FC = ({ + children, + product, + onSubmit, + ...rest +}) => { + const props = useProductUpdateForm(product, onSubmit, rest); + + return
{children(props)}; +}; + +ProductUpdateForm.displayName = "ProductUpdateForm"; +export default ProductUpdateForm; diff --git a/src/products/components/ProductVariantCreatePage/ProductVariantCreatePage.tsx b/src/products/components/ProductVariantCreatePage/ProductVariantCreatePage.tsx index 4655ee737..cbe8fa284 100644 --- a/src/products/components/ProductVariantCreatePage/ProductVariantCreatePage.tsx +++ b/src/products/components/ProductVariantCreatePage/ProductVariantCreatePage.tsx @@ -2,48 +2,23 @@ import AppHeader from "@saleor/components/AppHeader"; 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 PageHeader from "@saleor/components/PageHeader"; import SaveButtonBar from "@saleor/components/SaveButtonBar"; import { ProductErrorWithAttributesFragment } from "@saleor/fragments/types/ProductErrorWithAttributesFragment"; -import useFormset, { - FormsetChange, - FormsetData -} from "@saleor/hooks/useFormset"; -import { getVariantAttributeInputFromProduct } from "@saleor/products/utils/data"; import { SearchWarehouses_search_edges_node } from "@saleor/searches/types/SearchWarehouses"; import { ReorderAction } from "@saleor/types"; -import useMetadataChangeTrigger from "@saleor/utils/metadata/useMetadataChangeTrigger"; import React from "react"; import { useIntl } from "react-intl"; -import { maybe } from "../../../misc"; import { ProductVariantCreateData_product } from "../../types/ProductVariantCreateData"; import ProductShipping from "../ProductShipping/ProductShipping"; -import ProductStocks, { ProductStockInput } from "../ProductStocks"; -import ProductVariantAttributes, { - VariantAttributeInputData -} from "../ProductVariantAttributes"; +import ProductStocks from "../ProductStocks"; +import ProductVariantAttributes from "../ProductVariantAttributes"; import ProductVariantNavigation from "../ProductVariantNavigation"; import ProductVariantPrice from "../ProductVariantPrice"; - -interface ProductVariantCreatePageFormData extends MetadataFormData { - costPrice: string; - images: string[]; - price: string; - quantity: string; - sku: string; - trackInventory: boolean; - weight: string; -} - -export interface ProductVariantCreatePageSubmitData - extends ProductVariantCreatePageFormData { - attributes: FormsetData; - stocks: ProductStockInput[]; -} +import ProductVariantCreateForm, { ProductVariantCreateData } from "./form"; interface ProductVariantCreatePageProps { currencySymbol: string; @@ -55,7 +30,7 @@ interface ProductVariantCreatePageProps { warehouses: SearchWarehouses_search_edges_node[]; weightUnit: string; onBack: () => void; - onSubmit: (data: ProductVariantCreatePageSubmitData) => void; + onSubmit: (data: ProductVariantCreateData) => void; onVariantClick: (variantId: string) => void; onVariantReorder: ReorderAction; onWarehouseConfigure: () => void; @@ -77,144 +52,90 @@ const ProductVariantCreatePage: React.FC = ({ onWarehouseConfigure }) => { const intl = useIntl(); - const attributeInput = React.useMemo( - () => getVariantAttributeInputFromProduct(product), - [product] - ); - const { change: changeAttributeData, data: attributes } = useFormset( - attributeInput - ); - const { - add: addStock, - change: changeStockData, - data: stocks, - remove: removeStock - } = useFormset([]); - const { - makeChangeHandler: makeMetadataChangeHandler - } = useMetadataChangeTrigger(); - - const initialForm: ProductVariantCreatePageFormData = { - costPrice: "", - images: product?.images.map(image => image.id), - metadata: [], - price: "", - privateMetadata: [], - quantity: "0", - sku: "", - trackInventory: true, - weight: "" - }; - - const handleSubmit = (data: ProductVariantCreatePageFormData) => - onSubmit({ - ...data, - attributes, - stocks - }); return ( -
- {({ change, data, hasChanged, submit, triggerChange }) => { - const handleAttributeChange: FormsetChange = (id, value) => { - changeAttributeData(id, value); - triggerChange(); - }; - const changeMetadata = makeMetadataChangeHandler(change); - - return ( - - {maybe(() => product.name)} - - -
- product.thumbnail.url)} - variants={maybe(() => product.variants)} - onRowClick={(variantId: string) => { - if (product && product.variants) { - return onVariantClick(variantId); - } - }} - onReorder={onVariantReorder} - /> -
-
- - - - - - - { - 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} - /> - - -
-
- -
- ); - }} - + + {({ change, data, handlers, hasChanged, submit }) => ( + + {product?.name} + + +
+ { + if (product && product.variants) { + return onVariantClick(variantId); + } + }} + onReorder={onVariantReorder} + /> +
+
+ + + + + + + + + +
+
+ +
+ )} +
); }; ProductVariantCreatePage.displayName = "ProductVariantCreatePage"; diff --git a/src/products/components/ProductVariantCreatePage/form.tsx b/src/products/components/ProductVariantCreatePage/form.tsx new file mode 100644 index 000000000..794c0b192 --- /dev/null +++ b/src/products/components/ProductVariantCreatePage/form.tsx @@ -0,0 +1,140 @@ +import { MetadataFormData } from "@saleor/components/Metadata"; +import useForm, { FormChange } from "@saleor/hooks/useForm"; +import useFormset, { + FormsetChange, + FormsetData +} from "@saleor/hooks/useFormset"; +import { ProductVariantCreateData_product } from "@saleor/products/types/ProductVariantCreateData"; +import { getVariantAttributeInputFromProduct } from "@saleor/products/utils/data"; +import { SearchWarehouses_search_edges_node } from "@saleor/searches/types/SearchWarehouses"; +import useMetadataChangeTrigger from "@saleor/utils/metadata/useMetadataChangeTrigger"; +import React from "react"; + +import { ProductStockInput } from "../ProductStocks"; +import { VariantAttributeInputData } from "../ProductVariantAttributes"; + +export interface ProductVariantCreateFormData extends MetadataFormData { + costPrice: string; + price: string; + sku: string; + trackInventory: boolean; + weight: string; +} +export interface ProductVariantCreateData extends ProductVariantCreateFormData { + attributes: FormsetData; + stocks: ProductStockInput[]; +} + +export interface UseProductVariantCreateFormOpts { + warehouses: SearchWarehouses_search_edges_node[]; +} + +export interface UseProductVariantCreateFormResult { + change: FormChange; + data: ProductVariantCreateData; + // TODO: type FormsetChange + handlers: Record<"changeStock" | "selectAttribute", FormsetChange> & + Record<"addStock" | "deleteStock", (id: string) => void> & { + changeMetadata: FormChange; + }; + hasChanged: boolean; + submit: () => void; +} + +export interface ProductVariantCreateFormProps + extends UseProductVariantCreateFormOpts { + children: (props: UseProductVariantCreateFormResult) => React.ReactNode; + product: ProductVariantCreateData_product; + onSubmit: (data: ProductVariantCreateData) => void; +} + +const initial: ProductVariantCreateFormData = { + costPrice: "", + metadata: [], + price: "", + privateMetadata: [], + sku: "", + trackInventory: true, + weight: "" +}; + +function useProductVariantCreateForm( + product: ProductVariantCreateData_product, + onSubmit: (data: ProductVariantCreateData) => void, + opts: UseProductVariantCreateFormOpts +): UseProductVariantCreateFormResult { + const [changed, setChanged] = React.useState(false); + const triggerChange = () => setChanged(true); + + const attributeInput = getVariantAttributeInputFromProduct(product); + + const form = useForm(initial); + const attributes = useFormset(attributeInput); + const stocks = useFormset([]); + const { + makeChangeHandler: makeMetadataChangeHandler + } = useMetadataChangeTrigger(); + + const handleChange: FormChange = (event, cb) => { + form.change(event, cb); + triggerChange(); + }; + const changeMetadata = makeMetadataChangeHandler(handleChange); + const handleAttributeChange: FormsetChange = (id, value) => { + attributes.change(id, value); + triggerChange(); + }; + const handleStockAdd = (id: string) => { + triggerChange(); + stocks.add({ + data: null, + id, + label: opts.warehouses.find(warehouse => warehouse.id === id).name, + value: "0" + }); + }; + const handleStockChange = (id: string, value: string) => { + triggerChange(); + stocks.change(id, value); + }; + const handleStockDelete = (id: string) => { + triggerChange(); + stocks.remove(id); + }; + + const data: ProductVariantCreateData = { + ...form.data, + attributes: attributes.data, + stocks: stocks.data + }; + + const submit = () => onSubmit(data); + + return { + change: handleChange, + data, + handlers: { + addStock: handleStockAdd, + changeMetadata, + changeStock: handleStockChange, + deleteStock: handleStockDelete, + selectAttribute: handleAttributeChange + }, + hasChanged: changed, + submit + }; +} + +const ProductVariantCreateForm: React.FC = ({ + children, + product, + onSubmit, + ...rest +}) => { + const props = useProductVariantCreateForm(product, onSubmit, rest); + + return
{children(props)}; +}; + +ProductVariantCreateForm.displayName = "ProductVariantCreateForm"; +export default ProductVariantCreateForm; diff --git a/src/products/components/ProductVariantPage/ProductVariantPage.tsx b/src/products/components/ProductVariantPage/ProductVariantPage.tsx index f70fdca38..09b9d4497 100644 --- a/src/products/components/ProductVariantPage/ProductVariantPage.tsx +++ b/src/products/components/ProductVariantPage/ProductVariantPage.tsx @@ -2,7 +2,6 @@ import AppHeader from "@saleor/components/AppHeader"; 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 { MetadataFormData } from "@saleor/components/Metadata"; import Metadata from "@saleor/components/Metadata/Metadata"; @@ -11,19 +10,9 @@ import SaveButtonBar from "@saleor/components/SaveButtonBar"; import { ProductErrorWithAttributesFragment } from "@saleor/fragments/types/ProductErrorWithAttributesFragment"; import { ProductVariant } from "@saleor/fragments/types/ProductVariant"; import { WarehouseFragment } from "@saleor/fragments/types/WarehouseFragment"; -import useFormset, { - FormsetChange, - FormsetData -} from "@saleor/hooks/useFormset"; +import { FormsetData } from "@saleor/hooks/useFormset"; import { VariantUpdate_productVariantUpdate_errors } from "@saleor/products/types/VariantUpdate"; -import { - getAttributeInputFromVariant, - getStockInputFromVariant -} from "@saleor/products/utils/data"; import { ReorderAction } from "@saleor/types"; -import { mapMetadataItemToInput } from "@saleor/utils/maps"; -import useMetadataChangeTrigger from "@saleor/utils/metadata/useMetadataChangeTrigger"; -import { diff } from "fast-array-diff"; import React from "react"; import { maybe } from "../../../misc"; @@ -37,6 +26,7 @@ import ProductVariantImageSelectDialog from "../ProductVariantImageSelectDialog" import ProductVariantNavigation from "../ProductVariantNavigation"; import ProductVariantPrice from "../ProductVariantPrice"; import ProductVariantSetDefault from "../ProductVariantSetDefault"; +import ProductVariantUpdateForm from "./form"; export interface ProductVariantPageFormData extends MetadataFormData { costPrice: string; @@ -97,77 +87,16 @@ const ProductVariantPage: React.FC = ({ onSetDefaultVariant, onWarehouseConfigure }) => { - const attributeInput = React.useMemo( - () => getAttributeInputFromVariant(variant), - [variant] - ); - const stockInput = React.useMemo(() => getStockInputFromVariant(variant), [ - variant - ]); - const { change: changeAttributeData, data: attributes } = useFormset( - attributeInput - ); - const { - add: addStock, - change: changeStockData, - data: stocks, - remove: removeStock - } = useFormset(stockInput); - const [isModalOpened, setModalStatus] = React.useState(false); const toggleModal = () => setModalStatus(!isModalOpened); - const { - isMetadataModified, - isPrivateMetadataModified, - makeChangeHandler: makeMetadataChangeHandler - } = useMetadataChangeTrigger(); - - const variantImages = maybe(() => variant.images.map(image => image.id), []); - const productImages = maybe(() => - variant.product.images.sort((prev, next) => - prev.sortOrder > next.sortOrder ? 1 : -1 - ) + const variantImages = variant?.images?.map(image => image.id); + const productImages = variant?.product?.images?.sort((prev, next) => + prev.sortOrder > next.sortOrder ? 1 : -1 ); - const images = maybe(() => - productImages - .filter(image => variantImages.indexOf(image.id) !== -1) - .sort((prev, next) => (prev.sortOrder > next.sortOrder ? 1 : -1)) - ); - - const initialForm: ProductVariantPageFormData = { - costPrice: variant?.costPrice?.amount.toString() || "", - metadata: variant?.metadata?.map(mapMetadataItemToInput), - price: variant?.price?.amount.toString() || "", - privateMetadata: variant?.privateMetadata?.map(mapMetadataItemToInput), - sku: variant?.sku || "", - trackInventory: !!variant?.trackInventory, - weight: variant?.weight?.value.toString() || "" - }; - - const handleSubmit = (data: ProductVariantPageFormData) => { - const dataStocks = stocks.map(stock => stock.id); - const variantStocks = variant.stocks.map(stock => stock.warehouse.id); - const stockDiff = diff(variantStocks, dataStocks); - const metadata = isMetadataModified ? data.metadata : undefined; - const privateMetadata = isPrivateMetadataModified - ? data.privateMetadata - : undefined; - - onSubmit({ - ...data, - addStocks: stocks.filter(stock => - stockDiff.added.some(addedStock => addedStock === stock.id) - ), - attributes, - metadata, - privateMetadata, - removeStocks: stockDiff.removed, - updateStocks: stocks.filter( - stock => !stockDiff.added.some(addedStock => addedStock === stock.id) - ) - }); - }; + const images = productImages + ?.filter(image => variantImages.indexOf(image.id) !== -1) + .sort((prev, next) => (prev.sortOrder > next.sortOrder ? 1 : -1)); return ( <> @@ -182,116 +111,95 @@ const ProductVariantPage: React.FC = ({ /> )} -
- {({ change, data, hasChanged, submit, triggerChange }) => { - const handleAttributeChange: FormsetChange = (id, value) => { - changeAttributeData(id, value); - triggerChange(); - }; - - const changeMetadata = makeMetadataChangeHandler(change); - - return ( - <> - -
- variant.product.thumbnail.url - )} - variants={maybe(() => variant.product.variants)} - onAdd={onAdd} - onRowClick={(variantId: string) => { - if (variant) { - return onVariantClick(variantId); - } - }} - onReorder={onVariantReorder} - /> -
-
- - - - - + {({ change, data, handlers, hasChanged, submit }) => ( + <> + +
+ variant.product.thumbnail.url + )} + variants={maybe(() => variant.product.variants)} + onAdd={onAdd} + onRowClick={(variantId: string) => { + if (variant) { + return onVariantClick(variantId); } - loading={loading} - onChange={change} - /> - - - - { - triggerChange(); - changeStockData(id, value); - }} - onFormDataChange={change} - onWarehouseStockAdd={id => { - triggerChange(); - addStock({ - data: null, - id, - label: warehouses.find( - warehouse => warehouse.id === id - ).name, - value: "0" - }); - }} - onWarehouseStockDelete={id => { - triggerChange(); - removeStock(id); - }} - onWarehouseConfigure={onWarehouseConfigure} - /> - - -
-
- - - ); - }} - + }} + onReorder={onVariantReorder} + /> +
+
+ + + + + + + + + + + +
+
+ + + )} + {variant && ( ; + stocks: ProductStockInput[]; +} +export interface ProductVariantUpdateSubmitData + extends ProductVariantUpdateFormData { + attributes: FormsetData; + addStocks: ProductStockInput[]; + updateStocks: ProductStockInput[]; + removeStocks: string[]; +} + +export interface UseProductVariantUpdateFormOpts { + warehouses: SearchWarehouses_search_edges_node[]; +} + +export interface UseProductVariantUpdateFormResult { + change: FormChange; + data: ProductVariantUpdateData; + handlers: Record<"changeStock" | "selectAttribute", FormsetChange> & + Record<"addStock" | "deleteStock", (id: string) => void> & { + changeMetadata: FormChange; + }; + hasChanged: boolean; + submit: () => void; +} + +export interface ProductVariantUpdateFormProps + extends UseProductVariantUpdateFormOpts { + children: (props: UseProductVariantUpdateFormResult) => React.ReactNode; + variant: ProductVariant; + onSubmit: (data: ProductVariantUpdateSubmitData) => SubmitPromise; +} + +function useProductVariantUpdateForm( + variant: ProductVariant, + onSubmit: (data: ProductVariantUpdateSubmitData) => SubmitPromise, + opts: UseProductVariantUpdateFormOpts +): UseProductVariantUpdateFormResult { + const [changed, setChanged] = React.useState(false); + const triggerChange = () => setChanged(true); + + const attributeInput = getAttributeInputFromVariant(variant); + const stockInput = getStockInputFromVariant(variant); + + const initial: ProductVariantUpdateFormData = { + costPrice: variant?.costPrice?.amount.toString() || "", + metadata: variant?.metadata?.map(mapMetadataItemToInput), + price: variant?.price?.amount.toString() || "", + privateMetadata: variant?.privateMetadata?.map(mapMetadataItemToInput), + sku: variant?.sku || "", + trackInventory: variant?.trackInventory, + weight: variant?.weight?.value.toString() || "" + }; + + const form = useForm(initial); + const attributes = useFormset(attributeInput); + const stocks = useFormset(stockInput); + const { + isMetadataModified, + isPrivateMetadataModified, + makeChangeHandler: makeMetadataChangeHandler + } = useMetadataChangeTrigger(); + + const handleChange: FormChange = (event, cb) => { + form.change(event, cb); + triggerChange(); + }; + const changeMetadata = makeMetadataChangeHandler(handleChange); + const handleAttributeChange: FormsetChange = (id, value) => { + attributes.change(id, value); + triggerChange(); + }; + const handleStockAdd = (id: string) => { + triggerChange(); + stocks.add({ + data: null, + id, + label: opts.warehouses.find(warehouse => warehouse.id === id).name, + value: "0" + }); + }; + const handleStockChange = (id: string, value: string) => { + triggerChange(); + stocks.change(id, value); + }; + const handleStockDelete = (id: string) => { + triggerChange(); + stocks.remove(id); + }; + + const metadata = isMetadataModified ? form.data.metadata : undefined; + const privateMetadata = isPrivateMetadataModified + ? form.data.privateMetadata + : undefined; + + const dataStocks = stocks.data.map(stock => stock.id); + const variantStocks = variant?.stocks.map(stock => stock.warehouse.id) || []; + const stockDiff = diff(variantStocks, dataStocks); + + const addStocks = stocks.data.filter(stock => + stockDiff.added.some(addedStock => addedStock === stock.id) + ); + const updateStocks = stocks.data.filter( + stock => !stockDiff.added.some(addedStock => addedStock === stock.id) + ); + + const data: ProductVariantUpdateData = { + ...form.data, + attributes: attributes.data, + stocks: stocks.data + }; + const submitData: ProductVariantUpdateSubmitData = { + ...form.data, + addStocks, + attributes: attributes.data, + metadata, + privateMetadata, + removeStocks: stockDiff.removed, + updateStocks + }; + + const submit = () => handleFormSubmit(submitData, onSubmit, setChanged); + + return { + change: handleChange, + data, + handlers: { + addStock: handleStockAdd, + changeMetadata, + changeStock: handleStockChange, + deleteStock: handleStockDelete, + selectAttribute: handleAttributeChange + }, + hasChanged: changed, + submit + }; +} + +const ProductVariantUpdateForm: React.FC = ({ + children, + variant, + onSubmit, + ...rest +}) => { + const props = useProductVariantUpdateForm(variant, onSubmit, rest); + + return
{children(props)}; +}; + +ProductVariantUpdateForm.displayName = "ProductVariantUpdateForm"; +export default ProductVariantUpdateForm; diff --git a/src/products/utils/data.ts b/src/products/utils/data.ts index 270cb5784..f581aecac 100644 --- a/src/products/utils/data.ts +++ b/src/products/utils/data.ts @@ -123,16 +123,14 @@ export function getStockInputFromVariant( export function getVariantAttributeInputFromProduct( product: ProductVariantCreateData_product ): VariantAttributeInput[] { - return maybe(() => - product.productType.variantAttributes.map(attribute => ({ - data: { - values: attribute.values - }, - id: attribute.id, - label: attribute.name, - value: "" - })) - ); + return product?.productType?.variantAttributes?.map(attribute => ({ + data: { + values: attribute.values + }, + id: attribute.id, + label: attribute.name, + value: "" + })); } export function getStockInputFromProduct( diff --git a/src/products/utils/handlers.ts b/src/products/utils/handlers.ts index 917b0c1e5..9c7021d43 100644 --- a/src/products/utils/handlers.ts +++ b/src/products/utils/handlers.ts @@ -8,7 +8,7 @@ import { getAttributeInputFromProductType, ProductType } from "./data"; export function createAttributeChangeHandler( changeAttributeData: FormsetChange, triggerChange: () => void -): FormsetChange { +): FormsetChange { return (attributeId: string, value: string) => { triggerChange(); changeAttributeData(attributeId, value === "" ? [] : [value]); @@ -19,7 +19,7 @@ export function createAttributeMultiChangeHandler( changeAttributeData: FormsetChange, attributes: FormsetData, triggerChange: () => void -): FormsetChange { +): FormsetChange { return (attributeId: string, value: string) => { const attribute = attributes.find( attribute => attribute.id === attributeId @@ -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: { diff --git a/src/products/views/ProductVariantCreate.tsx b/src/products/views/ProductVariantCreate.tsx index 91041ba4f..1b891c0e5 100644 --- a/src/products/views/ProductVariantCreate.tsx +++ b/src/products/views/ProductVariantCreate.tsx @@ -15,9 +15,8 @@ import React from "react"; import { useIntl } from "react-intl"; import { decimal, weight } from "../../misc"; -import ProductVariantCreatePage, { - ProductVariantCreatePageSubmitData -} from "../components/ProductVariantCreatePage"; +import ProductVariantCreatePage from "../components/ProductVariantCreatePage"; +import { ProductVariantCreateData } from "../components/ProductVariantCreatePage/form"; import { useProductVariantReorderMutation, useVariantCreateMutation @@ -84,7 +83,7 @@ export const ProductVariant: React.FC = ({ ); const handleBack = () => navigate(productUrl(productId)); - const handleCreate = async (formData: ProductVariantCreatePageSubmitData) => { + const handleCreate = async (formData: ProductVariantCreateData) => { const result = await variantCreate({ variables: { input: { diff --git a/src/shipping/components/ShippingZoneDetailsPage/ShippingZoneDetailsPage.tsx b/src/shipping/components/ShippingZoneDetailsPage/ShippingZoneDetailsPage.tsx index 31c41233a..c814f3c86 100644 --- a/src/shipping/components/ShippingZoneDetailsPage/ShippingZoneDetailsPage.tsx +++ b/src/shipping/components/ShippingZoneDetailsPage/ShippingZoneDetailsPage.tsx @@ -14,6 +14,7 @@ import { ShippingZoneDetailsFragment, ShippingZoneDetailsFragment_warehouses } from "@saleor/fragments/types/ShippingZoneDetailsFragment"; +import { SubmitPromise } from "@saleor/hooks/useForm"; import useStateFromProps from "@saleor/hooks/useStateFromProps"; import createMultiAutocompleteSelectHandler from "@saleor/utils/handlers/multiAutocompleteSelectChangeHandler"; import React from "react"; @@ -46,7 +47,7 @@ export interface ShippingZoneDetailsPageProps onPriceRateAdd: () => void; onPriceRateEdit: (id: string) => void; onRateRemove: (rateId: string) => void; - onSubmit: (data: FormData) => void; + onSubmit: (data: FormData) => SubmitPromise; onWarehouseAdd: () => void; onWeightRateAdd: () => void; onWeightRateEdit: (id: string) => void; diff --git a/src/shipping/views/ShippingZoneDetails/index.tsx b/src/shipping/views/ShippingZoneDetails/index.tsx index 1ebaf32cc..84bc07de1 100644 --- a/src/shipping/views/ShippingZoneDetails/index.tsx +++ b/src/shipping/views/ShippingZoneDetails/index.tsx @@ -148,13 +148,13 @@ const ShippingZoneDetails: React.FC = ({ } }); - const handleSubmit = (submitData: FormData) => { + const handleSubmit = async (submitData: FormData) => { const warehouseDiff = diff( data.shippingZone.warehouses.map(warehouse => warehouse.id), submitData.warehouses ); - updateShippingZone({ + const result = await updateShippingZone({ variables: { id, input: { @@ -164,6 +164,8 @@ const ShippingZoneDetails: React.FC = ({ } } }); + + return result.data.shippingZoneUpdate.errors; }; if (data?.shippingZone === null) { diff --git a/src/siteSettings/components/SiteSettingsPage/SiteSettingsPage.tsx b/src/siteSettings/components/SiteSettingsPage/SiteSettingsPage.tsx index b05db7280..d6e1a8d0f 100644 --- a/src/siteSettings/components/SiteSettingsPage/SiteSettingsPage.tsx +++ b/src/siteSettings/components/SiteSettingsPage/SiteSettingsPage.tsx @@ -11,6 +11,7 @@ import PageHeader from "@saleor/components/PageHeader"; import SaveButtonBar from "@saleor/components/SaveButtonBar"; import { ShopErrorFragment } from "@saleor/fragments/types/ShopErrorFragment"; import useAddressValidation from "@saleor/hooks/useAddressValidation"; +import { SubmitPromise } from "@saleor/hooks/useForm"; import useStateFromProps from "@saleor/hooks/useStateFromProps"; import { commonMessages, sectionNames } from "@saleor/intl"; import createSingleAutocompleteSelectHandler from "@saleor/utils/handlers/singleAutocompleteSelectChangeHandler"; @@ -54,7 +55,7 @@ export interface SiteSettingsPageProps { onBack: () => void; onKeyAdd: () => void; onKeyRemove: (keyType: AuthorizationKeyType) => void; - onSubmit: (data: SiteSettingsPageFormData) => void; + onSubmit: (data: SiteSettingsPageFormData) => SubmitPromise; } export function areAddressInputFieldsModified( @@ -105,7 +106,7 @@ const SiteSettingsPage: React.FC = props => { const { errors: validationErrors, submit: handleSubmitWithAddress - } = useAddressValidation(onSubmit); + } = useAddressValidation(onSubmit); const initialFormAddress: SiteSettingsPageAddressFormData = { city: maybe(() => shop.companyAddress.city, ""), @@ -134,7 +135,7 @@ const SiteSettingsPage: React.FC = props => { const submitFunc = areAddressInputFieldsModified(data) ? handleSubmitWithAddress : onSubmit; - submitFunc(data); + return submitFunc(data); }} confirmLeave > diff --git a/src/siteSettings/views/index.tsx b/src/siteSettings/views/index.tsx index b090eda9d..bd0c6e02e 100644 --- a/src/siteSettings/views/index.tsx +++ b/src/siteSettings/views/index.tsx @@ -106,7 +106,7 @@ export const SiteSettings: React.FC = ({ params }) => { keyType: data.type } }); - const handleUpdateShopSettings = ( + const handleUpdateShopSettings = async ( data: SiteSettingsPageFormData ) => { const addressInput = areAddressInputFieldsModified(data) @@ -123,7 +123,7 @@ export const SiteSettings: React.FC = ({ params }) => { : { companyName: data.companyName }; - updateShopSettings({ + const result = await updateShopSettings({ variables: { addressInput, shopDomainInput: { @@ -139,6 +139,12 @@ export const SiteSettings: React.FC = ({ params }) => { } } }); + + return [ + ...result.data.shopAddressUpdate.errors, + ...result.data.shopDomainUpdate.errors, + ...result.data.shopSettingsUpdate.errors + ]; }; return ( diff --git a/src/staff/components/StaffDetailsPage/StaffDetailsPage.tsx b/src/staff/components/StaffDetailsPage/StaffDetailsPage.tsx index 11ccc1439..3eab80409 100644 --- a/src/staff/components/StaffDetailsPage/StaffDetailsPage.tsx +++ b/src/staff/components/StaffDetailsPage/StaffDetailsPage.tsx @@ -12,6 +12,7 @@ import { MultiAutocompleteChoiceType } from "@saleor/components/MultiAutocomplet import PageHeader from "@saleor/components/PageHeader"; import SaveButtonBar from "@saleor/components/SaveButtonBar"; import { StaffErrorFragment } from "@saleor/fragments/types/StaffErrorFragment"; +import { SubmitPromise } from "@saleor/hooks/useForm"; import useLocale from "@saleor/hooks/useLocale"; import useStateFromProps from "@saleor/hooks/useStateFromProps"; import { sectionNames } from "@saleor/intl"; @@ -50,7 +51,7 @@ export interface StaffDetailsPageProps extends SearchPageProps { onChangePassword: () => void; onDelete: () => void; onImageDelete: () => void; - onSubmit: (data: StaffDetailsFormData) => void; + onSubmit: (data: StaffDetailsFormData) => SubmitPromise; onImageUpload(file: File); } diff --git a/src/staff/views/StaffDetails.tsx b/src/staff/views/StaffDetails.tsx index 9c571fbe8..589149c2f 100644 --- a/src/staff/views/StaffDetails.tsx +++ b/src/staff/views/StaffDetails.tsx @@ -12,7 +12,9 @@ import usePermissionGroupSearch from "@saleor/searches/usePermissionGroupSearch" import React from "react"; import { FormattedMessage, useIntl } from "react-intl"; -import StaffDetailsPage from "../components/StaffDetailsPage/StaffDetailsPage"; +import StaffDetailsPage, { + StaffDetailsFormData +} from "../components/StaffDetailsPage/StaffDetailsPage"; import StaffPasswordResetDialog from "../components/StaffPasswordResetDialog"; import { TypedStaffAvatarDeleteMutation, @@ -122,168 +124,175 @@ export const StaffDetails: React.FC = ({ id, params }) => { return ( - {(updateStaffMember, updateResult) => ( - - {(deleteStaffMember, deleteResult) => ( - - {updateStaffAvatar => ( - - {(deleteStaffAvatar, deleteAvatarResult) => { - const isUserSameAsViewer = - user.user?.id === data?.user?.id; + {(updateStaffMember, updateResult) => { + const handleSubmit = async (formData: StaffDetailsFormData) => { + const result = await updateStaffMember({ + variables: { + id, + input: { + email: formData.email, + firstName: formData.firstName, + isActive: formData.isActive, + lastName: formData.lastName, + ...groupsDiff(data?.user, formData) + } + } + }); - return ( - <> - - - navigate( - staffMemberDetailsUrl(id, { - action: "change-password" - }) - ) - } - onDelete={() => - navigate( - staffMemberDetailsUrl(id, { - action: "remove" - }) - ) - } - onSubmit={variables => { - updateStaffMember({ - variables: { - id, - input: { - email: variables.email, - firstName: variables.firstName, - isActive: variables.isActive, - lastName: variables.lastName, - ...groupsDiff(data?.user, variables) + return result.data.staffUpdate.errors; + }; + + return ( + + {(deleteStaffMember, deleteResult) => ( + + {updateStaffAvatar => ( + + {(deleteStaffAvatar, deleteAvatarResult) => { + const isUserSameAsViewer = + user.user?.id === data?.user?.id; + + return ( + <> + + + navigate( + staffMemberDetailsUrl(id, { + action: "change-password" + }) + ) + } + onDelete={() => + navigate( + staffMemberDetailsUrl(id, { + action: "remove" + }) + ) + } + onSubmit={handleSubmit} + onImageUpload={file => + updateStaffAvatar({ + variables: { + image: file } - } - }); - }} - onImageUpload={file => - updateStaffAvatar({ - variables: { - image: file - } - }) - } - onImageDelete={() => - navigate( - staffMemberDetailsUrl(id, { - action: "remove-avatar" }) - ) - } - availablePermissionGroups={searchPermissionGroupsOpts.data?.search.edges.map( - edge => edge.node - )} - staffMember={staffMember} - saveButtonBarState={updateResult.status} - fetchMorePermissionGroups={{ - hasMore: - searchPermissionGroupsOpts.data?.search - .pageInfo.hasNextPage, - loading: searchPermissionGroupsOpts.loading, - onFetchMore: loadMorePermissionGroups - }} - onSearchChange={searchPermissionGroups} - /> - - - - - - - - - {getStringOrPlaceholder( - data?.user?.email - )} - - ) - }} - /> - - - - changePassword({ - variables: data - }) - } - /> - - ); - }} - - )} - - )} - - )} + } + onImageDelete={() => + navigate( + staffMemberDetailsUrl(id, { + action: "remove-avatar" + }) + ) + } + availablePermissionGroups={searchPermissionGroupsOpts.data?.search.edges.map( + edge => edge.node + )} + staffMember={staffMember} + saveButtonBarState={updateResult.status} + fetchMorePermissionGroups={{ + hasMore: + searchPermissionGroupsOpts.data?.search + .pageInfo.hasNextPage, + loading: searchPermissionGroupsOpts.loading, + onFetchMore: loadMorePermissionGroups + }} + onSearchChange={searchPermissionGroups} + /> + + + + + + + + + {getStringOrPlaceholder( + data?.user?.email + )} + + ) + }} + /> + + + + changePassword({ + variables: data + }) + } + /> + + ); + }} + + )} + + )} + + ); + }} ); }} diff --git a/src/storybook/stories/components/AssignProductDialog.tsx b/src/storybook/stories/components/AssignProductDialog.tsx index 8898dcf56..27ce8df54 100644 --- a/src/storybook/stories/components/AssignProductDialog.tsx +++ b/src/storybook/stories/components/AssignProductDialog.tsx @@ -2,6 +2,7 @@ import placeholderImage from "@assets/images/placeholder60x60.png"; import AssignProductDialog, { AssignProductDialogProps } from "@saleor/components/AssignProductDialog"; +import { fetchMoreProps } from "@saleor/fixtures"; import { products } from "@saleor/products/fixtures"; import { storiesOf } from "@storybook/react"; import React from "react"; @@ -9,6 +10,7 @@ import React from "react"; import Decorator from "../../Decorator"; const props: AssignProductDialogProps = { + ...fetchMoreProps, confirmButtonState: "default", loading: false, onClose: () => undefined, diff --git a/src/storybook/stories/discounts/VoucherDetailsPage.tsx b/src/storybook/stories/discounts/VoucherDetailsPage.tsx index e94786671..7263eb720 100644 --- a/src/storybook/stories/discounts/VoucherDetailsPage.tsx +++ b/src/storybook/stories/discounts/VoucherDetailsPage.tsx @@ -3,7 +3,7 @@ import { storiesOf } from "@storybook/react"; import React from "react"; import VoucherDetailsPage, { - FormData, + VoucherDetailsPageFormData, VoucherDetailsPageProps, VoucherDetailsPageTab } from "../../../discounts/components/VoucherDetailsPage"; @@ -59,7 +59,7 @@ storiesOf("Views / Discounts / Voucher details", module) "type", "usageLimit", "discountValue" - ] as Array).map(field => ({ + ] as Array).map(field => ({ __typename: "DiscountError", code: DiscountErrorCode.INVALID, field diff --git a/src/storybook/stories/pages/PageDetailsPage.tsx b/src/storybook/stories/pages/PageDetailsPage.tsx index 67bd9b3f6..51af7b057 100644 --- a/src/storybook/stories/pages/PageDetailsPage.tsx +++ b/src/storybook/stories/pages/PageDetailsPage.tsx @@ -3,7 +3,7 @@ import { storiesOf } from "@storybook/react"; import React from "react"; import PageDetailsPage, { - FormData, + PageDetailsPageFormData, PageDetailsPageProps } from "../../../pages/components/PageDetailsPage"; import { page } from "../../../pages/fixtures"; @@ -36,7 +36,7 @@ storiesOf("Views / Pages / Page details", module) "isPublished", "seoDescription", "seoTitle" - ] as Array).map(field => ({ + ] as Array).map(field => ({ __typename: "PageError", code: PageErrorCode.INVALID, field diff --git a/src/storybook/stories/plugins/PluginDetailsPage.tsx b/src/storybook/stories/plugins/PluginDetailsPage.tsx index 3a79bbc76..0e0c787f3 100644 --- a/src/storybook/stories/plugins/PluginDetailsPage.tsx +++ b/src/storybook/stories/plugins/PluginDetailsPage.tsx @@ -3,7 +3,7 @@ import { storiesOf } from "@storybook/react"; import React from "react"; import PluginsDetailsPage, { - FormData, + PluginDetailsPageFormData, PluginsDetailsPageProps } from "../../../plugins/components/PluginsDetailsPage"; import { plugin } from "../../../plugins/fixtures"; @@ -31,7 +31,7 @@ storiesOf("Views / Plugins / Plugin details", module) {...props} errors={[ ...(["active", "Username or account", "Password or license"] as Array< - keyof FormData + keyof PluginDetailsPageFormData >).map(field => ({ __typename: "PluginError" as "PluginError", code: PluginErrorCode.INVALID, diff --git a/src/storybook/stories/products/ProductCreatePage.tsx b/src/storybook/stories/products/ProductCreatePage.tsx index 4a1cd132d..dd02b1e0d 100644 --- a/src/storybook/stories/products/ProductCreatePage.tsx +++ b/src/storybook/stories/products/ProductCreatePage.tsx @@ -5,7 +5,7 @@ import { storiesOf } from "@storybook/react"; import React from "react"; import ProductCreatePage, { - ProductCreatePageSubmitData + ProductCreateFormData } from "../../../products/components/ProductCreatePage"; import { product as productFixture } from "../../../products/fixtures"; import { productTypes } from "../../../productTypes/fixtures"; @@ -74,17 +74,15 @@ storiesOf("Views / Products / Create product", module) "productType", "category", "sku" - ] as Array).map( - field => ({ - __typename: "ProductError", - attributes: - field === "attributes" - ? [productTypes[0].productAttributes[0].id] - : null, - code: ProductErrorCode.INVALID, - field - }) - )} + ] as Array).map(field => ({ + __typename: "ProductError", + attributes: + field === "attributes" + ? [productTypes[0].productAttributes[0].id] + : null, + code: ProductErrorCode.INVALID, + field + }))} header="Add product" collections={product.collections} fetchCategories={() => undefined} @@ -94,7 +92,7 @@ storiesOf("Views / Products / Create product", module) fetchMoreCollections={fetchMoreProps} fetchMoreProductTypes={fetchMoreProps} initial={{ - productType: productTypes[0].id + productType: productTypes[0] }} productTypes={productTypes} categories={[product.category]} diff --git a/src/styles/useScrollableDialogStyle.ts b/src/styles/useScrollableDialogStyle.ts new file mode 100644 index 000000000..7b9a65a9a --- /dev/null +++ b/src/styles/useScrollableDialogStyle.ts @@ -0,0 +1,28 @@ +import makeStyles from "@material-ui/core/styles/makeStyles"; + +const useScrollableDialogStyle = makeStyles( + theme => ({ + content: { + overflowY: "hidden" + }, + dialog: { + height: "calc(100% - 64px)", + maxHeight: 700 + }, + loadMoreLoaderContainer: { + alignItems: "center", + display: "flex", + height: theme.spacing(3), + justifyContent: "center", + marginTop: theme.spacing(3) + }, + scrollArea: { + overflowY: "scroll" + } + }), + { + name: "ScrollableDialog" + } +); + +export default useScrollableDialogStyle; diff --git a/src/utils/handlers/handleFormSubmit.ts b/src/utils/handlers/handleFormSubmit.ts new file mode 100644 index 000000000..584cf6cdf --- /dev/null +++ b/src/utils/handlers/handleFormSubmit.ts @@ -0,0 +1,18 @@ +import { SubmitPromise } from "@saleor/hooks/useForm"; + +async function handleFormSubmit( + data: T, + onSubmit: (data: T) => SubmitPromise, + setChanged: (changed: boolean) => void +): Promise { + const errors = await onSubmit(data); + const ok = errors.length === 0; + + if (ok) { + setChanged(false); + } + + return ok; +} + +export default handleFormSubmit; diff --git a/src/utils/handlers/metadataUpdateHandler.ts b/src/utils/handlers/metadataUpdateHandler.ts index 92cf1cccf..aa436b391 100644 --- a/src/utils/handlers/metadataUpdateHandler.ts +++ b/src/utils/handlers/metadataUpdateHandler.ts @@ -1,4 +1,5 @@ import { MetadataFormData } from "@saleor/components/Metadata/types"; +import { MetadataErrorFragment } from "@saleor/fragments/types/MetadataErrorFragment"; import { MetadataInput } from "@saleor/types/globalTypes"; import { diff } from "fast-array-diff"; import { MutationFetchResult } from "react-apollo"; @@ -28,7 +29,9 @@ function createMetadataUpdateHandler( variables: UpdatePrivateMetadataVariables ) => Promise> ) { - return async (data: TData) => { + return async ( + data: TData + ): Promise> => { const errors = await update(data); if (errors.length > 0) { diff --git a/src/warehouses/components/WarehouseCreatePage/WarehouseCreatePage.tsx b/src/warehouses/components/WarehouseCreatePage/WarehouseCreatePage.tsx index 8f2b61748..d9b8102a6 100644 --- a/src/warehouses/components/WarehouseCreatePage/WarehouseCreatePage.tsx +++ b/src/warehouses/components/WarehouseCreatePage/WarehouseCreatePage.tsx @@ -58,7 +58,7 @@ const WarehouseCreatePage: React.FC = ({ const { errors: validationErrors, submit: handleSubmit - } = useAddressValidation(onSubmit); + } = useAddressValidation(onSubmit); return (
diff --git a/src/warehouses/components/WarehouseDetailsPage/WarehouseDetailsPage.tsx b/src/warehouses/components/WarehouseDetailsPage/WarehouseDetailsPage.tsx index 38aefea3f..9b12d67a5 100644 --- a/src/warehouses/components/WarehouseDetailsPage/WarehouseDetailsPage.tsx +++ b/src/warehouses/components/WarehouseDetailsPage/WarehouseDetailsPage.tsx @@ -11,6 +11,7 @@ import { ShopInfo_shop_countries } from "@saleor/components/Shop/types/ShopInfo" import { AddressTypeInput } from "@saleor/customers/types"; import { WarehouseErrorFragment } from "@saleor/fragments/types/WarehouseErrorFragment"; import useAddressValidation from "@saleor/hooks/useAddressValidation"; +import { SubmitPromise } from "@saleor/hooks/useForm"; import useStateFromProps from "@saleor/hooks/useStateFromProps"; import { sectionNames } from "@saleor/intl"; import { findValueInEnum, maybe } from "@saleor/misc"; @@ -36,7 +37,7 @@ export interface WarehouseDetailsPageProps { onBack: () => void; onDelete: () => void; onShippingZoneClick: (id: string) => void; - onSubmit: (data: WarehouseDetailsPageFormData) => void; + onSubmit: (data: WarehouseDetailsPageFormData) => SubmitPromise; } const WarehouseDetailsPage: React.FC = ({ @@ -58,7 +59,7 @@ const WarehouseDetailsPage: React.FC = ({ const { errors: validationErrors, submit: handleSubmit - } = useAddressValidation(onSubmit); + } = useAddressValidation(onSubmit); const initialForm: WarehouseDetailsPageFormData = { city: maybe(() => warehouse.address.city, ""), @@ -76,7 +77,7 @@ const WarehouseDetailsPage: React.FC = ({ return ( - {({ change, data, submit }) => { + {({ change, data, hasChanged, submit }) => { const countryChoices = mapCountriesToChoices(countries); const handleCountryChange = createSingleAutocompleteSelectHandler( change, @@ -121,7 +122,7 @@ const WarehouseDetailsPage: React.FC = ({ { streetAddress1: data.streetAddress1, streetAddress2: data.streetAddress2 }, + companyName: data.companyName, name: data.name } } diff --git a/src/warehouses/views/WarehouseDetails/WarehouseDetails.tsx b/src/warehouses/views/WarehouseDetails/WarehouseDetails.tsx index 81262754b..bebb1e2f4 100644 --- a/src/warehouses/views/WarehouseDetails/WarehouseDetails.tsx +++ b/src/warehouses/views/WarehouseDetails/WarehouseDetails.tsx @@ -13,7 +13,9 @@ import { shippingZoneUrl } from "@saleor/shipping/urls"; import { CountryCode } from "@saleor/types/globalTypes"; import createDialogActionHandlers from "@saleor/utils/handlers/dialogActionHandlers"; import WarehouseDeleteDialog from "@saleor/warehouses/components/WarehouseDeleteDialog"; -import WarehouseDetailsPage from "@saleor/warehouses/components/WarehouseDetailsPage"; +import WarehouseDetailsPage, { + WarehouseDetailsPageFormData +} from "@saleor/warehouses/components/WarehouseDetailsPage"; import { useWarehouseDelete, useWarehouseUpdate @@ -76,6 +78,28 @@ const WarehouseDetails: React.FC = ({ id, params }) => { return navigate(warehouseListUrl())} />; } + const handleSubmit = async (data: WarehouseDetailsPageFormData) => { + const result = await updateWarehouse({ + variables: { + id, + input: { + address: { + city: data.city, + cityArea: data.cityArea, + country: findValueInEnum(data.country, CountryCode), + countryArea: data.countryArea, + phone: data.phone, + postalCode: data.postalCode, + streetAddress1: data.streetAddress1, + streetAddress2: data.streetAddress2 + }, + name: data.name + } + } + }); + + return result.data.updateWarehouse.errors; + }; return ( <> @@ -88,26 +112,7 @@ const WarehouseDetails: React.FC = ({ id, params }) => { onBack={() => navigate(warehouseListUrl())} onDelete={() => openModal("delete")} onShippingZoneClick={id => navigate(shippingZoneUrl(id))} - onSubmit={data => - updateWarehouse({ - variables: { - id, - input: { - address: { - city: data.city, - cityArea: data.cityArea, - country: findValueInEnum(data.country, CountryCode), - countryArea: data.countryArea, - phone: data.phone, - postalCode: data.postalCode, - streetAddress1: data.streetAddress1, - streetAddress2: data.streetAddress2 - }, - name: data.name - } - } - }) - } + onSubmit={handleSubmit} />