Add variant stock management

This commit is contained in:
dominik-zeglen 2020-03-27 11:40:34 +01:00
parent 4b661c9bff
commit 0eb7750321
7 changed files with 274 additions and 38 deletions

View file

@ -180,6 +180,7 @@ const ProductStocks: React.FC<ProductStocksProps> = ({
<TableCell className={classes.colQuantity}> <TableCell className={classes.colQuantity}>
<TextField <TextField
className={classes.inputComponent} className={classes.inputComponent}
disabled={disabled}
fullWidth fullWidth
inputProps={{ inputProps={{
className: classes.input, className: classes.input,

View file

@ -13,7 +13,10 @@ import useFormset, {
FormsetData FormsetData
} from "@saleor/hooks/useFormset"; } from "@saleor/hooks/useFormset";
import { VariantUpdate_productVariantUpdate_errors } from "@saleor/products/types/VariantUpdate"; import { VariantUpdate_productVariantUpdate_errors } from "@saleor/products/types/VariantUpdate";
import { getAttributeInputFromVariant } from "@saleor/products/utils/data"; import {
getAttributeInputFromVariant,
getStockInputFromVariant
} from "@saleor/products/utils/data";
import { maybe } from "../../../misc"; import { maybe } from "../../../misc";
import { ProductVariant } from "../../types/ProductVariant"; import { ProductVariant } from "../../types/ProductVariant";
import ProductVariantAttributes, { import ProductVariantAttributes, {
@ -23,18 +26,19 @@ import ProductVariantImages from "../ProductVariantImages";
import ProductVariantImageSelectDialog from "../ProductVariantImageSelectDialog"; import ProductVariantImageSelectDialog from "../ProductVariantImageSelectDialog";
import ProductVariantNavigation from "../ProductVariantNavigation"; import ProductVariantNavigation from "../ProductVariantNavigation";
import ProductVariantPrice from "../ProductVariantPrice"; import ProductVariantPrice from "../ProductVariantPrice";
import ProductVariantStock from "../ProductVariantStock"; import ProductStocks, { ProductStockInput } from "../ProductStocks";
export interface ProductVariantPageFormData { export interface ProductVariantPageFormData {
costPrice: string; costPrice: string;
priceOverride: string; priceOverride: string;
quantity: string;
sku: string; sku: string;
trackInventory: boolean;
} }
export interface ProductVariantPageSubmitData export interface ProductVariantPageSubmitData
extends ProductVariantPageFormData { extends ProductVariantPageFormData {
attributes: FormsetData<VariantAttributeInputData, string>; attributes: FormsetData<VariantAttributeInputData, string>;
stocks: ProductStockInput[];
} }
interface ProductVariantPageProps { interface ProductVariantPageProps {
@ -44,6 +48,7 @@ interface ProductVariantPageProps {
loading?: boolean; loading?: boolean;
placeholderImage?: string; placeholderImage?: string;
header: string; header: string;
onWarehousesEdit: () => void;
onAdd(); onAdd();
onBack(); onBack();
onDelete(); onDelete();
@ -64,15 +69,20 @@ const ProductVariantPage: React.FC<ProductVariantPageProps> = ({
onDelete, onDelete,
onImageSelect, onImageSelect,
onSubmit, onSubmit,
onWarehousesEdit,
onVariantClick onVariantClick
}) => { }) => {
const attributeInput = React.useMemo( const attributeInput = React.useMemo(
() => getAttributeInputFromVariant(variant), () => getAttributeInputFromVariant(variant),
[variant] [variant]
); );
const stockInput = React.useMemo(() => getStockInputFromVariant(variant), [
variant
]);
const { change: changeAttributeData, data: attributes } = useFormset( const { change: changeAttributeData, data: attributes } = useFormset(
attributeInput attributeInput
); );
const { change: changeStockData, data: stocks } = useFormset(stockInput);
const [isModalOpened, setModalStatus] = React.useState(false); const [isModalOpened, setModalStatus] = React.useState(false);
const toggleModal = () => setModalStatus(!isModalOpened); const toggleModal = () => setModalStatus(!isModalOpened);
@ -92,14 +102,15 @@ const ProductVariantPage: React.FC<ProductVariantPageProps> = ({
const initialForm: ProductVariantPageFormData = { const initialForm: ProductVariantPageFormData = {
costPrice: maybe(() => variant.costPrice.amount.toString(), ""), costPrice: maybe(() => variant.costPrice.amount.toString(), ""),
priceOverride: maybe(() => variant.priceOverride.amount.toString(), ""), priceOverride: maybe(() => variant.priceOverride.amount.toString(), ""),
quantity: maybe(() => variant.quantity.toString(), "0"), sku: maybe(() => variant.sku, ""),
sku: maybe(() => variant.sku, "") trackInventory: variant?.trackInventory
}; };
const handleSubmit = (data: ProductVariantPageFormData) => const handleSubmit = (data: ProductVariantPageFormData) =>
onSubmit({ onSubmit({
...data, ...data,
attributes attributes,
stocks
}); });
return ( return (
@ -164,15 +175,17 @@ const ProductVariantPage: React.FC<ProductVariantPageProps> = ({
onChange={change} onChange={change}
/> />
<CardSpacer /> <CardSpacer />
<ProductVariantStock <ProductStocks
data={data}
disabled={loading}
errors={errors} errors={errors}
sku={data.sku} stocks={stocks}
quantity={data.quantity} onChange={(id, value) => {
stockAllocated={ triggerChange();
variant ? variant.quantityAllocated : undefined changeStockData(id, value);
} }}
loading={loading} onFormDataChange={change}
onChange={change} onWarehousesEdit={onWarehousesEdit}
/> />
</div> </div>
</Grid> </Grid>

View file

@ -355,6 +355,7 @@ export const TypedVariantDeleteMutation = TypedMutation<
>(variantDeleteMutation); >(variantDeleteMutation);
export const variantUpdateMutation = gql` export const variantUpdateMutation = gql`
${bulkStockErrorFragment}
${fragmentVariant} ${fragmentVariant}
${productErrorFragment} ${productErrorFragment}
mutation VariantUpdate( mutation VariantUpdate(
@ -365,6 +366,7 @@ export const variantUpdateMutation = gql`
$sku: String $sku: String
$quantity: Int $quantity: Int
$trackInventory: Boolean! $trackInventory: Boolean!
$stocks: [StockInput!]!
) { ) {
productVariantUpdate( productVariantUpdate(
id: $id id: $id
@ -384,6 +386,14 @@ export const variantUpdateMutation = gql`
...ProductVariant ...ProductVariant
} }
} }
productVariantStocksUpdate(stocks: $stocks, variantId: $id) {
errors: bulkStockErrors {
...BulkStockErrorFragment
}
productVariant {
...ProductVariant
}
}
} }
`; `;
export const TypedVariantUpdateMutation = TypedMutation< export const TypedVariantUpdateMutation = TypedMutation<

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, ProductErrorCode } from "./../../types/globalTypes"; import { AttributeValueInput, StockInput, ProductErrorCode } from "./../../types/globalTypes";
// ==================================================== // ====================================================
// GraphQL mutation operation: VariantUpdate // GraphQL mutation operation: VariantUpdate
@ -130,8 +130,132 @@ export interface VariantUpdate_productVariantUpdate {
productVariant: VariantUpdate_productVariantUpdate_productVariant | null; productVariant: VariantUpdate_productVariantUpdate_productVariant | null;
} }
export interface VariantUpdate_productVariantStocksUpdate_errors {
__typename: "BulkStockError";
code: ProductErrorCode;
field: string | null;
index: number | null;
}
export interface VariantUpdate_productVariantStocksUpdate_productVariant_attributes_attribute_values {
__typename: "AttributeValue";
id: string;
name: string | null;
slug: string | null;
}
export interface VariantUpdate_productVariantStocksUpdate_productVariant_attributes_attribute {
__typename: "Attribute";
id: string;
name: string | null;
slug: string | null;
valueRequired: boolean;
values: (VariantUpdate_productVariantStocksUpdate_productVariant_attributes_attribute_values | null)[] | null;
}
export interface VariantUpdate_productVariantStocksUpdate_productVariant_attributes_values {
__typename: "AttributeValue";
id: string;
name: string | null;
slug: string | null;
}
export interface VariantUpdate_productVariantStocksUpdate_productVariant_attributes {
__typename: "SelectedAttribute";
attribute: VariantUpdate_productVariantStocksUpdate_productVariant_attributes_attribute;
values: (VariantUpdate_productVariantStocksUpdate_productVariant_attributes_values | null)[];
}
export interface VariantUpdate_productVariantStocksUpdate_productVariant_costPrice {
__typename: "Money";
amount: number;
currency: string;
}
export interface VariantUpdate_productVariantStocksUpdate_productVariant_images {
__typename: "ProductImage";
id: string;
url: string;
}
export interface VariantUpdate_productVariantStocksUpdate_productVariant_priceOverride {
__typename: "Money";
amount: number;
currency: string;
}
export interface VariantUpdate_productVariantStocksUpdate_productVariant_product_images {
__typename: "ProductImage";
id: string;
alt: string;
sortOrder: number | null;
url: string;
}
export interface VariantUpdate_productVariantStocksUpdate_productVariant_product_thumbnail {
__typename: "Image";
url: string;
}
export interface VariantUpdate_productVariantStocksUpdate_productVariant_product_variants_images {
__typename: "ProductImage";
id: string;
url: string;
}
export interface VariantUpdate_productVariantStocksUpdate_productVariant_product_variants {
__typename: "ProductVariant";
id: string;
name: string;
sku: string;
images: (VariantUpdate_productVariantStocksUpdate_productVariant_product_variants_images | null)[] | null;
}
export interface VariantUpdate_productVariantStocksUpdate_productVariant_product {
__typename: "Product";
id: string;
images: (VariantUpdate_productVariantStocksUpdate_productVariant_product_images | null)[] | null;
name: string;
thumbnail: VariantUpdate_productVariantStocksUpdate_productVariant_product_thumbnail | null;
variants: (VariantUpdate_productVariantStocksUpdate_productVariant_product_variants | null)[] | null;
}
export interface VariantUpdate_productVariantStocksUpdate_productVariant_stocks_warehouse {
__typename: "Warehouse";
id: string;
name: string;
}
export interface VariantUpdate_productVariantStocksUpdate_productVariant_stocks {
__typename: "Stock";
id: string;
quantity: number;
warehouse: VariantUpdate_productVariantStocksUpdate_productVariant_stocks_warehouse;
}
export interface VariantUpdate_productVariantStocksUpdate_productVariant {
__typename: "ProductVariant";
id: string;
attributes: VariantUpdate_productVariantStocksUpdate_productVariant_attributes[];
costPrice: VariantUpdate_productVariantStocksUpdate_productVariant_costPrice | null;
images: (VariantUpdate_productVariantStocksUpdate_productVariant_images | null)[] | null;
name: string;
priceOverride: VariantUpdate_productVariantStocksUpdate_productVariant_priceOverride | null;
product: VariantUpdate_productVariantStocksUpdate_productVariant_product;
sku: string;
stocks: (VariantUpdate_productVariantStocksUpdate_productVariant_stocks | null)[] | null;
trackInventory: boolean;
}
export interface VariantUpdate_productVariantStocksUpdate {
__typename: "ProductVariantStocksUpdate";
errors: VariantUpdate_productVariantStocksUpdate_errors[];
productVariant: VariantUpdate_productVariantStocksUpdate_productVariant | null;
}
export interface VariantUpdate { export interface VariantUpdate {
productVariantUpdate: VariantUpdate_productVariantUpdate | null; productVariantUpdate: VariantUpdate_productVariantUpdate | null;
productVariantStocksUpdate: VariantUpdate_productVariantStocksUpdate | null;
} }
export interface VariantUpdateVariables { export interface VariantUpdateVariables {
@ -142,4 +266,5 @@ export interface VariantUpdateVariables {
sku?: string | null; sku?: string | null;
quantity?: number | null; quantity?: number | null;
trackInventory: boolean; trackInventory: boolean;
stocks: StockInput[];
} }

View file

@ -80,8 +80,10 @@ export const productUrl = (id: string, params?: ProductUrlQueryParams) =>
export const productVariantEditPath = (productId: string, variantId: string) => export const productVariantEditPath = (productId: string, variantId: string) =>
urlJoin(productSection, productId, "variant", variantId); urlJoin(productSection, productId, "variant", variantId);
export type ProductVariantEditUrlDialog = "remove"; export type ProductVariantEditUrlDialog = "edit-stocks" | "remove";
export type ProductVariantEditUrlQueryParams = Dialog<"remove">; export type ProductVariantEditUrlQueryParams = Dialog<
ProductVariantEditUrlDialog
>;
export const productVariantEditUrl = ( export const productVariantEditUrl = (
productId: string, productId: string,
variantId: string, variantId: string,

View file

@ -103,6 +103,19 @@ export function getAttributeInputFromVariant(
); );
} }
export function getStockInputFromVariant(
variant: ProductVariant
): ProductStockInput[] {
return (
variant?.stocks.map(stock => ({
data: null,
id: stock.warehouse.id,
label: stock.warehouse.name,
value: stock.quantity.toString()
})) || []
);
}
export function getVariantAttributeInputFromProduct( export function getVariantAttributeInputFromProduct(
product: ProductVariantCreateData_product product: ProductVariantCreateData_product
): VariantAttributeInput[] { ): VariantAttributeInput[] {

View file

@ -7,7 +7,10 @@ import useNavigator from "@saleor/hooks/useNavigator";
import useNotifier from "@saleor/hooks/useNotifier"; import useNotifier from "@saleor/hooks/useNotifier";
import { commonMessages } from "@saleor/intl"; import { commonMessages } from "@saleor/intl";
import NotFoundPage from "@saleor/components/NotFoundPage"; import NotFoundPage from "@saleor/components/NotFoundPage";
import { decimal, maybe } from "../../misc"; import createDialogActionHandlers from "@saleor/utils/handlers/dialogActionHandlers";
import { DEFAULT_INITIAL_SEARCH_DATA } from "@saleor/config";
import useWarehouseSearch from "@saleor/searches/useWarehouseSearch";
import { decimal } from "../../misc";
import ProductVariantDeleteDialog from "../components/ProductVariantDeleteDialog"; import ProductVariantDeleteDialog from "../components/ProductVariantDeleteDialog";
import ProductVariantPage, { import ProductVariantPage, {
ProductVariantPageSubmitData ProductVariantPageSubmitData
@ -22,8 +25,11 @@ import {
productUrl, productUrl,
productVariantAddUrl, productVariantAddUrl,
productVariantEditUrl, productVariantEditUrl,
ProductVariantEditUrlQueryParams ProductVariantEditUrlQueryParams,
ProductVariantEditUrlDialog
} from "../urls"; } from "../urls";
import ProductWarehousesDialog from "../components/ProductWarehousesDialog";
import { useAddOrRemoveStocks } from "../mutations";
interface ProductUpdateProps { interface ProductUpdateProps {
variantId: string; variantId: string;
@ -46,6 +52,40 @@ export const ProductVariant: React.FC<ProductUpdateProps> = ({
setErrors([]); setErrors([]);
}, [variantId]); }, [variantId]);
const {
loadMore: loadMoreWarehouses,
search: searchWarehouses,
result: searchWarehousesOpts
} = useWarehouseSearch({
variables: {
...DEFAULT_INITIAL_SEARCH_DATA,
first: 20
}
});
const [addOrRemoveStocks, addOrRemoveStocksOpts] = useAddOrRemoveStocks({
onCompleted: data => {
if (
data.productVariantStocksCreate.errors.length === 0 &&
data.productVariantStocksDelete.errors.length === 0
) {
notify({
text: intl.formatMessage(commonMessages.savedChanges)
});
closeModal();
}
}
});
const [openModal, closeModal] = createDialogActionHandlers<
ProductVariantEditUrlDialog,
ProductVariantEditUrlQueryParams
>(
navigate,
params => productVariantEditUrl(productId, variantId, params),
params
);
const handleBack = () => navigate(productUrl(productId)); const handleBack = () => navigate(productUrl(productId));
return ( return (
@ -107,14 +147,14 @@ export const ProductVariant: React.FC<ProductUpdateProps> = ({
return ( return (
<> <>
<WindowTitle title={maybe(() => data.productVariant.name)} /> <WindowTitle title={data?.productVariant?.name} />
<ProductVariantPage <ProductVariantPage
errors={errors} errors={errors}
saveButtonBarState={updateVariant.opts.status} saveButtonBarState={updateVariant.opts.status}
loading={disableFormSave} loading={disableFormSave}
placeholderImage={placeholderImg} placeholderImage={placeholderImg}
variant={variant} variant={variant}
header={variant ? variant.name || variant.sku : undefined} header={variant?.name || variant?.sku}
onAdd={() => navigate(productVariantAddUrl(productId))} onAdd={() => navigate(productVariantAddUrl(productId))}
onBack={handleBack} onBack={handleBack}
onDelete={() => onDelete={() =>
@ -125,8 +165,7 @@ export const ProductVariant: React.FC<ProductUpdateProps> = ({
) )
} }
onImageSelect={handleImageSelect} onImageSelect={handleImageSelect}
onSubmit={(data: ProductVariantPageSubmitData) => { onSubmit={(data: ProductVariantPageSubmitData) =>
if (variant) {
updateVariant.mutate({ updateVariant.mutate({
attributes: data.attributes.map(attribute => ({ attributes: data.attributes.map(attribute => ({
id: attribute.id, id: attribute.id,
@ -135,15 +174,18 @@ export const ProductVariant: React.FC<ProductUpdateProps> = ({
costPrice: decimal(data.costPrice), costPrice: decimal(data.costPrice),
id: variantId, id: variantId,
priceOverride: decimal(data.priceOverride), priceOverride: decimal(data.priceOverride),
quantity: parseInt(data.quantity, 0),
sku: data.sku, sku: data.sku,
trackInventory: true // FIXME: missing in UI stocks: data.stocks.map(stock => ({
}); quantity: parseInt(stock.value, 10),
warehouse: stock.id
})),
trackInventory: data.trackInventory
})
} }
}}
onVariantClick={variantId => { onVariantClick={variantId => {
navigate(productVariantEditUrl(productId, variantId)); navigate(productVariantEditUrl(productId, variantId));
}} }}
onWarehousesEdit={() => openModal("edit-stocks")}
/> />
<ProductVariantDeleteDialog <ProductVariantDeleteDialog
confirmButtonState={deleteVariant.opts.status} confirmButtonState={deleteVariant.opts.status}
@ -156,7 +198,37 @@ export const ProductVariant: React.FC<ProductUpdateProps> = ({
}) })
} }
open={params.action === "remove"} open={params.action === "remove"}
name={maybe(() => data.productVariant.name)} name={data?.productVariant?.name}
/>
<ProductWarehousesDialog
confirmButtonState={addOrRemoveStocksOpts.status}
disabled={addOrRemoveStocksOpts.loading}
errors={[
...(addOrRemoveStocksOpts.data?.productVariantStocksCreate
.errors || []),
...(addOrRemoveStocksOpts.data?.productVariantStocksDelete
.errors || [])
]}
onClose={closeModal}
open={params.action === "edit-stocks"}
warehouses={searchWarehousesOpts.data?.search.edges.map(
edge => edge.node
)}
warehousesWithStocks={
variant?.stocks.map(stock => stock.warehouse.id) || []
}
onConfirm={data =>
addOrRemoveStocks({
variables: {
add: data.added.map(id => ({
quantity: 0,
warehouse: id
})),
remove: data.removed,
variantId
}
})
}
/> />
</> </>
); );