Add simple product stock management

This commit is contained in:
dominik-zeglen 2020-03-25 14:06:14 +01:00
parent db3b51f931
commit 4b661c9bff
12 changed files with 150 additions and 42 deletions

View file

@ -172,7 +172,7 @@ export const CategoryDetails: React.FC<CategoryDetailsProps> = ({
disabled={loading} disabled={loading}
errors={updateResult.data?.categoryUpdate.errors || []} errors={updateResult.data?.categoryUpdate.errors || []}
onAddCategory={() => navigate(categoryAddUrl(id))} onAddCategory={() => navigate(categoryAddUrl(id))}
onAddProduct={() => navigate(productAddUrl)} onAddProduct={() => navigate(productAddUrl())}
onBack={() => onBack={() =>
navigate( navigate(
maybe( maybe(

View file

@ -46,7 +46,7 @@ export function searchInCommands(
{ {
label: intl.formatMessage(messages.createProduct), label: intl.formatMessage(messages.createProduct),
onClick: () => { onClick: () => {
navigate(productAddUrl); navigate(productAddUrl());
return false; return false;
} }
}, },

View file

@ -28,6 +28,7 @@ import { SearchProductTypes_search_edges_node_productAttributes } from "@saleor/
import createMultiAutocompleteSelectHandler from "@saleor/utils/handlers/multiAutocompleteSelectChangeHandler"; import createMultiAutocompleteSelectHandler from "@saleor/utils/handlers/multiAutocompleteSelectChangeHandler";
import createSingleAutocompleteSelectHandler from "@saleor/utils/handlers/singleAutocompleteSelectChangeHandler"; import createSingleAutocompleteSelectHandler from "@saleor/utils/handlers/singleAutocompleteSelectChangeHandler";
import { ProductErrorFragment } from "@saleor/attributes/types/ProductErrorFragment"; import { ProductErrorFragment } from "@saleor/attributes/types/ProductErrorFragment";
import { SearchWarehouses_search_edges_node } from "@saleor/searches/types/SearchWarehouses";
import { FetchMoreProps } from "../../../types"; import { FetchMoreProps } from "../../../types";
import { import {
createAttributeChangeHandler, createAttributeChangeHandler,
@ -81,6 +82,7 @@ interface ProductCreatePageProps {
}>; }>;
header: string; header: string;
saveButtonBarState: ConfirmButtonTransitionState; saveButtonBarState: ConfirmButtonTransitionState;
warehouses: SearchWarehouses_search_edges_node[];
fetchCategories: (data: string) => void; fetchCategories: (data: string) => void;
fetchCollections: (data: string) => void; fetchCollections: (data: string) => void;
fetchProductTypes: (data: string) => void; fetchProductTypes: (data: string) => void;
@ -103,6 +105,7 @@ export const ProductCreatePage: React.FC<ProductCreatePageProps> = ({
header, header,
productTypes: productTypeChoiceList, productTypes: productTypeChoiceList,
saveButtonBarState, saveButtonBarState,
warehouses,
onBack, onBack,
fetchProductTypes, fetchProductTypes,
onSubmit, onSubmit,
@ -116,7 +119,18 @@ export const ProductCreatePage: React.FC<ProductCreatePageProps> = ({
data: attributes, data: attributes,
set: setAttributeData set: setAttributeData
} = useFormset<ProductAttributeInputData>([]); } = useFormset<ProductAttributeInputData>([]);
const { change: changeStockData, data: stocks } = useFormset<null>([]); const { change: changeStockData, data: stocks, set: setStocks } = useFormset<
null
>([]);
React.useEffect(() => {
const newStocks = warehouses.map(warehouse => ({
data: null,
id: warehouse.id,
label: warehouse.name,
value: stocks.find(stock => stock.id === warehouse.id)?.value || 0
}));
setStocks(newStocks);
}, [JSON.stringify(warehouses)]);
// Ensures that it will not change after component rerenders, because it // Ensures that it will not change after component rerenders, because it
// generates different block keys and it causes editor to lose its content. // generates different block keys and it causes editor to lose its content.
@ -248,7 +262,7 @@ export const ProductCreatePage: React.FC<ProductCreatePageProps> = ({
onFormDataChange={change} onFormDataChange={change}
errors={errors} errors={errors}
stocks={stocks} stocks={stocks}
onWarehouseEdit={onWarehouseEdit} onWarehousesEdit={onWarehouseEdit}
/> />
<CardSpacer /> <CardSpacer />
</> </>

View file

@ -21,6 +21,7 @@ import ControlledCheckbox from "@saleor/components/ControlledCheckbox";
import FormSpacer from "@saleor/components/FormSpacer"; import FormSpacer from "@saleor/components/FormSpacer";
import Hr from "@saleor/components/Hr"; import Hr from "@saleor/components/Hr";
import { renderCollection } from "@saleor/misc"; import { renderCollection } from "@saleor/misc";
import Link from "@saleor/components/Link";
export type ProductStockInput = FormsetAtomicData<null, string>; export type ProductStockInput = FormsetAtomicData<null, string>;
export interface ProductStockFormData { export interface ProductStockFormData {
@ -35,7 +36,7 @@ export interface ProductStocksProps {
stocks: ProductStockInput[]; stocks: ProductStockInput[];
onChange: FormsetChange; onChange: FormsetChange;
onFormDataChange: FormChange; onFormDataChange: FormChange;
onWarehousesEdit: () => undefined; onWarehousesEdit: () => void;
} }
const useStyles = makeStyles( const useStyles = makeStyles(
@ -171,24 +172,41 @@ const ProductStocks: React.FC<ProductStocksProps> = ({
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
{renderCollection(stocks, stock => ( {renderCollection(
<TableRow> stocks,
<TableCell className={classes.colName}>{stock.label}</TableCell> stock => (
<TableCell className={classes.colQuantity}> <TableRow>
<TextField <TableCell className={classes.colName}>{stock.label}</TableCell>
className={classes.inputComponent} <TableCell className={classes.colQuantity}>
fullWidth <TextField
inputProps={{ className={classes.inputComponent}
className: classes.input, fullWidth
min: 0, inputProps={{
type: "number" className: classes.input,
}} min: 0,
onChange={event => onChange(stock.id, event.target.value)} type: "number"
value={stock.value} }}
/> onChange={event => onChange(stock.id, event.target.value)}
</TableCell> value={stock.value}
</TableRow> />
))} </TableCell>
</TableRow>
),
() => (
<TableRow>
<TableCell colSpan={2}>
<FormattedMessage
defaultMessage={
"This product doesn't have any stock. You can add it <l>here</l>."
}
values={{
l: str => <Link onClick={onWarehousesEdit}>{str}</Link>
}}
/>
</TableCell>
</TableRow>
)
)}
</TableBody> </TableBody>
</Table> </Table>
</Card> </Card>

View file

@ -15,7 +15,6 @@ import ConfirmButton, {
import { buttonMessages } from "@saleor/intl"; import { buttonMessages } from "@saleor/intl";
import { SearchWarehouses_search_edges_node } from "@saleor/searches/types/SearchWarehouses"; import { SearchWarehouses_search_edges_node } from "@saleor/searches/types/SearchWarehouses";
import Skeleton from "@saleor/components/Skeleton"; import Skeleton from "@saleor/components/Skeleton";
import { Product_variants_stocks } from "@saleor/products/types/Product";
import ControlledCheckbox from "@saleor/components/ControlledCheckbox"; import ControlledCheckbox from "@saleor/components/ControlledCheckbox";
import { isSelected, toggle } from "@saleor/utils/lists"; import { isSelected, toggle } from "@saleor/utils/lists";
import useStateFromProps from "@saleor/hooks/useStateFromProps"; import useStateFromProps from "@saleor/hooks/useStateFromProps";
@ -44,8 +43,8 @@ export interface ProductWarehousesDialogProps {
disabled: boolean; disabled: boolean;
errors: Array<BulkStockErrorFragment | StockErrorFragment>; errors: Array<BulkStockErrorFragment | StockErrorFragment>;
open: boolean; open: boolean;
stocks: Product_variants_stocks[];
warehouses: SearchWarehouses_search_edges_node[]; warehouses: SearchWarehouses_search_edges_node[];
warehousesWithStocks: string[];
onClose: () => void; onClose: () => void;
onConfirm: (data: DiffData<string>) => void; onConfirm: (data: DiffData<string>) => void;
} }
@ -57,18 +56,18 @@ const ProductWarehousesDialog: React.FC<ProductWarehousesDialogProps> = ({
onClose, onClose,
onConfirm, onConfirm,
open, open,
stocks, warehousesWithStocks,
warehouses warehouses
}) => { }) => {
const classes = useStyles({}); const classes = useStyles({});
const intl = useIntl(); const intl = useIntl();
const initial = stocks?.map(stock => stock.warehouse.id) || [];
const [selectedWarehouses, setSelectedWarehouses] = useStateFromProps( const [selectedWarehouses, setSelectedWarehouses] = useStateFromProps(
initial warehousesWithStocks || []
); );
const handleConfirm = () => onConfirm(diff(initial, selectedWarehouses)); const handleConfirm = () =>
onConfirm(diff(warehousesWithStocks, selectedWarehouses));
return ( return (
<Dialog onClose={onClose} maxWidth="sm" fullWidth open={open}> <Dialog onClose={onClose} maxWidth="sm" fullWidth open={open}>

View file

@ -18,9 +18,10 @@ import {
ProductUrlQueryParams, ProductUrlQueryParams,
productVariantAddPath, productVariantAddPath,
productVariantEditPath, productVariantEditPath,
ProductVariantEditUrlQueryParams ProductVariantEditUrlQueryParams,
ProductAddUrlQueryParams
} from "./urls"; } from "./urls";
import ProductCreate from "./views/ProductCreate"; import ProductCreateComponent from "./views/ProductCreate";
import ProductImageComponent from "./views/ProductImage"; import ProductImageComponent from "./views/ProductImage";
import ProductListComponent from "./views/ProductList"; import ProductListComponent from "./views/ProductList";
import ProductUpdateComponent from "./views/ProductUpdate"; import ProductUpdateComponent from "./views/ProductUpdate";
@ -92,6 +93,13 @@ const ProductVariantCreate: React.FC<RouteComponentProps<any>> = ({
/> />
); );
const ProductCreate: React.FC<RouteComponentProps> = ({ location }) => {
const qs = parseQs(location.search.substr(1));
const params: ProductAddUrlQueryParams = qs;
return <ProductCreateComponent params={params} />;
};
const Component = () => { const Component = () => {
const intl = useIntl(); const intl = useIntl();

View file

@ -302,6 +302,7 @@ export const productCreateMutation = gql`
$sku: String $sku: String
$stockQuantity: Int $stockQuantity: Int
$seo: SeoInput $seo: SeoInput
$stocks: [StockInput!]!
) { ) {
productCreate( productCreate(
input: { input: {
@ -318,6 +319,7 @@ export const productCreateMutation = gql`
sku: $sku sku: $sku
quantity: $stockQuantity quantity: $stockQuantity
seo: $seo seo: $seo
stocks: $stocks
} }
) { ) {
errors: productErrors { errors: productErrors {

View file

@ -2,7 +2,7 @@
/* eslint-disable */ /* eslint-disable */
// This file was automatically generated and should not be edited. // This file was automatically generated and should not be edited.
import { AttributeValueInput, SeoInput, ProductErrorCode, AttributeInputTypeEnum } from "./../../types/globalTypes"; import { AttributeValueInput, SeoInput, StockInput, ProductErrorCode, AttributeInputTypeEnum } from "./../../types/globalTypes";
// ==================================================== // ====================================================
// GraphQL mutation operation: ProductCreate // GraphQL mutation operation: ProductCreate
@ -211,4 +211,5 @@ export interface ProductCreateVariables {
sku?: string | null; sku?: string | null;
stockQuantity?: number | null; stockQuantity?: number | null;
seo?: SeoInput | null; seo?: SeoInput | null;
stocks: StockInput[];
} }

View file

@ -17,7 +17,10 @@ import {
const productSection = "/products/"; const productSection = "/products/";
export const productAddPath = urlJoin(productSection, "add"); export const productAddPath = urlJoin(productSection, "add");
export const productAddUrl = productAddPath; export type ProductAddUrlDialog = "edit-stocks";
export type ProductAddUrlQueryParams = Dialog<ProductAddUrlDialog>;
export const productAddUrl = (params?: ProductAddUrlQueryParams): string =>
productAddPath + "?" + stringifyQs(params);
export const productListPath = productSection; export const productListPath = productSection;
export type ProductListUrlDialog = export type ProductListUrlDialog =

View file

@ -9,19 +9,31 @@ import useShop from "@saleor/hooks/useShop";
import useCategorySearch from "@saleor/searches/useCategorySearch"; import useCategorySearch from "@saleor/searches/useCategorySearch";
import useCollectionSearch from "@saleor/searches/useCollectionSearch"; import useCollectionSearch from "@saleor/searches/useCollectionSearch";
import useProductTypeSearch from "@saleor/searches/useProductTypeSearch"; import useProductTypeSearch from "@saleor/searches/useProductTypeSearch";
import useWarehouseSearch from "@saleor/searches/useWarehouseSearch";
import createDialogActionHandlers from "@saleor/utils/handlers/dialogActionHandlers";
import { SearchWarehouses_search_edges_node } from "@saleor/searches/types/SearchWarehouses";
import { decimal, maybe } from "../../misc"; import { decimal, maybe } from "../../misc";
import ProductCreatePage, { import ProductCreatePage, {
ProductCreatePageSubmitData ProductCreatePageSubmitData
} from "../components/ProductCreatePage"; } from "../components/ProductCreatePage";
import { TypedProductCreateMutation } from "../mutations"; import { TypedProductCreateMutation } from "../mutations";
import { ProductCreate } from "../types/ProductCreate"; import { ProductCreate } from "../types/ProductCreate";
import { productListUrl, productUrl } from "../urls"; import {
productListUrl,
productUrl,
ProductAddUrlDialog,
ProductAddUrlQueryParams,
productAddUrl
} from "../urls";
import ProductWarehousesDialog from "../components/ProductWarehousesDialog";
interface ProductUpdateProps { interface ProductCreateViewProps {
id: string; params: ProductAddUrlQueryParams;
} }
export const ProductUpdate: React.FC<ProductUpdateProps> = () => { export const ProductCreateView: React.FC<ProductCreateViewProps> = ({
params
}) => {
const navigate = useNavigator(); const navigate = useNavigator();
const notify = useNotifier(); const notify = useNotifier();
const shop = useShop(); const shop = useShop();
@ -47,6 +59,24 @@ export const ProductUpdate: React.FC<ProductUpdateProps> = () => {
} = useProductTypeSearch({ } = useProductTypeSearch({
variables: DEFAULT_INITIAL_SEARCH_DATA variables: DEFAULT_INITIAL_SEARCH_DATA
}); });
const {
loadMore: loadMoreWarehouses,
search: searchWarehouses,
result: searchWarehousesOpts
} = useWarehouseSearch({
variables: {
...DEFAULT_INITIAL_SEARCH_DATA,
first: 20
}
});
const [warehouses, setWarehouses] = React.useState<
SearchWarehouses_search_edges_node[]
>([]);
const [openModal, closeModal] = createDialogActionHandlers<
ProductAddUrlDialog,
ProductAddUrlQueryParams
>(navigate, productAddUrl, params);
const handleBack = () => navigate(productListUrl()); const handleBack = () => navigate(productListUrl());
@ -88,8 +118,10 @@ export const ProductUpdate: React.FC<ProductUpdateProps> = () => {
title: formData.seoTitle title: formData.seoTitle
}, },
sku: formData.sku, sku: formData.sku,
stockQuantity: stocks: formData.stocks.map(stock => ({
formData.stockQuantity !== null ? formData.stockQuantity : 0 quantity: parseInt(stock.value, 0),
warehouse: stock.id
}))
} }
}); });
}; };
@ -124,6 +156,7 @@ export const ProductUpdate: React.FC<ProductUpdateProps> = () => {
productTypes={maybe(() => productTypes={maybe(() =>
searchProductTypesOpts.data.search.edges.map(edge => edge.node) searchProductTypesOpts.data.search.edges.map(edge => edge.node)
)} )}
warehouses={warehouses}
onBack={handleBack} onBack={handleBack}
onSubmit={handleSubmit} onSubmit={handleSubmit}
saveButtonBarState={productCreateOpts.status} saveButtonBarState={productCreateOpts.status}
@ -148,6 +181,32 @@ export const ProductUpdate: React.FC<ProductUpdateProps> = () => {
loading: searchProductTypesOpts.loading, loading: searchProductTypesOpts.loading,
onFetchMore: loadMoreProductTypes onFetchMore: loadMoreProductTypes
}} }}
onWarehouseEdit={() => openModal("edit-stocks")}
/>
<ProductWarehousesDialog
confirmButtonState="default"
disabled={false}
errors={[]}
onClose={closeModal}
open={params.action === "edit-stocks"}
warehouses={searchWarehousesOpts.data?.search.edges.map(
edge => edge.node
)}
warehousesWithStocks={warehouses.map(warehouse => warehouse.id)}
onConfirm={data => {
setWarehouses(
[
...warehouses,
...data.added.map(
addedId =>
searchWarehousesOpts.data.search.edges.find(
edge => edge.node.id === addedId
).node
)
].filter(warehouse => !data.removed.includes(warehouse.id))
);
closeModal();
}}
/> />
</> </>
); );
@ -155,4 +214,4 @@ export const ProductUpdate: React.FC<ProductUpdateProps> = () => {
</TypedProductCreateMutation> </TypedProductCreateMutation>
); );
}; };
export default ProductUpdate; export default ProductCreateView;

View file

@ -306,7 +306,7 @@ export const ProductList: React.FC<ProductListProps> = ({ params }) => {
.hasNextPage, .hasNextPage,
false false
)} )}
onAdd={() => navigate(productAddUrl)} onAdd={() => navigate(productAddUrl())}
disabled={loading} disabled={loading}
products={maybe(() => products={maybe(() =>
data.products.edges.map(edge => edge.node) data.products.edges.map(edge => edge.node)

View file

@ -384,11 +384,15 @@ export const ProductUpdate: React.FC<ProductUpdateProps> = ({ id, params }) => {
?.productVariantStocksDelete.errors || []) ?.productVariantStocksDelete.errors || [])
]} ]}
onClose={closeModal} onClose={closeModal}
stocks={product?.variants[0].stocks || []}
open={params.action === "edit-stocks"} open={params.action === "edit-stocks"}
warehouses={searchWarehousesOpts.data?.search.edges.map( warehouses={searchWarehousesOpts.data?.search.edges.map(
edge => edge.node edge => edge.node
)} )}
warehousesWithStocks={
product?.variants[0].stocks.map(
stock => stock.warehouse.id
) || []
}
onConfirm={data => onConfirm={data =>
addOrRemoveStocks({ addOrRemoveStocks({
variables: { variables: {