Merge branch 'master' into SALEOR-1403-cannot-fulfill-order-when-product-quantity-is-one

This commit is contained in:
Dominik Żegleń 2020-11-03 12:15:39 +01:00 committed by GitHub
commit d16cf2c6da
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
88 changed files with 2381 additions and 1866 deletions

View file

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

View file

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

12
package-lock.json generated
View file

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

View file

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

View file

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

View file

@ -130,8 +130,8 @@ export const CustomAppDetails: React.FC<OrderListProps> = ({
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<OrderListProps> = ({
}
});
return result.data.appUpdate.errors;
};
const handleTokenCreate = (name: string) =>
createToken({
variables: {

View file

@ -126,7 +126,7 @@ const AttributePage: React.FC<AttributePageProps> = ({
? data.privateMetadata
: undefined;
onSubmit({
return onSubmit({
...data,
metadata,
privateMetadata,

View file

@ -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<CategoryUpdatePageProps> = ({
? data.privateMetadata
: undefined;
onSubmit({
return onSubmit({
...data,
metadata,
privateMetadata

View file

@ -81,7 +81,7 @@ const CollectionDetailsPage: React.FC<CollectionDetailsPageProps> = ({
? data.privateMetadata
: undefined;
onSubmit({
return onSubmit({
...data,
isPublished: data.isPublished || !!data.publicationDate,
metadata,

View file

@ -60,7 +60,7 @@ export const CollectionDetails: React.FC<CollectionDetailsProps> = ({
);
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<CollectionDetailsProps> = ({
/>
<AssignProductDialog
confirmButtonState={assignProductOpts.status}
hasMore={result.data?.search?.pageInfo.hasNextPage}
open={params.action === "assign"}
onFetch={search}
onFetchMore={loadMore}
loading={result.loading}
onClose={closeModal}
onSubmit={products =>

View file

@ -1,7 +1,15 @@
import React from "react";
const AppActionContext = React.createContext<React.RefObject<HTMLDivElement>>(
undefined
);
interface AppAction {
anchor: React.RefObject<HTMLDivElement>;
docked: boolean;
setDocked: (docked: boolean) => void;
}
const AppActionContext = React.createContext<AppAction>({
anchor: undefined,
docked: true,
setDocked: () => undefined
});
export const useAppAction = () => React.useContext(AppActionContext);
export default AppActionContext;

View file

@ -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<AppLayoutProps> = ({ 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<AppLayoutProps> = ({ children }) => {
setVisibility={setNavigatorVisibility}
/>
<AppHeaderContext.Provider value={appHeaderAnchor}>
<AppActionContext.Provider value={appActionAnchor}>
<AppActionContext.Provider
value={{
anchor: appActionAnchor,
docked,
setDocked
}}
>
<div className={classes.root}>
<div className={classes.sideBar}>
<ResponsiveDrawer
@ -533,7 +543,12 @@ const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
: children}
</main>
</div>
<div className={classes.appAction} ref={appActionAnchor} />
<div
className={classNames(classes.appAction, {
[classes.appActionDocked]: docked
})}
ref={appActionAnchor}
/>
</div>
</div>
</AppActionContext.Provider>

View file

@ -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<AssignContainerDialogProps, "containers" | "title" | "search"> {
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<AssignCategoriesDialogProps> = props => {
const {
confirmButtonState,
open,
loading,
categories: categories,
onClose,
onFetch,
onSubmit
} = props;
const classes = useStyles(props);
const AssignCategoryDialog: React.FC<AssignCategoryDialogProps> = ({
categories,
...rest
}) => {
const intl = useIntl();
const [query, onQueryChange] = useSearchQuery(onFetch);
const [selectedCategories, setSelectedCategories] = React.useState<
SearchCategories_search_edges_node[]
>([]);
const handleSubmit = () => onSubmit(selectedCategories);
return (
<Dialog
open={open}
onClose={onClose}
classes={{ paper: classes.overflow }}
fullWidth
maxWidth="sm"
>
<DialogTitle>
<FormattedMessage
defaultMessage="Assign Categories"
description="dialog header"
/>
</DialogTitle>
<DialogContent className={classes.overflow}>
<TextField
name="query"
value={query}
onChange={onQueryChange}
label={intl.formatMessage({
defaultMessage: "Search Categories"
})}
placeholder={intl.formatMessage({
defaultMessage: "Search by category name, etc..."
})}
fullWidth
InputProps={{
autoComplete: "off",
endAdornment: loading && <CircularProgress size={16} />
}}
/>
<FormSpacer />
<ResponsiveTable>
<TableBody>
{categories &&
categories.map(category => {
const isSelected = !!selectedCategories.find(
selectedCategories => selectedCategories.id === category.id
);
return (
<TableRow key={category.id}>
<TableCell
padding="checkbox"
className={classes.checkboxCell}
>
<Checkbox
checked={isSelected}
onChange={() =>
handleCategoryAssign(
category,
isSelected,
selectedCategories,
setSelectedCategories
)
}
/>
</TableCell>
<TableCell className={classes.wideCell}>
{category.name}
</TableCell>
</TableRow>
);
})}
</TableBody>
</ResponsiveTable>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>
<FormattedMessage {...buttonMessages.back} />
</Button>
<ConfirmButton
transitionState={confirmButtonState}
color="primary"
variant="contained"
type="submit"
onClick={handleSubmit}
>
<FormattedMessage
defaultMessage="Assign categories"
description="button"
/>
</ConfirmButton>
</DialogActions>
</Dialog>
<AssignContainerDialog
containers={categories}
search={{
label: intl.formatMessage({
defaultMessage: "Search Category"
}),
placeholder: intl.formatMessage({
defaultMessage: "Search by category name, etc..."
})
}}
title={intl.formatMessage({
defaultMessage: "Assign Category",
description: "dialog header"
})}
{...rest}
/>
);
};
AssignCategoriesDialog.displayName = "AssignCategoriesDialog";
export default AssignCategoriesDialog;
AssignCategoryDialog.displayName = "AssignCategoryDialog";
export default AssignCategoryDialog;

View file

@ -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<AssignContainerDialogProps, "containers" | "title" | "search"> {
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<AssignCollectionDialogProps> = props => {
const {
confirmButtonState,
open,
loading,
collections,
onClose,
onFetch,
onSubmit
} = props;
const classes = useStyles(props);
const AssignCollectionDialog: React.FC<AssignCollectionDialogProps> = ({
collections,
...rest
}) => {
const intl = useIntl();
const [query, onQueryChange] = useSearchQuery(onFetch);
const [selectedCollections, setSelectedCollections] = React.useState<
SearchCollections_search_edges_node[]
>([]);
const handleSubmit = () => onSubmit(selectedCollections);
return (
<Dialog
onClose={onClose}
open={open}
classes={{ paper: classes.overflow }}
fullWidth
maxWidth="sm"
>
<DialogTitle>
<FormattedMessage
defaultMessage="Assign Collection"
description="dialog header"
/>
</DialogTitle>
<DialogContent className={classes.overflow}>
<TextField
name="query"
value={query}
onChange={onQueryChange}
label={intl.formatMessage({
defaultMessage: "Search Collection"
})}
placeholder={intl.formatMessage({
defaultMessage: "Search by collection name, etc..."
})}
fullWidth
InputProps={{
autoComplete: "off",
endAdornment: loading && <CircularProgress size={16} />
}}
/>
<FormSpacer />
<ResponsiveTable>
<TableBody>
{collections &&
collections.map(collection => {
const isSelected = !!selectedCollections.find(
selectedCollection => selectedCollection.id === collection.id
);
return (
<TableRow key={collection.id}>
<TableCell
padding="checkbox"
className={classes.checkboxCell}
>
<Checkbox
checked={isSelected}
onChange={() =>
handleCollectionAssign(
collection,
isSelected,
selectedCollections,
setSelectedCollections
)
}
/>
</TableCell>
<TableCell className={classes.wideCell}>
{collection.name}
</TableCell>
</TableRow>
);
})}
</TableBody>
</ResponsiveTable>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>
<FormattedMessage {...buttonMessages.back} />
</Button>
<ConfirmButton
transitionState={confirmButtonState}
color="primary"
variant="contained"
type="submit"
onClick={handleSubmit}
>
<FormattedMessage
defaultMessage="Assign collections"
description="button"
/>
</ConfirmButton>
</DialogActions>
</Dialog>
<AssignContainerDialog
containers={collections}
search={{
label: intl.formatMessage({
defaultMessage: "Search Collection"
}),
placeholder: intl.formatMessage({
defaultMessage: "Search by collection name, etc..."
})
}}
title={intl.formatMessage({
defaultMessage: "Assign Collection",
description: "dialog header"
})}
{...rest}
/>
);
};
AssignCollectionDialog.displayName = "AssignCollectionDialog";
export default AssignCollectionDialog;

View file

@ -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<AssignContainerDialogProps> = 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<string[]>(
[]
);
const container = React.useRef<HTMLDivElement>();
const handleSubmit = () => onSubmit(selectedContainers);
const containerHeight = container.current?.scrollHeight - 130;
return (
<Dialog
onClose={onClose}
open={open}
classes={{ paper: scrollableDialogClasses.dialog }}
fullWidth
maxWidth="sm"
>
<DialogTitle>{title}</DialogTitle>
<DialogContent
className={scrollableDialogClasses.content}
ref={container}
>
<TextField
name="query"
value={query}
onChange={onQueryChange}
label={search.label}
placeholder={search.placeholder}
fullWidth
InputProps={{
autoComplete: "off",
endAdornment: loading && <CircularProgress size={16} />
}}
/>
<FormSpacer />
<div
className={scrollableDialogClasses.scrollArea}
style={{ height: containerHeight }}
>
<InfiniteScroll
pageStart={0}
loadMore={onFetchMore}
hasMore={hasMore}
useWindow={false}
loader={
<div className={scrollableDialogClasses.loadMoreLoaderContainer}>
<CircularProgress size={16} />
</div>
}
threshold={10}
>
<ResponsiveTable>
<TableBody>
{containers?.map(container => {
const isSelected = !!selectedContainers.find(
selectedContainer => selectedContainer === container.id
);
return (
<TableRow key={container.id}>
<TableCell
padding="checkbox"
className={classes.checkboxCell}
>
<Checkbox
checked={isSelected}
onChange={() =>
handleContainerAssign(
container.id,
isSelected,
selectedContainers,
setSelectedContainers
)
}
/>
</TableCell>
<TableCell className={classes.wideCell}>
{container.name}
</TableCell>
</TableRow>
);
})}
</TableBody>
</ResponsiveTable>
</InfiniteScroll>
</div>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>
<FormattedMessage {...buttonMessages.back} />
</Button>
<ConfirmButton
transitionState={confirmButtonState}
color="primary"
variant="contained"
type="submit"
onClick={handleSubmit}
>
<FormattedMessage defaultMessage="Assign" description="button" />
</ConfirmButton>
</DialogActions>
</Dialog>
);
};
AssignContainerDialog.displayName = "AssignContainerDialog";
export default AssignContainerDialog;

View file

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

View file

@ -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<AssignProductDialogProps> = 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<HTMLDivElement>();
const handleSubmit = () => onSubmit(selectedProducts);
const containerHeight = container.current?.scrollHeight - 130;
return (
<Dialog
onClose={onClose}
open={open}
classes={{ paper: classes.overflow }}
classes={{ paper: scrollableDialogClasses.dialog }}
fullWidth
maxWidth="sm"
>
@ -115,7 +118,10 @@ const AssignProductDialog: React.FC<AssignProductDialogProps> = props => {
description="dialog header"
/>
</DialogTitle>
<DialogContent>
<DialogContent
className={scrollableDialogClasses.content}
ref={container}
>
<TextField
name="query"
value={query}
@ -134,45 +140,61 @@ const AssignProductDialog: React.FC<AssignProductDialogProps> = props => {
}}
/>
<FormSpacer />
<div className={classes.scrollArea}>
<ResponsiveTable>
<TableBody>
{products &&
products.map(product => {
const isSelected = selectedProducts.some(
selectedProduct => selectedProduct.id === product.id
);
<div
className={scrollableDialogClasses.scrollArea}
style={{ height: containerHeight }}
>
<InfiniteScroll
pageStart={0}
loadMore={onFetchMore}
hasMore={hasMore}
useWindow={false}
loader={
<div className={scrollableDialogClasses.loadMoreLoaderContainer}>
<CircularProgress size={16} />
</div>
}
threshold={10}
>
<ResponsiveTable key="table">
<TableBody>
{products &&
products.map(product => {
const isSelected = selectedProducts.some(
selectedProduct => selectedProduct.id === product.id
);
return (
<TableRow key={product.id}>
<TableCellAvatar
className={classes.avatar}
thumbnail={maybe(() => product.thumbnail.url)}
/>
<TableCell className={classes.colName}>
{product.name}
</TableCell>
<TableCell
padding="checkbox"
className={classes.checkboxCell}
>
<Checkbox
checked={isSelected}
onChange={() =>
handleProductAssign(
product,
isSelected,
selectedProducts,
setSelectedProducts
)
}
return (
<TableRow key={product.id}>
<TableCellAvatar
className={classes.avatar}
thumbnail={maybe(() => product.thumbnail.url)}
/>
</TableCell>
</TableRow>
);
})}
</TableBody>
</ResponsiveTable>
<TableCell className={classes.colName}>
{product.name}
</TableCell>
<TableCell
padding="checkbox"
className={classes.checkboxCell}
>
<Checkbox
checked={isSelected}
onChange={() =>
handleProductAssign(
product,
isSelected,
selectedProducts,
setSelectedProducts
)
}
/>
</TableCell>
</TableRow>
);
})}
</TableBody>
</ResponsiveTable>
</InfiniteScroll>
</div>
</DialogContent>
<DialogActions>

View file

@ -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<ButtonProps, "classes">,
WithStyles<typeof styles> {
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;

View file

@ -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<T> {
@ -6,7 +6,7 @@ export interface FormProps<T> {
confirmLeave?: boolean;
initial?: T;
resetOnSubmit?: boolean;
onSubmit?: (data: T) => void;
onSubmit?: (data: T) => SubmitPromise | void;
}
function Form<T>(props: FormProps<T>) {

View file

@ -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<SaveButtonBarProps> = 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 (
<AppActionContext.Consumer>
{anchor =>
anchor ? (
<Portal container={anchor.current}>
{shouldDisplay && (
<div className={classes.root} {...rest}>
<Container>
<Card elevation={scrolledToBottom ? 0 : 16}>
<CardContent className={classes.content}>
{!!onDelete && (
<Button
variant="contained"
onClick={onDelete}
className={classes.deleteButton}
data-test="button-bar-delete"
>
{labels && labels.delete
? labels.delete
: intl.formatMessage(buttonMessages.delete)}
</Button>
)}
<div className={classes.spacer} />
<Button
className={classes.cancelButton}
variant="text"
onClick={onCancel}
data-test="button-bar-cancel"
>
{maybe(
() => labels.cancel,
intl.formatMessage(buttonMessages.back)
)}
</Button>
<ConfirmButton
disabled={disabled}
onClick={onSave}
transitionState={state}
data-test="button-bar-confirm"
>
{maybe(
() => labels.save,
intl.formatMessage(buttonMessages.save)
)}
</ConfirmButton>
</CardContent>
</Card>
</Container>
</div>
)}
</Portal>
) : null
}
</AppActionContext.Consumer>
);
return appAction.anchor ? (
<Portal container={appAction.anchor.current}>
<div className={classes.root} {...rest}>
<Container>
<Card
className={classes.paper}
elevation={!(appAction.docked || scrolledToBottom) ? 16 : 0}
>
<CardContent className={classes.content}>
{!!onDelete && (
<Button
variant="contained"
onClick={onDelete}
className={classes.deleteButton}
data-test="button-bar-delete"
>
{labels?.delete || intl.formatMessage(buttonMessages.delete)}
</Button>
)}
<div className={classes.spacer} />
<Button
className={classes.cancelButton}
variant="text"
onClick={onCancel}
data-test="button-bar-cancel"
>
{labels?.cancel || intl.formatMessage(buttonMessages.back)}
</Button>
<ConfirmButton
disabled={disabled}
onClick={onSave}
transitionState={state}
data-test="button-bar-confirm"
onTransitionToDefault={() => appAction.setDocked(true)}
>
{labels?.save || intl.formatMessage(buttonMessages.save)}
</ConfirmButton>
</CardContent>
</Card>
</Container>
</div>
</Portal>
) : null;
};
SaveButtonBar.displayName = "SaveButtonBar";
export default SaveButtonBar;

View file

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

View file

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

View file

@ -76,7 +76,7 @@ const CustomerCreatePage: React.FC<CustomerCreatePageProps> = ({
const {
errors: validationErrors,
submit: handleSubmitWithAddress
} = useAddressValidation<CustomerCreatePageFormData>(formData =>
} = useAddressValidation<CustomerCreatePageFormData, void>(formData =>
onSubmit({
address: {
city: formData.city,

View file

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

View file

@ -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<CustomerDetailsViewProps> = ({
return <NotFoundPage onBack={handleBack} />;
}
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 (
<>
<WindowTitle
@ -97,20 +118,7 @@ export const CustomerDetailsView: React.FC<CustomerDetailsViewProps> = ({
}
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, {

View file

@ -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<SaleDetailsPageProps> = ({
}) => {
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),

View file

@ -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<any>) => void;
@ -38,7 +38,7 @@ const SaleInfo: React.FC<SaleInfoProps> = ({
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",

View file

@ -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<SaleTypeProps> = props => {
<RadioGroupField
choices={choices}
disabled={disabled}
name={"type" as keyof FormData}
name={"type" as keyof SaleDetailsPageFormData}
value={data.type}
onChange={onChange}
/>

View file

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

View file

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

View file

@ -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<VoucherDetailsPageProps> = ({
requirementsPickerInitValue = RequirementsPicker.NONE;
}
const initialForm: FormData = {
const initialForm: VoucherDetailsPageFormData = {
applyOncePerCustomer: maybe(() => voucher.applyOncePerCustomer, false),
applyOncePerOrder: maybe(() => voucher.applyOncePerOrder, false),
code: maybe(() => voucher.code, ""),

View file

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

View file

@ -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}
/>
</CardContent>

View file

@ -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 = ({
<RadioGroupField
choices={requirementsPickerChoices}
disabled={disabled}
name={"requirementsPicker" as keyof FormData}
name={"requirementsPicker" as keyof VoucherDetailsPageFormData}
value={data.requirementsPicker}
onChange={onChange}
/>
@ -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

View file

@ -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<any>) => 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}
/>

View file

@ -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<VoucherValueProps> = 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<VoucherValueProps> = 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<VoucherValueProps> = props => {
<Hr className={classes.hr} />
<FormSpacer />
<ControlledCheckbox
name={"applyOncePerOrder" as keyof FormData}
name={"applyOncePerOrder" as keyof VoucherDetailsPageFormData}
label={
<>
<FormattedMessage

View file

@ -27,6 +27,7 @@ import { decimal, joinDateTime, maybe } from "../../misc";
import { productUrl } from "../../products/urls";
import { DiscountValueTypeEnum, SaleType } from "../../types/globalTypes";
import SaleDetailsPage, {
SaleDetailsPageFormData,
SaleDetailsPageTab
} from "../components/SaleDetailsPage";
import {
@ -68,18 +69,21 @@ export const SaleDetails: React.FC<SaleDetailsProps> = ({ 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<SaleDetailsProps> = ({ 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<SaleDetailsProps> = ({ 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<SaleDetailsProps> = ({ id, params }) => {
/>
<AssignProductDialog
confirmButtonState={saleCataloguesAddOpts.status}
hasMore={
searchProductsOpts.data?.search.pageInfo
.hasNextPage
}
open={params.action === "assign-product"}
onFetch={searchProducts}
onFetchMore={loadMoreProducts}
loading={searchProductsOpts.loading}
onClose={closeModal}
onSubmit={products =>
@ -359,8 +369,13 @@ export const SaleDetails: React.FC<SaleDetailsProps> = ({ 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<SaleDetailsProps> = ({ id, params }) => {
...paginationState,
id,
input: {
categories: categories.map(
product => product.id
)
categories
}
}
})
@ -386,8 +399,13 @@ export const SaleDetails: React.FC<SaleDetailsProps> = ({ 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<SaleDetailsProps> = ({ id, params }) => {
...paginationState,
id,
input: {
collections: collections.map(
product => product.id
)
collections
}
}
})

View file

@ -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<VoucherDetailsProps> = ({
);
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<VoucherDetailsProps> = ({
}
});
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<VoucherDetailsProps> = ({
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<VoucherDetailsProps> = ({
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<VoucherDetailsProps> = ({
...paginationState,
id,
input: {
categories: categories.map(
product => product.id
)
categories
}
}
})
@ -439,8 +442,13 @@ export const VoucherDetails: React.FC<VoucherDetailsProps> = ({
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<VoucherDetailsProps> = ({
...paginationState,
id,
input: {
collections: collections.map(
product => product.id
)
collections
}
}
})
@ -484,8 +490,13 @@ export const VoucherDetails: React.FC<VoucherDetailsProps> = ({
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 =>

View file

@ -5,14 +5,14 @@ import { AccountErrorCode, AddressInput } from "@saleor/types/globalTypes";
import { add, remove } from "@saleor/utils/lists";
import { useState } from "react";
interface UseAddressValidation<T> {
interface UseAddressValidation<TInput, TOutput> {
errors: AccountErrorFragment[];
submit: (data: T & AddressTypeInput) => void;
submit: (data: TInput & AddressTypeInput) => TOutput;
}
function useAddressValidation<T>(
onSubmit: (address: T & AddressInput) => void
): UseAddressValidation<T> {
function useAddressValidation<TInput, TOutput>(
onSubmit: (address: TInput & AddressInput) => TOutput
): UseAddressValidation<TInput, TOutput> {
const [validationErrors, setValidationErrors] = useState<
AccountErrorFragment[]
>([]);
@ -25,7 +25,7 @@ function useAddressValidation<T>(
return {
errors: validationErrors,
submit: (data: T & AddressTypeInput) => {
submit: (data: TInput & AddressTypeInput) => {
try {
setValidationErrors(
remove(
@ -34,7 +34,7 @@ function useAddressValidation<T>(
(a, b) => a.field === b.field
)
);
onSubmit(transformFormToAddress(data));
return onSubmit(transformFormToAddress(data));
} catch {
setValidationErrors(add(countryRequiredError, validationErrors));
}

View file

@ -10,6 +10,7 @@ export interface ChangeEvent<TData = any> {
value: TData;
};
}
export type SubmitPromise = Promise<any[]>;
export type FormChange = (event: ChangeEvent, cb?: () => void) => void;
@ -51,7 +52,7 @@ function handleRefresh<T extends FormData>(
function useForm<T extends FormData>(
initial: T,
onSubmit: (data: T) => void
onSubmit?: (data: T) => SubmitPromise | void
): UseFormResult<T> {
const [hasChanged, setChanged] = useState(false);
const [data, setData] = useStateFromProps(initial, {
@ -106,8 +107,16 @@ function useForm<T extends FormData>(
}));
}
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() {

View file

@ -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<boolean>;
onSubmit: (data: MenuDetailsSubmitData) => SubmitPromise;
}
const MenuDetailsPage: React.FC<MenuDetailsPageProps> = ({
@ -62,14 +63,16 @@ const MenuDetailsPage: React.FC<MenuDetailsPageProps> = ({
);
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) => {

View file

@ -141,28 +141,20 @@ const MenuDetails: React.FC<MenuDetailsProps> = ({ 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 (

View file

@ -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<OrderDetailsPageProps> = props => {
@ -123,7 +124,7 @@ const OrderDetailsPage: React.FC<OrderDetailsPageProps> = props => {
? data.privateMetadata
: undefined;
onSubmit({
return onSubmit({
metadata,
privateMetadata
});

View file

@ -122,6 +122,8 @@ export const OrderDetails: React.FC<OrderDetailsProps> = ({ id, params }) => {
text: intl.formatMessage(commonMessages.savedChanges)
});
}
return result;
};
return (

View file

@ -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<PageDetailsPageProps> = ({
@ -67,7 +68,7 @@ const PageDetailsPage: React.FC<PageDetailsPageProps> = ({
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<PageDetailsPageProps> = ({
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,

View file

@ -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<PageInfoProps> = 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<PageInfoProps> = props => {
defaultMessage: "Content",
description: "page content"
})}
name={"content" as keyof FormData}
name={"content" as keyof PageDetailsPageFormData}
onChange={onChange}
/>
</CardContent>

View file

@ -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<PageCreateProps> = () => {
return (
<TypedPageCreate onCompleted={handlePageCreate}>
{(pageCreate, pageCreateOpts) => {
const handleCreate = async (formData: FormData) => {
const handleCreate = async (formData: PageDetailsPageFormData) => {
const result = await pageCreate({
variables: {
input: {

View file

@ -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<PageDetailsProps> = ({ id, params }) => {
{(pageUpdate, pageUpdateOpts) => (
<TypedPageDetailsQuery variables={{ id }}>
{pageDetails => {
const handleUpdate = async (data: FormData) => {
const handleUpdate = async (data: PageDetailsPageFormData) => {
const result = await pageUpdate({
variables: {
id,

View file

@ -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<PermissionGroupDetailsPageProps> = ({

View file

@ -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<PermissionGroupDetailsProps> = ({
);
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 (
<>
<PermissionGroupDetailsPage
@ -144,18 +161,7 @@ export const PermissionGroupDetails: React.FC<PermissionGroupDetailsProps> = ({
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}

View file

@ -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<PluginInfoProps> = ({
})}
</Typography>
<ControlledCheckbox
name={"active" as keyof FormData}
name={"active" as keyof PluginDetailsPageFormData}
label={intl.formatMessage({
defaultMessage: "Set plugin as Active"
})}

View file

@ -14,10 +14,10 @@ import { getFieldError } from "@saleor/utils/errors";
import React from "react";
import { useIntl } from "react-intl";
import { FormData } from "../PluginsDetailsPage";
import { PluginDetailsPageFormData } from "../PluginsDetailsPage";
interface PluginSettingsProps {
data: FormData;
data: PluginDetailsPageFormData;
errors: UserError[];
disabled: boolean;
onChange: (event: React.ChangeEvent<any>) => void;

View file

@ -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<PluginsDetailsPageProps> = 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))

View file

@ -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<PluginsDetailsProps> = ({
}
});
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 (
<>
<WindowTitle title={pluginDetails.data?.plugin?.name} />
@ -110,20 +131,7 @@ export const PluginsDetails: React.FC<PluginsDetailsProps> = ({
id
})
}
onSubmit={formData =>
pluginUpdate({
variables: {
id,
input: {
active: formData.active,
configuration: getConfigurationInput(
pluginDetails.data.plugin.configuration,
formData.configuration
)
}
}
})
}
onSubmit={handleSubmit}
/>
{pluginDetails.data?.plugin?.configuration && (
<>

View file

@ -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<ProductTypeDetailsPageProps> = ({
? data.privateMetadata
: undefined;
onSubmit({
return onSubmit({
...data,
metadata,
privateMetadata

View file

@ -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<FormData>;
productTypes?: Array<{
id: string;
name: string;
hasVariants: boolean;
productAttributes: SearchProductTypes_search_edges_node_productAttributes[];
}>;
initial?: Partial<ProductCreateFormData>;
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<ProductCreatePageProps> = ({
@ -133,65 +86,12 @@ export const ProductCreatePage: React.FC<ProductCreatePageProps> = ({
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<ProductAttributeInputData>(
initial?.productType
? getAttributeInputFromProductType(initialProductType)
: []
);
const {
add: addStock,
change: changeStockData,
data: stocks,
remove: removeStock
} = useFormset<null, string>([]);
// 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<ProductCreatePageProps> = ({
MultiAutocompleteChoiceType[]
>([]);
const [productType, setProductType] = useStateFromProps<ProductType>(
initialProductType || null
);
const [selectedTaxType, setSelectedTaxType] = useStateFromProps(
initial?.taxCode || null
);
@ -217,50 +114,23 @@ export const ProductCreatePage: React.FC<ProductCreatePageProps> = ({
value: taxType.taxCode
})) || [];
const handleSubmit = (data: FormData) =>
onSubmit({
...data,
attributes,
stocks
});
return (
<Form onSubmit={handleSubmit} initial={initialData} confirmLeave>
{({ 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);
<ProductCreateForm
onSubmit={onSubmit}
initial={initial}
categories={categories}
collections={collections}
productTypes={productTypeChoiceList}
selectedCollections={selectedCollections}
setSelectedCategory={setSelectedCategory}
setSelectedCollections={setSelectedCollections}
setSelectedTaxType={setSelectedTaxType}
taxTypes={taxTypeChoices}
warehouses={warehouses}
>
{({ change, data, handlers, hasChanged, submit }) => {
// Comparing explicitly to false because `hasVariants` can be undefined
const isSimpleProduct = data.productType?.hasVariants === false;
return (
<Container>
@ -278,17 +148,17 @@ export const ProductCreatePage: React.FC<ProductCreatePageProps> = ({
onChange={change}
/>
<CardSpacer />
{attributes.length > 0 && (
{data.attributes.length > 0 && (
<ProductAttributes
attributes={attributes}
attributes={data.attributes}
disabled={disabled}
errors={errors}
onChange={handleAttributeChange}
onMultiChange={handleAttributeMultiChange}
onChange={handlers.selectAttribute}
onMultiChange={handlers.selectAttributeMultiple}
/>
)}
<CardSpacer />
{!!productType && !productType.hasVariants && (
{isSimpleProduct && (
<>
<ProductShipping
data={data}
@ -311,27 +181,11 @@ export const ProductCreatePage: React.FC<ProductCreatePageProps> = ({
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}
/>
<CardSpacer />
@ -353,7 +207,7 @@ export const ProductCreatePage: React.FC<ProductCreatePageProps> = ({
onChange={change}
/>
<CardSpacer />
<Metadata data={data} onChange={changeMetadata} />
<Metadata data={data} onChange={handlers.changeMetadata} />
</div>
<div>
<ProductOrganization
@ -370,12 +224,12 @@ export const ProductCreatePage: React.FC<ProductCreatePageProps> = ({
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}
/>
<CardSpacer />
@ -409,7 +263,7 @@ export const ProductCreatePage: React.FC<ProductCreatePageProps> = ({
data={data}
disabled={disabled}
onChange={change}
onTaxTypeChange={handleTaxTypeSelect}
onTaxTypeChange={handlers.selectTaxRate}
selectedTaxTypeDisplayName={selectedTaxType}
taxTypes={taxTypes}
/>
@ -424,7 +278,7 @@ export const ProductCreatePage: React.FC<ProductCreatePageProps> = ({
</Container>
);
}}
</Form>
</ProductCreateForm>
);
};
ProductCreatePage.displayName = "ProductCreatePage";

View file

@ -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<string>
> &
Record<"addStock" | "deleteStock", (id: string) => void>;
export interface UseProductCreateFormResult {
change: FormChange;
data: ProductCreateData;
handlers: ProductCreateHandlers;
hasChanged: boolean;
submit: () => Promise<boolean>;
}
export interface UseProductCreateFormOpts
extends Record<
"categories" | "collections" | "taxTypes",
SingleAutocompleteChoiceType[]
> {
setSelectedCategory: React.Dispatch<React.SetStateAction<string>>;
setSelectedCollections: React.Dispatch<
React.SetStateAction<MultiAutocompleteChoiceType[]>
>;
setSelectedTaxType: React.Dispatch<React.SetStateAction<string>>;
selectedCollections: MultiAutocompleteChoiceType[];
productTypes: SearchProductTypes_search_edges_node[];
warehouses: SearchWarehouses_search_edges_node[];
}
export interface ProductCreateFormProps extends UseProductCreateFormOpts {
children: (props: UseProductCreateFormResult) => React.ReactNode;
initial?: Partial<ProductCreateFormData>;
onSubmit: (data: ProductCreateData) => Promise<boolean>;
}
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<ProductCreateFormData>,
onSubmit: (data: ProductCreateData) => Promise<boolean>,
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<ProductAttributeInputData>(
initial?.productType
? getAttributeInputFromProductType(initialProductType)
: []
);
const stocks = useFormset<null, string>([]);
const [productType, setProductType] = useStateFromProps<ProductType>(
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<string> = (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<ProductCreateFormProps> = ({
children,
initial,
onSubmit,
...rest
}) => {
const props = useProductCreateForm(initial || {}, onSubmit, rest);
return <form onSubmit={props.submit}>{children(props)}</form>;
};
ProductCreateForm.displayName = "ProductCreateForm";
export default ProductCreateForm;

View file

@ -1,2 +1,3 @@
export { default } from "./ProductCreatePage";
export * from "./ProductCreatePage";
export * from "./form";

View file

@ -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<ProductOrganizationProps> = props => {
defaultMessage: "Product Type"
})}
choices={productTypes}
value={data.productType}
value={data.productType?.id}
onChange={onProductTypeChange}
fetchChoices={fetchProductTypes}
data-test="product-type"

View file

@ -127,9 +127,10 @@ const ProductStocks: React.FC<ProductStocksProps> = ({
const anchor = React.useRef<HTMLDivElement>();
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<ProductStocksProps> = ({
</span>
</div>
</Typography>
{!warehouses.length && (
{!warehouses?.length && (
<Typography color="textSecondary" className={classes.noWarehouseInfo}>
{hasVariants ? (
<>
@ -219,7 +220,7 @@ const ProductStocks: React.FC<ProductStocksProps> = ({
</Typography>
)}
</CardContent>
{warehouses.length > 0 && (
{warehouses?.length > 0 && (
<Table>
<TableHead>
<TableRow>

View file

@ -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<ProductUpdatePageProps> = ({
}) => {
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<ProductUpdatePageProps> = ({
product?.taxType.description
);
const {
isMetadataModified,
isPrivateMetadataModified,
makeChangeHandler: makeMetadataChangeHandler
} = useMetadataChangeTrigger();
const initialData = getProductUpdatePageFormData(product, variants);
const initialDescription = maybe<RawDraftContentState>(() =>
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 (
<Form onSubmit={handleSubmit} initial={initialData} confirmLeave>
{({ 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 (
<>
<Container>
<AppHeader onBack={onBack}>
{intl.formatMessage(sectionNames.products)}
</AppHeader>
<PageHeader title={header} />
<Grid>
<div>
<ProductDetailsForm
data={data}
disabled={disabled}
<ProductUpdateForm
onSubmit={onSubmit}
product={product}
categories={categories}
collections={collections}
selectedCollections={selectedCollections}
setSelectedCategory={setSelectedCategory}
setSelectedCollections={setSelectedCollections}
setSelectedTaxType={setSelectedTaxType}
taxTypes={taxTypeChoices}
warehouses={warehouses}
>
{({ change, data, handlers, hasChanged, submit }) => (
<>
<Container>
<AppHeader onBack={onBack}>
{intl.formatMessage(sectionNames.products)}
</AppHeader>
<PageHeader title={header} />
<Grid>
<div>
<ProductDetailsForm
data={data}
disabled={disabled}
errors={errors}
initialDescription={initialDescription}
onChange={change}
/>
<CardSpacer />
<ProductImages
images={images}
placeholderImage={placeholderImage}
onImageDelete={onImageDelete}
onImageReorder={onImageReorder}
onImageEdit={onImageEdit}
onImageUpload={onImageUpload}
/>
<CardSpacer />
{data.attributes.length > 0 && (
<ProductAttributes
attributes={data.attributes}
errors={errors}
initialDescription={initialDescription}
onChange={change}
disabled={disabled}
onChange={handlers.selectAttribute}
onMultiChange={handlers.selectAttributeMultiple}
/>
<CardSpacer />
<ProductImages
images={images}
placeholderImage={placeholderImage}
onImageDelete={onImageDelete}
onImageReorder={onImageReorder}
onImageEdit={onImageEdit}
onImageUpload={onImageUpload}
/>
<CardSpacer />
{attributes.length > 0 && (
<ProductAttributes
attributes={attributes}
)}
<CardSpacer />
{!!product?.productType && !hasVariants && (
<>
<ProductPricing
currency={currency}
data={data}
disabled={disabled}
errors={errors}
disabled={disabled}
onChange={handleAttributeChange}
onMultiChange={handleAttributeMultiChange}
onChange={change}
/>
)}
<CardSpacer />
{!!product?.productType && !hasVariants && (
<>
<ProductPricing
currency={currency}
data={data}
disabled={disabled}
errors={errors}
onChange={change}
/>
<CardSpacer />
</>
)}
{hasVariants ? (
<ProductVariants
<CardSpacer />
</>
)}
{hasVariants ? (
<ProductVariants
disabled={disabled}
variants={variants}
product={product}
fallbackPrice={
product?.variants?.length
? product.variants[0].price
: undefined
}
onRowClick={onVariantShow}
onVariantAdd={onVariantAdd}
onVariantsAdd={onVariantsAdd}
onVariantReorder={onVariantReorder}
onSetDefaultVariant={onSetDefaultVariant}
toolbar={toolbar}
isChecked={isChecked}
selected={selected}
toggle={toggle}
toggleAll={toggleAll}
/>
) : (
<>
<ProductShipping
data={data}
disabled={disabled}
variants={variants}
product={product}
fallbackPrice={
product?.variants?.length
? product.variants[0].price
: undefined
errors={errors}
weightUnit={product?.weight?.unit || defaultWeightUnit}
onChange={change}
/>
<CardSpacer />
<ProductStocks
data={data}
disabled={disabled}
hasVariants={false}
errors={errors}
stocks={data.stocks}
warehouses={warehouses}
onChange={handlers.changeStock}
onFormDataChange={change}
onWarehouseStockAdd={handlers.addStock}
onWarehouseStockDelete={handlers.deleteStock}
onWarehouseConfigure={onWarehouseConfigure}
/>
</>
)}
<CardSpacer />
<SeoForm
errors={errors}
title={data.seoTitle}
titlePlaceholder={data.name}
description={data.seoDescription}
descriptionPlaceholder={maybe(() =>
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"
})}
/>
<CardSpacer />
<Metadata data={data} onChange={handlers.changeMetadata} />
</div>
<div>
<ProductOrganization
canChangeType={false}
categories={categories}
categoryInputDisplayValue={selectedCategory}
collections={collections}
collectionsInputDisplayValue={selectedCollections}
data={data}
disabled={disabled}
errors={errors}
fetchCategories={fetchCategories}
fetchCollections={fetchCollections}
fetchMoreCategories={fetchMoreCategories}
fetchMoreCollections={fetchMoreCollections}
productType={product?.productType}
onCategoryChange={handlers.selectCategory}
onCollectionChange={handlers.selectCollection}
/>
<CardSpacer />
<AvailabilityCard
data={data}
errors={errors}
disabled={disabled}
messages={{
hiddenLabel: intl.formatMessage({
defaultMessage: "Not published",
description: "product label"
}),
hiddenSecondLabel: intl.formatMessage(
{
defaultMessage: "will become published on {date}",
description: "product publication date label"
},
{
date: localizeDate(data.publicationDate, "L")
}
onRowClick={onVariantShow}
onVariantAdd={onVariantAdd}
onVariantsAdd={onVariantsAdd}
onVariantReorder={onVariantReorder}
onSetDefaultVariant={onSetDefaultVariant}
toolbar={toolbar}
isChecked={isChecked}
selected={selected}
toggle={toggle}
toggleAll={toggleAll}
/>
) : (
<>
<ProductShipping
data={data}
disabled={disabled}
errors={errors}
weightUnit={product?.weight?.unit || defaultWeightUnit}
onChange={change}
/>
<CardSpacer />
<ProductStocks
data={data}
disabled={disabled}
hasVariants={false}
errors={errors}
stocks={stocks}
warehouses={warehouses}
onChange={(id, value) => {
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}
/>
</>
)}
<CardSpacer />
<SeoForm
errors={errors}
title={data.seoTitle}
titlePlaceholder={data.name}
description={data.seoDescription}
descriptionPlaceholder={maybe(() =>
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"
})}
/>
<CardSpacer />
<Metadata data={data} onChange={changeMetadata} />
</div>
<div>
<ProductOrganization
canChangeType={false}
categories={categories}
categoryInputDisplayValue={selectedCategory}
collections={collections}
collectionsInputDisplayValue={selectedCollections}
data={data}
disabled={disabled}
errors={errors}
fetchCategories={fetchCategories}
fetchCollections={fetchCollections}
fetchMoreCategories={fetchMoreCategories}
fetchMoreCollections={fetchMoreCollections}
productType={maybe(() => product.productType)}
onCategoryChange={handleCategorySelect}
onCollectionChange={handleCollectionSelect}
/>
<CardSpacer />
<AvailabilityCard
data={data}
errors={errors}
disabled={disabled}
messages={{
hiddenLabel: intl.formatMessage({
defaultMessage: "Not published",
description: "product label"
}),
hiddenSecondLabel: intl.formatMessage(
{
defaultMessage: "will become published on {date}",
description: "product publication date label"
},
{
date: localizeDate(data.publicationDate, "L")
}
),
visibleLabel: intl.formatMessage({
defaultMessage: "Published",
description: "product label"
})
}}
onChange={change}
/>
<CardSpacer />
<ProductTaxes
data={data}
disabled={disabled}
selectedTaxTypeDisplayName={selectedTaxType}
taxTypes={taxTypes}
onChange={change}
onTaxTypeChange={handleTaxTypeSelect}
/>
</div>
</Grid>
<SaveButtonBar
onCancel={onBack}
onDelete={onDelete}
onSave={submit}
state={saveButtonBarState}
disabled={disabled || !hasChanged}
/>
</Container>
</>
);
}}
</Form>
),
visibleLabel: intl.formatMessage({
defaultMessage: "Published",
description: "product label"
})
}}
onChange={change}
/>
<CardSpacer />
<ProductTaxes
data={data}
disabled={disabled}
selectedTaxTypeDisplayName={selectedTaxType}
taxTypes={taxTypes}
onChange={change}
onTaxTypeChange={handlers.selectTaxRate}
/>
</div>
</Grid>
<SaveButtonBar
onCancel={onBack}
onDelete={onDelete}
onSave={submit}
state={saveButtonBarState}
disabled={disabled || !hasChanged}
/>
</Container>
</>
)}
</ProductUpdateForm>
);
};
ProductUpdatePage.displayName = "ProductUpdatePage";

View file

@ -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<string>
> &
Record<"addStock" | "deleteStock", (id: string) => void>;
export interface UseProductUpdateFormResult {
change: FormChange;
data: ProductUpdateData;
handlers: ProductUpdateHandlers;
hasChanged: boolean;
submit: () => Promise<boolean>;
}
export interface UseProductUpdateFormOpts
extends Record<
"categories" | "collections" | "taxTypes",
SingleAutocompleteChoiceType[]
> {
setSelectedCategory: React.Dispatch<React.SetStateAction<string>>;
setSelectedCollections: React.Dispatch<
React.SetStateAction<MultiAutocompleteChoiceType[]>
>;
setSelectedTaxType: React.Dispatch<React.SetStateAction<string>>;
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<null, string>
) => {
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<string> = (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<ProductUpdateFormProps> = ({
children,
product,
onSubmit,
...rest
}) => {
const props = useProductUpdateForm(product, onSubmit, rest);
return <form onSubmit={props.submit}>{children(props)}</form>;
};
ProductUpdateForm.displayName = "ProductUpdateForm";
export default ProductUpdateForm;

View file

@ -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<VariantAttributeInputData>;
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<ProductVariantCreatePageProps> = ({
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<null, string>([]);
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 (
<Form initial={initialForm} onSubmit={handleSubmit}>
{({ change, data, hasChanged, submit, triggerChange }) => {
const handleAttributeChange: FormsetChange = (id, value) => {
changeAttributeData(id, value);
triggerChange();
};
const changeMetadata = makeMetadataChangeHandler(change);
return (
<Container>
<AppHeader onBack={onBack}>{maybe(() => product.name)}</AppHeader>
<PageHeader title={header} />
<Grid variant="inverted">
<div>
<ProductVariantNavigation
fallbackThumbnail={maybe(() => product.thumbnail.url)}
variants={maybe(() => product.variants)}
onRowClick={(variantId: string) => {
if (product && product.variants) {
return onVariantClick(variantId);
}
}}
onReorder={onVariantReorder}
/>
</div>
<div>
<ProductVariantAttributes
attributes={attributes}
disabled={disabled}
errors={errors}
onChange={handleAttributeChange}
/>
<CardSpacer />
<ProductVariantPrice
data={data}
errors={errors}
currencySymbol={currencySymbol}
loading={disabled}
onChange={change}
/>
<CardSpacer />
<ProductShipping
data={data}
disabled={disabled}
errors={errors}
weightUnit={weightUnit}
onChange={change}
/>
<CardSpacer />
<ProductStocks
data={data}
disabled={disabled}
hasVariants={true}
onFormDataChange={change}
errors={errors}
stocks={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);
}}
onWarehouseConfigure={onWarehouseConfigure}
/>
<CardSpacer />
<Metadata data={data} onChange={changeMetadata} />
</div>
</Grid>
<SaveButtonBar
disabled={disabled || !onSubmit || !hasChanged}
labels={{
delete: intl.formatMessage({
defaultMessage: "Delete Variant",
description: "button"
}),
save: intl.formatMessage({
defaultMessage: "Save variant",
description: "button"
})
}}
state={saveButtonBarState}
onCancel={onBack}
onSave={submit}
/>
</Container>
);
}}
</Form>
<ProductVariantCreateForm
product={product}
onSubmit={onSubmit}
warehouses={warehouses}
>
{({ change, data, handlers, hasChanged, submit }) => (
<Container>
<AppHeader onBack={onBack}>{product?.name}</AppHeader>
<PageHeader title={header} />
<Grid variant="inverted">
<div>
<ProductVariantNavigation
fallbackThumbnail={product?.thumbnail?.url}
variants={product?.variants}
onRowClick={(variantId: string) => {
if (product && product.variants) {
return onVariantClick(variantId);
}
}}
onReorder={onVariantReorder}
/>
</div>
<div>
<ProductVariantAttributes
attributes={data.attributes}
disabled={disabled}
errors={errors}
onChange={handlers.selectAttribute}
/>
<CardSpacer />
<ProductVariantPrice
data={data}
errors={errors}
currencySymbol={currencySymbol}
loading={disabled}
onChange={change}
/>
<CardSpacer />
<ProductShipping
data={data}
disabled={disabled}
errors={errors}
weightUnit={weightUnit}
onChange={change}
/>
<CardSpacer />
<ProductStocks
data={data}
disabled={disabled}
hasVariants={true}
onFormDataChange={change}
errors={errors}
stocks={data.stocks}
warehouses={warehouses}
onChange={handlers.changeStock}
onWarehouseStockAdd={handlers.addStock}
onWarehouseStockDelete={handlers.deleteStock}
onWarehouseConfigure={onWarehouseConfigure}
/>
<CardSpacer />
<Metadata data={data} onChange={handlers.changeMetadata} />
</div>
</Grid>
<SaveButtonBar
disabled={disabled || !onSubmit || !hasChanged}
labels={{
delete: intl.formatMessage({
defaultMessage: "Delete Variant",
description: "button"
}),
save: intl.formatMessage({
defaultMessage: "Save variant",
description: "button"
})
}}
state={saveButtonBarState}
onCancel={onBack}
onSave={submit}
/>
</Container>
)}
</ProductVariantCreateForm>
);
};
ProductVariantCreatePage.displayName = "ProductVariantCreatePage";

View file

@ -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<VariantAttributeInputData, string>;
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<null, string>([]);
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<ProductVariantCreateFormProps> = ({
children,
product,
onSubmit,
...rest
}) => {
const props = useProductVariantCreateForm(product, onSubmit, rest);
return <form onSubmit={props.submit}>{children(props)}</form>;
};
ProductVariantCreateForm.displayName = "ProductVariantCreateForm";
export default ProductVariantCreateForm;

View file

@ -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<ProductVariantPageProps> = ({
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<ProductVariantPageProps> = ({
/>
)}
</PageHeader>
<Form initial={initialForm} onSubmit={handleSubmit} confirmLeave>
{({ change, data, hasChanged, submit, triggerChange }) => {
const handleAttributeChange: FormsetChange = (id, value) => {
changeAttributeData(id, value);
triggerChange();
};
const changeMetadata = makeMetadataChangeHandler(change);
return (
<>
<Grid variant="inverted">
<div>
<ProductVariantNavigation
current={variant ? variant.id : undefined}
defaultVariantId={defaultVariantId}
fallbackThumbnail={maybe(
() => variant.product.thumbnail.url
)}
variants={maybe(() => variant.product.variants)}
onAdd={onAdd}
onRowClick={(variantId: string) => {
if (variant) {
return onVariantClick(variantId);
}
}}
onReorder={onVariantReorder}
/>
</div>
<div>
<ProductVariantAttributes
attributes={attributes}
disabled={loading}
errors={errors}
onChange={handleAttributeChange}
/>
<CardSpacer />
<ProductVariantImages
disabled={loading}
images={images}
placeholderImage={placeholderImage}
onImageAdd={toggleModal}
/>
<CardSpacer />
<ProductVariantPrice
errors={errors}
data={data}
currencySymbol={
variant && variant.price
? variant.price.currency
: variant && variant.costPrice
? variant.costPrice.currency
: ""
<ProductVariantUpdateForm
variant={variant}
onSubmit={onSubmit}
warehouses={warehouses}
>
{({ change, data, handlers, hasChanged, submit }) => (
<>
<Grid variant="inverted">
<div>
<ProductVariantNavigation
current={variant ? variant.id : undefined}
defaultVariantId={defaultVariantId}
fallbackThumbnail={maybe(
() => variant.product.thumbnail.url
)}
variants={maybe(() => variant.product.variants)}
onAdd={onAdd}
onRowClick={(variantId: string) => {
if (variant) {
return onVariantClick(variantId);
}
loading={loading}
onChange={change}
/>
<CardSpacer />
<ProductShipping
data={data}
disabled={loading}
errors={errors}
weightUnit={variant?.weight?.unit || defaultWeightUnit}
onChange={change}
/>
<CardSpacer />
<ProductStocks
data={data}
disabled={loading}
hasVariants={true}
errors={errors}
stocks={stocks}
warehouses={warehouses}
onChange={(id, value) => {
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}
/>
<CardSpacer />
<Metadata data={data} onChange={changeMetadata} />
</div>
</Grid>
<SaveButtonBar
disabled={loading || !hasChanged}
state={saveButtonBarState}
onCancel={onBack}
onDelete={onDelete}
onSave={submit}
/>
</>
);
}}
</Form>
}}
onReorder={onVariantReorder}
/>
</div>
<div>
<ProductVariantAttributes
attributes={data.attributes}
disabled={loading}
errors={errors}
onChange={handlers.selectAttribute}
/>
<CardSpacer />
<ProductVariantImages
disabled={loading}
images={images}
placeholderImage={placeholderImage}
onImageAdd={toggleModal}
/>
<CardSpacer />
<ProductVariantPrice
data={data}
errors={errors}
currencySymbol={
variant && variant.price
? variant.price.currency
: variant && variant.costPrice
? variant.costPrice.currency
: ""
}
loading={loading}
onChange={change}
/>
<CardSpacer />
<ProductShipping
data={data}
disabled={loading}
errors={errors}
weightUnit={variant?.weight?.unit || defaultWeightUnit}
onChange={change}
/>
<CardSpacer />
<ProductStocks
data={data}
disabled={loading}
hasVariants={true}
errors={errors}
stocks={data.stocks}
warehouses={warehouses}
onChange={handlers.changeStock}
onFormDataChange={change}
onWarehouseStockAdd={handlers.addStock}
onWarehouseStockDelete={handlers.deleteStock}
onWarehouseConfigure={onWarehouseConfigure}
/>
<CardSpacer />
<Metadata data={data} onChange={handlers.changeMetadata} />
</div>
</Grid>
<SaveButtonBar
disabled={loading || !hasChanged}
state={saveButtonBarState}
onCancel={onBack}
onDelete={onDelete}
onSave={submit}
/>
</>
)}
</ProductVariantUpdateForm>
</Container>
{variant && (
<ProductVariantImageSelectDialog

View file

@ -0,0 +1,180 @@
import { MetadataFormData } from "@saleor/components/Metadata";
import { ProductVariant } from "@saleor/fragments/types/ProductVariant";
import useForm, { FormChange, SubmitPromise } from "@saleor/hooks/useForm";
import useFormset, {
FormsetChange,
FormsetData
} from "@saleor/hooks/useFormset";
import {
getAttributeInputFromVariant,
getStockInputFromVariant
} from "@saleor/products/utils/data";
import { SearchWarehouses_search_edges_node } from "@saleor/searches/types/SearchWarehouses";
import { mapMetadataItemToInput } from "@saleor/utils/maps";
import useMetadataChangeTrigger from "@saleor/utils/metadata/useMetadataChangeTrigger";
import { diff } from "fast-array-diff";
import React from "react";
import handleFormSubmit from "../../../utils/handlers/handleFormSubmit";
import { ProductStockInput } from "../ProductStocks";
import { VariantAttributeInputData } from "../ProductVariantAttributes";
export interface ProductVariantUpdateFormData extends MetadataFormData {
costPrice: string;
price: string;
sku: string;
trackInventory: boolean;
weight: string;
}
export interface ProductVariantUpdateData extends ProductVariantUpdateFormData {
attributes: FormsetData<VariantAttributeInputData, string>;
stocks: ProductStockInput[];
}
export interface ProductVariantUpdateSubmitData
extends ProductVariantUpdateFormData {
attributes: FormsetData<VariantAttributeInputData, string>;
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<ProductVariantUpdateFormProps> = ({
children,
variant,
onSubmit,
...rest
}) => {
const props = useProductVariantUpdateForm(variant, onSubmit, rest);
return <form onSubmit={props.submit}>{children(props)}</form>;
};
ProductVariantUpdateForm.displayName = "ProductVariantUpdateForm";
export default ProductVariantUpdateForm;

View file

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

View file

@ -8,7 +8,7 @@ import { getAttributeInputFromProductType, ProductType } from "./data";
export function createAttributeChangeHandler(
changeAttributeData: FormsetChange<string[]>,
triggerChange: () => void
): FormsetChange {
): FormsetChange<string> {
return (attributeId: string, value: string) => {
triggerChange();
changeAttributeData(attributeId, value === "" ? [] : [value]);
@ -19,7 +19,7 @@ export function createAttributeMultiChangeHandler(
changeAttributeData: FormsetChange<string[]>,
attributes: FormsetData<ProductAttributeInputData, string[]>,
triggerChange: () => void
): FormsetChange {
): FormsetChange<string> {
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<ProductAttributeInputData>) => void,
setProductType: (productType: ProductType) => void,
productTypeChoiceList: ProductType[]
productTypeChoiceList: ProductType[],
triggerChange: () => void
): FormChange {
return (event: React.ChangeEvent<any>) => {
const id = event.target.value;
const selectedProductType = productTypeChoiceList.find(
productType => productType.id === id
);
triggerChange();
setProductType(selectedProductType);
change(event);
setAttributes(getAttributeInputFromProductType(selectedProductType));
};
}

View file

@ -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: {

View file

@ -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<ProductVariantCreateProps> = ({
);
const handleBack = () => navigate(productUrl(productId));
const handleCreate = async (formData: ProductVariantCreatePageSubmitData) => {
const handleCreate = async (formData: ProductVariantCreateData) => {
const result = await variantCreate({
variables: {
input: {

View file

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

View file

@ -148,13 +148,13 @@ const ShippingZoneDetails: React.FC<ShippingZoneDetailsProps> = ({
}
});
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<ShippingZoneDetailsProps> = ({
}
}
});
return result.data.shippingZoneUpdate.errors;
};
if (data?.shippingZone === null) {

View file

@ -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<SiteSettingsPageProps> = props => {
const {
errors: validationErrors,
submit: handleSubmitWithAddress
} = useAddressValidation<SiteSettingsPageFormData>(onSubmit);
} = useAddressValidation(onSubmit);
const initialFormAddress: SiteSettingsPageAddressFormData = {
city: maybe(() => shop.companyAddress.city, ""),
@ -134,7 +135,7 @@ const SiteSettingsPage: React.FC<SiteSettingsPageProps> = props => {
const submitFunc = areAddressInputFieldsModified(data)
? handleSubmitWithAddress
: onSubmit;
submitFunc(data);
return submitFunc(data);
}}
confirmLeave
>

View file

@ -106,7 +106,7 @@ export const SiteSettings: React.FC<SiteSettingsProps> = ({ params }) => {
keyType: data.type
}
});
const handleUpdateShopSettings = (
const handleUpdateShopSettings = async (
data: SiteSettingsPageFormData
) => {
const addressInput = areAddressInputFieldsModified(data)
@ -123,7 +123,7 @@ export const SiteSettings: React.FC<SiteSettingsProps> = ({ params }) => {
: {
companyName: data.companyName
};
updateShopSettings({
const result = await updateShopSettings({
variables: {
addressInput,
shopDomainInput: {
@ -139,6 +139,12 @@ export const SiteSettings: React.FC<SiteSettingsProps> = ({ params }) => {
}
}
});
return [
...result.data.shopAddressUpdate.errors,
...result.data.shopDomainUpdate.errors,
...result.data.shopSettingsUpdate.errors
];
};
return (

View file

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

View file

@ -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<OrderListProps> = ({ id, params }) => {
return (
<TypedStaffMemberUpdateMutation onCompleted={handleStaffMemberUpdate}>
{(updateStaffMember, updateResult) => (
<TypedStaffMemberDeleteMutation
variables={{ id }}
onCompleted={handleStaffMemberDelete}
>
{(deleteStaffMember, deleteResult) => (
<TypedStaffAvatarUpdateMutation
onCompleted={handleStaffMemberAvatarUpdate}
>
{updateStaffAvatar => (
<TypedStaffAvatarDeleteMutation
onCompleted={handleStaffMemberAvatarDelete}
>
{(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 (
<>
<WindowTitle
title={getStringOrPlaceholder(
staffMember?.email
)}
/>
<StaffDetailsPage
errors={
updateResult?.data?.staffUpdate?.errors || []
}
canEditAvatar={isUserSameAsViewer}
canEditPreferences={isUserSameAsViewer}
canEditStatus={!isUserSameAsViewer}
canRemove={!isUserSameAsViewer}
disabled={loading}
onBack={handleBack}
initialSearch=""
onChangePassword={() =>
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 (
<TypedStaffMemberDeleteMutation
variables={{ id }}
onCompleted={handleStaffMemberDelete}
>
{(deleteStaffMember, deleteResult) => (
<TypedStaffAvatarUpdateMutation
onCompleted={handleStaffMemberAvatarUpdate}
>
{updateStaffAvatar => (
<TypedStaffAvatarDeleteMutation
onCompleted={handleStaffMemberAvatarDelete}
>
{(deleteStaffAvatar, deleteAvatarResult) => {
const isUserSameAsViewer =
user.user?.id === data?.user?.id;
return (
<>
<WindowTitle
title={getStringOrPlaceholder(
staffMember?.email
)}
/>
<StaffDetailsPage
errors={
updateResult?.data?.staffUpdate?.errors ||
[]
}
canEditAvatar={isUserSameAsViewer}
canEditPreferences={isUserSameAsViewer}
canEditStatus={!isUserSameAsViewer}
canRemove={!isUserSameAsViewer}
disabled={loading}
onBack={handleBack}
initialSearch=""
onChangePassword={() =>
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}
/>
<ActionDialog
open={params.action === "remove"}
title={intl.formatMessage({
defaultMessage: "delete Staff User",
description: "dialog header"
})}
confirmButtonState={deleteResult.status}
variant="delete"
onClose={closeModal}
onConfirm={deleteStaffMember}
>
<DialogContentText>
<FormattedMessage
defaultMessage="Are you sure you want to delete {email} from staff members?"
values={{
email: getStringOrPlaceholder(
data?.user?.email
)
}}
/>
</DialogContentText>
</ActionDialog>
<ActionDialog
open={params.action === "remove-avatar"}
title={intl.formatMessage({
defaultMessage: "Delete Staff User Avatar",
description: "dialog header"
})}
confirmButtonState={deleteAvatarResult.status}
variant="delete"
onClose={closeModal}
onConfirm={deleteStaffAvatar}
>
<DialogContentText>
<FormattedMessage
defaultMessage="Are you sure you want to remove {email} avatar?"
values={{
email: (
<strong>
{getStringOrPlaceholder(
data?.user?.email
)}
</strong>
)
}}
/>
</DialogContentText>
</ActionDialog>
<StaffPasswordResetDialog
confirmButtonState={changePasswordOpts.status}
errors={
changePasswordOpts?.data?.passwordChange
?.errors || []
}
open={params.action === "change-password"}
onClose={closeModal}
onSubmit={data =>
changePassword({
variables: data
})
}
/>
</>
);
}}
</TypedStaffAvatarDeleteMutation>
)}
</TypedStaffAvatarUpdateMutation>
)}
</TypedStaffMemberDeleteMutation>
)}
}
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}
/>
<ActionDialog
open={params.action === "remove"}
title={intl.formatMessage({
defaultMessage: "delete Staff User",
description: "dialog header"
})}
confirmButtonState={deleteResult.status}
variant="delete"
onClose={closeModal}
onConfirm={deleteStaffMember}
>
<DialogContentText>
<FormattedMessage
defaultMessage="Are you sure you want to delete {email} from staff members?"
values={{
email: getStringOrPlaceholder(
data?.user?.email
)
}}
/>
</DialogContentText>
</ActionDialog>
<ActionDialog
open={params.action === "remove-avatar"}
title={intl.formatMessage({
defaultMessage: "Delete Staff User Avatar",
description: "dialog header"
})}
confirmButtonState={deleteAvatarResult.status}
variant="delete"
onClose={closeModal}
onConfirm={deleteStaffAvatar}
>
<DialogContentText>
<FormattedMessage
defaultMessage="Are you sure you want to remove {email} avatar?"
values={{
email: (
<strong>
{getStringOrPlaceholder(
data?.user?.email
)}
</strong>
)
}}
/>
</DialogContentText>
</ActionDialog>
<StaffPasswordResetDialog
confirmButtonState={changePasswordOpts.status}
errors={
changePasswordOpts?.data?.passwordChange
?.errors || []
}
open={params.action === "change-password"}
onClose={closeModal}
onSubmit={data =>
changePassword({
variables: data
})
}
/>
</>
);
}}
</TypedStaffAvatarDeleteMutation>
)}
</TypedStaffAvatarUpdateMutation>
)}
</TypedStaffMemberDeleteMutation>
);
}}
</TypedStaffMemberUpdateMutation>
);
}}

View file

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

View file

@ -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<keyof FormData>).map(field => ({
] as Array<keyof VoucherDetailsPageFormData>).map(field => ({
__typename: "DiscountError",
code: DiscountErrorCode.INVALID,
field

View file

@ -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<keyof FormData>).map(field => ({
] as Array<keyof PageDetailsPageFormData>).map(field => ({
__typename: "PageError",
code: PageErrorCode.INVALID,
field

View file

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

View file

@ -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<keyof ProductCreatePageSubmitData | "attributes">).map(
field => ({
__typename: "ProductError",
attributes:
field === "attributes"
? [productTypes[0].productAttributes[0].id]
: null,
code: ProductErrorCode.INVALID,
field
})
)}
] as Array<keyof ProductCreateFormData | "attributes">).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]}

View file

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

View file

@ -0,0 +1,18 @@
import { SubmitPromise } from "@saleor/hooks/useForm";
async function handleFormSubmit<T>(
data: T,
onSubmit: (data: T) => SubmitPromise,
setChanged: (changed: boolean) => void
): Promise<boolean> {
const errors = await onSubmit(data);
const ok = errors.length === 0;
if (ok) {
setChanged(false);
}
return ok;
}
export default handleFormSubmit;

View file

@ -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<TData extends MetadataFormData, TError>(
variables: UpdatePrivateMetadataVariables
) => Promise<MutationFetchResult<UpdatePrivateMetadata>>
) {
return async (data: TData) => {
return async (
data: TData
): Promise<Array<MetadataErrorFragment | TError>> => {
const errors = await update(data);
if (errors.length > 0) {

View file

@ -58,7 +58,7 @@ const WarehouseCreatePage: React.FC<WarehouseCreatePageProps> = ({
const {
errors: validationErrors,
submit: handleSubmit
} = useAddressValidation<WarehouseCreatePageFormData>(onSubmit);
} = useAddressValidation(onSubmit);
return (
<Form initial={initialForm} onSubmit={handleSubmit}>

View file

@ -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<WarehouseDetailsPageProps> = ({
@ -58,7 +59,7 @@ const WarehouseDetailsPage: React.FC<WarehouseDetailsPageProps> = ({
const {
errors: validationErrors,
submit: handleSubmit
} = useAddressValidation<WarehouseDetailsPageFormData>(onSubmit);
} = useAddressValidation(onSubmit);
const initialForm: WarehouseDetailsPageFormData = {
city: maybe(() => warehouse.address.city, ""),
@ -76,7 +77,7 @@ const WarehouseDetailsPage: React.FC<WarehouseDetailsPageProps> = ({
return (
<Form initial={initialForm} onSubmit={handleSubmit}>
{({ change, data, submit }) => {
{({ change, data, hasChanged, submit }) => {
const countryChoices = mapCountriesToChoices(countries);
const handleCountryChange = createSingleAutocompleteSelectHandler(
change,
@ -121,7 +122,7 @@ const WarehouseDetailsPage: React.FC<WarehouseDetailsPageProps> = ({
</div>
</Grid>
<SaveButtonBar
disabled={disabled}
disabled={disabled || !hasChanged}
onCancel={onBack}
onDelete={onDelete}
onSave={submit}

View file

@ -57,6 +57,7 @@ const WarehouseCreate: React.FC = () => {
streetAddress1: data.streetAddress1,
streetAddress2: data.streetAddress2
},
companyName: data.companyName,
name: data.name
}
}

View file

@ -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<WarehouseDetailsProps> = ({ id, params }) => {
return <NotFoundPage onBack={() => 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 (
<>
<WindowTitle title={data?.warehouse?.name} />
@ -88,26 +112,7 @@ const WarehouseDetails: React.FC<WarehouseDetailsProps> = ({ 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}
/>
<WarehouseDeleteDialog
confirmButtonState={deleteWarehouseTransitionState}