Add metadata (#670)

* Add metadata editor component

* Add tests

* Fix plurals

* Use pascal case in selectors

* Update product metadata

* Add metadata handler decorator

* Update snapshots

* wip

* Remove operation provider component

* Add metadata to collections

* Add metadata editor to variant

* Add metadata editor to categories

* Add metadata to product types

* Simplify code

* Add metadata to attributes

* Drop maybe

* Rename Metadata to MetadataFragment

* Update changelog and snapshots
This commit is contained in:
Dominik Żegleń 2020-08-28 14:45:11 +02:00 committed by GitHub
parent 265c8bc0ca
commit 7770ae34df
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
96 changed files with 12334 additions and 2002 deletions

View file

@ -29,6 +29,7 @@ All notable, unreleased changes to this project will be documented in this file.
- Fix navigator button rendering on safari browser - #656 by @dominik-zeglen
- Use hooks instead of containers with render props in product mutations - #667 by @dominik-zeglen
- Add apps and permission groups to navigator - #678 by @dominik-zeglen
- Add metadata - #670 by @dominik-zeglen
## 2.10.1

View file

@ -0,0 +1,7 @@
<svg width="91" height="88" viewBox="0 0 91 88" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 68.4054L43.7676 47.1814V1L1 22.224V68.4054Z" fill="#F1F6F6" stroke="#28234A" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M60.038 41.9077L87.9299 28.1653V10.2969L60.038 24.0393V41.9077Z" fill="#F1F6F6" stroke="#28234A" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M62.8279 86.998L90.2549 73.1248V45.625L62.8279 59.4981V86.998Z" fill="#F1F6F6" stroke="#28234A" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M46.5574 55.3909L60.0385 48.8828" stroke="#06847B" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M53.5302 43.7656V60.9656" stroke="#06847B" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 909 B

View file

@ -1549,6 +1549,46 @@
"context": "button",
"string": "Add"
},
"src_dot_components_dot_Metadata_dot_1148029984": {
"context": "metadata field value, header",
"string": "Value"
},
"src_dot_components_dot_Metadata_dot_1509432322": {
"context": "header",
"string": "Metadata"
},
"src_dot_components_dot_Metadata_dot_1535223586": {
"context": "header",
"string": "Private Metadata"
},
"src_dot_components_dot_Metadata_dot_2024779015": {
"context": "empty metadata text",
"string": "There is no private metadata created for this element."
},
"src_dot_components_dot_Metadata_dot_2087058956": {
"context": "empty metadata text",
"string": "There is no metadata created for this element."
},
"src_dot_components_dot_Metadata_dot_2537046678": {
"context": "metadata field name, header",
"string": "Field"
},
"src_dot_components_dot_Metadata_dot_3181473584": {
"context": "add metadata field,button",
"string": "Add Field"
},
"src_dot_components_dot_Metadata_dot_4190792473": {
"context": "table action",
"string": "Actions"
},
"src_dot_components_dot_Metadata_dot_549104951": {
"context": "number of metadata fields in model",
"string": "{number,plural,one{{number} Field} other{{number} Fields}}"
},
"src_dot_components_dot_Metadata_dot_553451245": {
"context": "empty metadata text",
"string": "Use the button below to add new metadata field"
},
"src_dot_components_dot_MoneyRange_dot_12301532": {
"context": "money",
"string": "to {money}"

View file

@ -4,6 +4,8 @@ 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 { MetadataFormData } from "@saleor/components/Metadata/types";
import PageHeader from "@saleor/components/PageHeader";
import SaveButtonBar from "@saleor/components/SaveButtonBar";
import {
@ -15,6 +17,8 @@ import { sectionNames } from "@saleor/intl";
import { maybe } from "@saleor/misc";
import { ReorderAction } from "@saleor/types";
import { AttributeInputTypeEnum } from "@saleor/types/globalTypes";
import { mapMetadataItemToInput } from "@saleor/utils/maps";
import useMetadataChangeTrigger from "@saleor/utils/metadata/useMetadataChangeTrigger";
import React from "react";
import { useIntl } from "react-intl";
import slugify from "slugify";
@ -38,7 +42,7 @@ export interface AttributePageProps {
onValueUpdate: (id: string) => void;
}
export interface AttributePageFormData {
export interface AttributePageFormData extends MetadataFormData {
availableInGrid: boolean;
filterableInDashboard: boolean;
inputType: AttributeInputTypeEnum;
@ -65,6 +69,12 @@ const AttributePage: React.FC<AttributePageProps> = ({
onValueUpdate
}) => {
const intl = useIntl();
const {
isMetadataModified,
isPrivateMetadataModified,
makeChangeHandler: makeMetadataChangeHandler
} = useMetadataChangeTrigger();
const initialForm: AttributePageFormData =
attribute === null
? {
@ -72,7 +82,9 @@ const AttributePage: React.FC<AttributePageProps> = ({
filterableInDashboard: true,
filterableInStorefront: true,
inputType: AttributeInputTypeEnum.DROPDOWN,
metadata: undefined,
name: "",
privateMetadata: undefined,
slug: "",
storefrontSearchPosition: "",
valueRequired: true,
@ -92,7 +104,11 @@ const AttributePage: React.FC<AttributePageProps> = ({
() => attribute.inputType,
AttributeInputTypeEnum.DROPDOWN
),
metadata: attribute?.metadata?.map(mapMetadataItemToInput),
name: maybe(() => attribute.name, ""),
privateMetadata: attribute?.privateMetadata?.map(
mapMetadataItemToInput
),
slug: maybe(() => attribute.slug, ""),
storefrontSearchPosition: maybe(
() => attribute.storefrontSearchPosition.toString(),
@ -102,66 +118,84 @@ const AttributePage: React.FC<AttributePageProps> = ({
visibleInStorefront: maybe(() => attribute.visibleInStorefront, true)
};
const handleSubmit = (data: AttributePageFormData) =>
const handleSubmit = (data: AttributePageFormData) => {
const metadata = isMetadataModified ? data.metadata : undefined;
const privateMetadata = isPrivateMetadataModified
? data.privateMetadata
: undefined;
onSubmit({
...data,
metadata,
privateMetadata,
slug: data.slug || slugify(data.name).toLowerCase()
});
};
return (
<Form initial={initialForm} onSubmit={handleSubmit}>
{({ change, data, submit }) => (
<Container>
<AppHeader onBack={onBack}>
{intl.formatMessage(sectionNames.attributes)}
</AppHeader>
<PageHeader
title={
attribute === null
? intl.formatMessage({
defaultMessage: "Create New Attribute",
description: "page title"
})
: maybe(() => attribute.name)
}
/>
<Grid>
<div>
<AttributeDetails
canChangeType={attribute === null}
data={data}
disabled={disabled}
errors={errors}
onChange={change}
/>
<CardSpacer />
<AttributeValues
disabled={disabled}
values={values}
onValueAdd={onValueAdd}
onValueDelete={onValueDelete}
onValueReorder={onValueReorder}
onValueUpdate={onValueUpdate}
/>
</div>
<div>
<AttributeProperties
data={data}
errors={errors}
disabled={disabled}
onChange={change}
/>
</div>
</Grid>
<SaveButtonBar
disabled={disabled}
state={saveButtonBarState}
onCancel={onBack}
onSave={submit}
onDelete={attribute === null ? undefined : onDelete}
/>
</Container>
)}
{({ change, data, submit }) => {
const changeMetadata = makeMetadataChangeHandler(change);
return (
<Container>
<AppHeader onBack={onBack}>
{intl.formatMessage(sectionNames.attributes)}
</AppHeader>
<PageHeader
title={
attribute === null
? intl.formatMessage({
defaultMessage: "Create New Attribute",
description: "page title"
})
: maybe(() => attribute.name)
}
/>
<Grid>
<div>
<AttributeDetails
canChangeType={attribute === null}
data={data}
disabled={disabled}
errors={errors}
onChange={change}
/>
<CardSpacer />
<AttributeValues
disabled={disabled}
values={values}
onValueAdd={onValueAdd}
onValueDelete={onValueDelete}
onValueReorder={onValueReorder}
onValueUpdate={onValueUpdate}
/>
{!!attribute && (
<>
<CardSpacer />
<Metadata data={data} onChange={changeMetadata} />
</>
)}
</div>
<div>
<AttributeProperties
data={data}
errors={errors}
disabled={disabled}
onChange={change}
/>
</div>
</Grid>
<SaveButtonBar
disabled={disabled}
state={saveButtonBarState}
onCancel={onBack}
onSave={submit}
onDelete={attribute === null ? undefined : onDelete}
/>
</Container>
);
}}
</Form>
);
};

View file

@ -14,7 +14,15 @@ export const attribute: AttributeDetailsFragment = {
filterableInStorefront: true,
id: "UHJvZHVjdEF0dHJpYnV0ZTo5",
inputType: AttributeInputTypeEnum.DROPDOWN,
metadata: [
{
__typename: "MetadataItem",
key: "integration.id",
value: "100023123"
}
],
name: "Author",
privateMetadata: [],
slug: "author",
storefrontSearchPosition: 2,
valueRequired: true,

View file

@ -1,6 +1,6 @@
import { attributeDetailsFragment } from "@saleor/fragments/attributes";
import { productErrorFragment } from "@saleor/fragments/errors";
import { TypedMutation } from "@saleor/mutations";
import makeMutation from "@saleor/hooks/makeMutation";
import gql from "graphql-tag";
import {
@ -46,7 +46,7 @@ const attributeBulkDelete = gql`
}
}
`;
export const AttributeBulkDeleteMutation = TypedMutation<
export const useAttributeBulkDeleteMutation = makeMutation<
AttributeBulkDelete,
AttributeBulkDeleteVariables
>(attributeBulkDelete);
@ -61,7 +61,7 @@ const attributeDelete = gql`
}
}
`;
export const AttributeDeleteMutation = TypedMutation<
export const useAttributeDeleteMutation = makeMutation<
AttributeDelete,
AttributeDeleteVariables
>(attributeDelete);
@ -80,7 +80,7 @@ export const attributeUpdateMutation = gql`
}
}
`;
export const AttributeUpdateMutation = TypedMutation<
export const useAttributeUpdateMutation = makeMutation<
AttributeUpdate,
AttributeUpdateVariables
>(attributeUpdateMutation);
@ -99,7 +99,7 @@ const attributeValueDelete = gql`
}
}
`;
export const AttributeValueDeleteMutation = TypedMutation<
export const useAttributeValueDeleteMutation = makeMutation<
AttributeValueDelete,
AttributeValueDeleteVariables
>(attributeValueDelete);
@ -118,7 +118,7 @@ export const attributeValueUpdateMutation = gql`
}
}
`;
export const AttributeValueUpdateMutation = TypedMutation<
export const useAttributeValueUpdateMutation = makeMutation<
AttributeValueUpdate,
AttributeValueUpdateVariables
>(attributeValueUpdateMutation);
@ -137,7 +137,7 @@ export const attributeValueCreateMutation = gql`
}
}
`;
export const AttributeValueCreateMutation = TypedMutation<
export const useAttributeValueCreateMutation = makeMutation<
AttributeValueCreate,
AttributeValueCreateVariables
>(attributeValueCreateMutation);
@ -156,7 +156,7 @@ export const attributeCreateMutation = gql`
}
}
`;
export const AttributeCreateMutation = TypedMutation<
export const useAttributeCreateMutation = makeMutation<
AttributeCreate,
AttributeCreateVariables
>(attributeCreateMutation);
@ -177,7 +177,7 @@ const attributeValueReorderMutation = gql`
}
}
`;
export const AttributeValueReorderMutation = TypedMutation<
export const useAttributeValueReorderMutation = makeMutation<
AttributeValueReorder,
AttributeValueReorderVariables
>(attributeValueReorderMutation);

View file

@ -6,7 +6,6 @@ import { pageInfoFragment } from "@saleor/fragments/pageInfo";
import makeQuery from "@saleor/hooks/makeQuery";
import gql from "graphql-tag";
import { TypedQuery } from "../queries";
import {
AttributeDetails,
AttributeDetailsVariables
@ -21,7 +20,7 @@ const attributeDetails = gql`
}
}
`;
export const AttributeDetailsQuery = TypedQuery<
export const useAttributeDetailsQuery = makeQuery<
AttributeDetails,
AttributeDetailsVariables
>(attributeDetails);

View file

@ -8,6 +8,18 @@ import { AttributeCreateInput, AttributeInputTypeEnum, AttributeValueType, Produ
// GraphQL mutation operation: AttributeCreate
// ====================================================
export interface AttributeCreate_attributeCreate_attribute_metadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface AttributeCreate_attributeCreate_attribute_privateMetadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface AttributeCreate_attributeCreate_attribute_values {
__typename: "AttributeValue";
id: string;
@ -24,6 +36,8 @@ export interface AttributeCreate_attributeCreate_attribute {
visibleInStorefront: boolean;
filterableInDashboard: boolean;
filterableInStorefront: boolean;
metadata: (AttributeCreate_attributeCreate_attribute_metadata | null)[];
privateMetadata: (AttributeCreate_attributeCreate_attribute_privateMetadata | null)[];
availableInGrid: boolean;
inputType: AttributeInputTypeEnum | null;
storefrontSearchPosition: number;

View file

@ -8,6 +8,18 @@ import { AttributeInputTypeEnum, AttributeValueType } from "./../../types/global
// GraphQL query operation: AttributeDetails
// ====================================================
export interface AttributeDetails_attribute_metadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface AttributeDetails_attribute_privateMetadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface AttributeDetails_attribute_values {
__typename: "AttributeValue";
id: string;
@ -24,6 +36,8 @@ export interface AttributeDetails_attribute {
visibleInStorefront: boolean;
filterableInDashboard: boolean;
filterableInStorefront: boolean;
metadata: (AttributeDetails_attribute_metadata | null)[];
privateMetadata: (AttributeDetails_attribute_privateMetadata | null)[];
availableInGrid: boolean;
inputType: AttributeInputTypeEnum | null;
storefrontSearchPosition: number;

View file

@ -8,6 +8,18 @@ import { AttributeUpdateInput, AttributeInputTypeEnum, AttributeValueType, Produ
// GraphQL mutation operation: AttributeUpdate
// ====================================================
export interface AttributeUpdate_attributeUpdate_attribute_metadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface AttributeUpdate_attributeUpdate_attribute_privateMetadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface AttributeUpdate_attributeUpdate_attribute_values {
__typename: "AttributeValue";
id: string;
@ -24,6 +36,8 @@ export interface AttributeUpdate_attributeUpdate_attribute {
visibleInStorefront: boolean;
filterableInDashboard: boolean;
filterableInStorefront: boolean;
metadata: (AttributeUpdate_attributeUpdate_attribute_metadata | null)[];
privateMetadata: (AttributeUpdate_attributeUpdate_attribute_privateMetadata | null)[];
availableInGrid: boolean;
inputType: AttributeInputTypeEnum | null;
storefrontSearchPosition: number;

View file

@ -8,6 +8,18 @@ import { AttributeValueCreateInput, AttributeInputTypeEnum, AttributeValueType,
// GraphQL mutation operation: AttributeValueCreate
// ====================================================
export interface AttributeValueCreate_attributeValueCreate_attribute_metadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface AttributeValueCreate_attributeValueCreate_attribute_privateMetadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface AttributeValueCreate_attributeValueCreate_attribute_values {
__typename: "AttributeValue";
id: string;
@ -24,6 +36,8 @@ export interface AttributeValueCreate_attributeValueCreate_attribute {
visibleInStorefront: boolean;
filterableInDashboard: boolean;
filterableInStorefront: boolean;
metadata: (AttributeValueCreate_attributeValueCreate_attribute_metadata | null)[];
privateMetadata: (AttributeValueCreate_attributeValueCreate_attribute_privateMetadata | null)[];
availableInGrid: boolean;
inputType: AttributeInputTypeEnum | null;
storefrontSearchPosition: number;

View file

@ -8,6 +8,18 @@ import { AttributeInputTypeEnum, AttributeValueType, ProductErrorCode } from "./
// GraphQL mutation operation: AttributeValueDelete
// ====================================================
export interface AttributeValueDelete_attributeValueDelete_attribute_metadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface AttributeValueDelete_attributeValueDelete_attribute_privateMetadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface AttributeValueDelete_attributeValueDelete_attribute_values {
__typename: "AttributeValue";
id: string;
@ -24,6 +36,8 @@ export interface AttributeValueDelete_attributeValueDelete_attribute {
visibleInStorefront: boolean;
filterableInDashboard: boolean;
filterableInStorefront: boolean;
metadata: (AttributeValueDelete_attributeValueDelete_attribute_metadata | null)[];
privateMetadata: (AttributeValueDelete_attributeValueDelete_attribute_privateMetadata | null)[];
availableInGrid: boolean;
inputType: AttributeInputTypeEnum | null;
storefrontSearchPosition: number;

View file

@ -8,6 +8,18 @@ import { AttributeValueCreateInput, AttributeInputTypeEnum, AttributeValueType,
// GraphQL mutation operation: AttributeValueUpdate
// ====================================================
export interface AttributeValueUpdate_attributeValueUpdate_attribute_metadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface AttributeValueUpdate_attributeValueUpdate_attribute_privateMetadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface AttributeValueUpdate_attributeValueUpdate_attribute_values {
__typename: "AttributeValue";
id: string;
@ -24,6 +36,8 @@ export interface AttributeValueUpdate_attributeValueUpdate_attribute {
visibleInStorefront: boolean;
filterableInDashboard: boolean;
filterableInStorefront: boolean;
metadata: (AttributeValueUpdate_attributeValueUpdate_attribute_metadata | null)[];
privateMetadata: (AttributeValueUpdate_attributeValueUpdate_attribute_privateMetadata | null)[];
availableInGrid: boolean;
inputType: AttributeInputTypeEnum | null;
storefrontSearchPosition: number;

View file

@ -1,7 +1,7 @@
import { ProductErrorFragment } from "@saleor/fragments/types/ProductErrorFragment";
import useNavigator from "@saleor/hooks/useNavigator";
import useNotifier from "@saleor/hooks/useNotifier";
import { maybe } from "@saleor/misc";
import { getStringOrPlaceholder } from "@saleor/misc";
import { ReorderEvent } from "@saleor/types";
import { ProductErrorCode } from "@saleor/types/globalTypes";
import createDialogActionHandlers from "@saleor/utils/handlers/dialogActionHandlers";
@ -21,8 +21,7 @@ import AttributeValueDeleteDialog from "../../components/AttributeValueDeleteDia
import AttributeValueEditDialog, {
AttributeValueEditDialogFormData
} from "../../components/AttributeValueEditDialog";
import { AttributeCreateMutation } from "../../mutations";
import { AttributeCreate } from "../../types/AttributeCreate";
import { useAttributeCreateMutation } from "../../mutations";
import {
attributeAddUrl,
AttributeAddUrlDialog,
@ -60,6 +59,20 @@ const AttributeDetails: React.FC<AttributeDetailsProps> = ({ params }) => {
[]
);
const [attributeCreate, attributeCreateOpts] = useAttributeCreateMutation({
onCompleted: data => {
if (data.attributeCreate.errors.length === 0) {
notify({
status: "success",
text: intl.formatMessage({
defaultMessage: "Successfully created attribute"
})
});
navigate(attributeUrl(data.attributeCreate.attribute.id));
}
}
});
const id = params.id ? parseInt(params.id, 0) : undefined;
const [openModal, closeModal] = createDialogActionHandlers<
@ -73,17 +86,6 @@ const AttributeDetails: React.FC<AttributeDetailsProps> = ({ params }) => {
setValues(remove(values[params.id], values, areValuesEqual));
closeModal();
};
const handleCreate = (data: AttributeCreate) => {
if (data.attributeCreate.errors.length === 0) {
notify({
status: "success",
text: intl.formatMessage({
defaultMessage: "Successfully created attribute"
})
});
navigate(attributeUrl(data.attributeCreate.attribute.id));
}
};
const handleValueUpdate = (input: AttributeValueEditDialogFormData) => {
if (isSelected(input, values, areValuesEqual)) {
setValueErrors([attributeValueAlreadyExistsError]);
@ -104,87 +106,83 @@ const AttributeDetails: React.FC<AttributeDetailsProps> = ({ params }) => {
setValues(move(values[oldIndex], values, areValuesEqual, newIndex));
return (
<AttributeCreateMutation onCompleted={handleCreate}>
{(attributeCreate, attributeCreateOpts) => (
<>
<AttributePage
attribute={null}
disabled={attributeCreateOpts.loading}
errors={attributeCreateOpts.data?.attributeCreate.errors || []}
onBack={() => navigate(attributeListUrl())}
onDelete={undefined}
onSubmit={input =>
attributeCreate({
variables: {
input: {
...input,
storefrontSearchPosition: parseInt(
input.storefrontSearchPosition,
0
),
values: values.map(value => ({
name: value.name
}))
}
}
})
}
onValueAdd={() => openModal("add-value")}
onValueDelete={id =>
openModal("remove-value", {
id
})
}
onValueReorder={handleValueReorder}
onValueUpdate={id =>
openModal("edit-value", {
id
})
}
saveButtonBarState={attributeCreateOpts.status}
values={values.map((value, valueIndex) => ({
__typename: "AttributeValue" as "AttributeValue",
id: valueIndex.toString(),
slug: slugify(value.name).toLowerCase(),
sortOrder: valueIndex,
type: null,
value: null,
...value
}))}
/>
<AttributeValueEditDialog
attributeValue={null}
confirmButtonState="default"
disabled={false}
errors={valueErrors}
open={params.action === "add-value"}
onClose={closeModal}
onSubmit={handleValueCreate}
/>
{values.length > 0 && (
<>
<AttributePage
attribute={null}
disabled={false}
errors={attributeCreateOpts.data?.attributeCreate.errors || []}
onBack={() => navigate(attributeListUrl())}
onDelete={undefined}
onSubmit={input =>
attributeCreate({
variables: {
input: {
...input,
storefrontSearchPosition: parseInt(
input.storefrontSearchPosition,
0
),
values: values.map(value => ({
name: value.name
}))
}
}
})
}
onValueAdd={() => openModal("add-value")}
onValueDelete={id =>
openModal("remove-value", {
id
})
}
onValueReorder={handleValueReorder}
onValueUpdate={id =>
openModal("edit-value", {
id
})
}
saveButtonBarState={attributeCreateOpts.status}
values={values.map((value, valueIndex) => ({
__typename: "AttributeValue" as "AttributeValue",
id: valueIndex.toString(),
slug: slugify(value.name).toLowerCase(),
sortOrder: valueIndex,
type: null,
value: null,
...value
}))}
<AttributeValueDeleteDialog
attributeName={undefined}
open={params.action === "remove-value"}
name={getStringOrPlaceholder(values[id].name)}
confirmButtonState="default"
onClose={closeModal}
onConfirm={handleValueDelete}
/>
<AttributeValueEditDialog
attributeValue={null}
attributeValue={values[params.id]}
confirmButtonState="default"
disabled={false}
errors={valueErrors}
open={params.action === "add-value"}
open={params.action === "edit-value"}
onClose={closeModal}
onSubmit={handleValueCreate}
onSubmit={handleValueUpdate}
/>
{values.length > 0 && (
<>
<AttributeValueDeleteDialog
attributeName={undefined}
open={params.action === "remove-value"}
name={maybe(() => values[id].name, "...")}
confirmButtonState="default"
onClose={closeModal}
onConfirm={handleValueDelete}
/>
<AttributeValueEditDialog
attributeValue={maybe(() => values[params.id])}
confirmButtonState="default"
disabled={false}
errors={valueErrors}
open={params.action === "edit-value"}
onClose={closeModal}
onSubmit={handleValueUpdate}
/>
</>
)}
</>
)}
</AttributeCreateMutation>
</>
);
};
AttributeDetails.displayName = "AttributeDetails";

View file

@ -5,29 +5,30 @@ import { maybe } from "@saleor/misc";
import { ReorderEvent } from "@saleor/types";
import { getProductErrorMessage } from "@saleor/utils/errors";
import createDialogActionHandlers from "@saleor/utils/handlers/dialogActionHandlers";
import createMetadataUpdateHandler from "@saleor/utils/handlers/metadataUpdateHandler";
import { move } from "@saleor/utils/lists";
import {
useMetadataUpdate,
usePrivateMetadataUpdate
} from "@saleor/utils/metadata/updateMetadata";
import React from "react";
import { useIntl } from "react-intl";
import AttributeDeleteDialog from "../../components/AttributeDeleteDialog";
import AttributePage from "../../components/AttributePage";
import AttributePage, {
AttributePageFormData
} from "../../components/AttributePage";
import AttributeValueDeleteDialog from "../../components/AttributeValueDeleteDialog";
import AttributeValueEditDialog from "../../components/AttributeValueEditDialog";
import {
AttributeDeleteMutation,
AttributeUpdateMutation,
AttributeValueCreateMutation,
AttributeValueDeleteMutation,
AttributeValueReorderMutation,
AttributeValueUpdateMutation
useAttributeDeleteMutation,
useAttributeUpdateMutation,
useAttributeValueCreateMutation,
useAttributeValueDeleteMutation,
useAttributeValueReorderMutation,
useAttributeValueUpdateMutation
} from "../../mutations";
import { AttributeDetailsQuery } from "../../queries";
import { AttributeDelete } from "../../types/AttributeDelete";
import { AttributeUpdate } from "../../types/AttributeUpdate";
import { AttributeValueCreate } from "../../types/AttributeValueCreate";
import { AttributeValueDelete } from "../../types/AttributeValueDelete";
import { AttributeValueReorder } from "../../types/AttributeValueReorder";
import { AttributeValueUpdate } from "../../types/AttributeValueUpdate";
import { useAttributeDetailsQuery } from "../../queries";
import {
attributeListUrl,
attributeUrl,
@ -44,285 +45,258 @@ const AttributeDetails: React.FC<AttributeDetailsProps> = ({ id, params }) => {
const navigate = useNavigator();
const notify = useNotifier();
const intl = useIntl();
const [updateMetadata] = useMetadataUpdate({});
const [updatePrivateMetadata] = usePrivateMetadataUpdate({});
const [openModal, closeModal] = createDialogActionHandlers<
AttributeUrlDialog,
AttributeUrlQueryParams
>(navigate, params => attributeUrl(id, params), params);
const handleDelete = (data: AttributeDelete) => {
if (data.attributeDelete.errors.length === 0) {
notify({
status: "success",
text: intl.formatMessage({
defaultMessage: "Attribute deleted"
})
});
navigate(attributeListUrl());
const { data, loading } = useAttributeDetailsQuery({
variables: {
id
}
};
const handleValueDelete = (data: AttributeValueDelete) => {
if (data.attributeValueDelete.errors.length === 0) {
notify({
status: "success",
text: intl.formatMessage({
defaultMessage: "Value deleted",
description: "attribute value deleted"
})
});
closeModal();
});
const [attributeDelete, attributeDeleteOpts] = useAttributeDeleteMutation({
onCompleted: data => {
if (data.attributeDelete.errors.length === 0) {
notify({
status: "success",
text: intl.formatMessage({
defaultMessage: "Attribute deleted"
})
});
navigate(attributeListUrl());
}
}
};
const handleUpdate = (data: AttributeUpdate) => {
if (data.attributeUpdate.errors.length === 0) {
notify({
status: "success",
text: intl.formatMessage(commonMessages.savedChanges)
});
});
const [
attributeValueDelete,
attributeValueDeleteOpts
] = useAttributeValueDeleteMutation({
onCompleted: data => {
if (data.attributeValueDelete.errors.length === 0) {
notify({
status: "success",
text: intl.formatMessage({
defaultMessage: "Value deleted",
description: "attribute value deleted"
})
});
closeModal();
}
}
};
const handleValueUpdate = (data: AttributeValueUpdate) => {
if (data.attributeValueUpdate.errors.length === 0) {
notify({
status: "success",
text: intl.formatMessage(commonMessages.savedChanges)
});
closeModal();
});
const [
attributeValueUpdate,
attributeValueUpdateOpts
] = useAttributeValueUpdateMutation({
onCompleted: data => {
if (data.attributeValueUpdate.errors.length === 0) {
notify({
status: "success",
text: intl.formatMessage(commonMessages.savedChanges)
});
closeModal();
}
}
};
const handleValueCreate = (data: AttributeValueCreate) => {
if (data.attributeValueCreate.errors.length === 0) {
notify({
status: "success",
text: intl.formatMessage({
defaultMessage: "Added new value",
description: "added new attribute value"
})
});
closeModal();
});
const [attributeUpdate, attributeUpdateOpts] = useAttributeUpdateMutation({
onCompleted: data => {
if (data.attributeUpdate.errors.length === 0) {
notify({
status: "success",
text: intl.formatMessage(commonMessages.savedChanges)
});
}
}
};
const handleValueReorderMutation = (data: AttributeValueReorder) => {
if (data.attributeReorderValues.errors.length !== 0) {
notify({
status: "error",
text: getProductErrorMessage(
data.attributeReorderValues.errors[0],
intl
)
});
});
const [
attributeValueCreate,
attributeValueCreateOpts
] = useAttributeValueCreateMutation({
onCompleted: data => {
if (data.attributeValueCreate.errors.length === 0) {
notify({
status: "success",
text: intl.formatMessage({
defaultMessage: "Added new value",
description: "added new attribute value"
})
});
closeModal();
}
}
});
const [attributeValueReorder] = useAttributeValueReorderMutation({
onCompleted: data => {
if (data.attributeReorderValues.errors.length !== 0) {
notify({
status: "error",
text: getProductErrorMessage(
data.attributeReorderValues.errors[0],
intl
)
});
}
}
});
const handleValueReorder = ({ newIndex, oldIndex }: ReorderEvent) =>
attributeValueReorder({
optimisticResponse: {
attributeReorderValues: {
__typename: "AttributeReorderValues",
attribute: {
...data.attribute,
values: move(
data.attribute.values[oldIndex],
data.attribute.values,
(a, b) => a.id === b.id,
newIndex
)
},
errors: []
}
},
variables: {
id,
move: {
id: data.attribute.values[oldIndex].id,
sortOrder: newIndex - oldIndex
}
}
});
const handleUpdate = async (data: AttributePageFormData) => {
const input = {
...data,
inputType: undefined,
metadata: undefined,
privateMetadata: undefined,
storefrontSearchPosition: parseInt(data.storefrontSearchPosition, 0)
};
const result = await attributeUpdate({
variables: {
id,
input
}
});
return result.data.attributeUpdate.errors;
};
const handleSubmit = createMetadataUpdateHandler(
data?.attribute,
handleUpdate,
variables => updateMetadata({ variables }),
variables => updatePrivateMetadata({ variables })
);
return (
<AttributeDetailsQuery variables={{ id }}>
{({ data, loading }) => (
<AttributeDeleteMutation onCompleted={handleDelete}>
{(attributeDelete, attributeDeleteOpts) => (
<AttributeValueDeleteMutation onCompleted={handleValueDelete}>
{(attributeValueDelete, attributeValueDeleteOpts) => (
<AttributeUpdateMutation onCompleted={handleUpdate}>
{(attributeUpdate, attributeUpdateOpts) => (
<AttributeValueUpdateMutation
onCompleted={handleValueUpdate}
>
{(attributeValueUpdate, attributeValueUpdateOpts) => (
<AttributeValueCreateMutation
onCompleted={handleValueCreate}
>
{(attributeValueCreate, attributeValueCreateOpts) => (
<AttributeValueReorderMutation
onCompleted={handleValueReorderMutation}
>
{attributeValueReorder => {
const handleValueReorder = ({
newIndex,
oldIndex
}: ReorderEvent) =>
attributeValueReorder({
optimisticResponse: {
attributeReorderValues: {
__typename: "AttributeReorderValues",
attribute: {
...data.attribute,
values: move(
data.attribute.values[oldIndex],
data.attribute.values,
(a, b) => a.id === b.id,
newIndex
)
},
errors: []
}
},
variables: {
id,
move: {
id: data.attribute.values[oldIndex].id,
sortOrder: newIndex - oldIndex
}
}
});
return (
<>
<AttributePage
attribute={maybe(() => data.attribute)}
disabled={loading}
errors={
attributeUpdateOpts.data
?.attributeUpdate.errors || []
}
onBack={() =>
navigate(attributeListUrl())
}
onDelete={() => openModal("remove")}
onSubmit={data => {
const input = {
...data,
inputType: undefined
};
attributeUpdate({
variables: {
id,
input: {
...input,
storefrontSearchPosition: parseInt(
input.storefrontSearchPosition,
0
)
}
}
});
}}
onValueAdd={() => openModal("add-value")}
onValueDelete={id =>
openModal("remove-value", {
id
})
}
onValueReorder={handleValueReorder}
onValueUpdate={id =>
openModal("edit-value", {
id
})
}
saveButtonBarState={
attributeUpdateOpts.status
}
values={maybe(
() => data.attribute.values
)}
/>
<AttributeDeleteDialog
open={params.action === "remove"}
name={maybe(
() => data.attribute.name,
"..."
)}
confirmButtonState={
attributeDeleteOpts.status
}
onClose={closeModal}
onConfirm={() =>
attributeDelete({
variables: {
id
}
})
}
/>
<AttributeValueDeleteDialog
attributeName={maybe(
() => data.attribute.name,
"..."
)}
open={params.action === "remove-value"}
name={maybe(
() =>
data.attribute.values.find(
value => params.id === value.id
).name,
"..."
)}
useName={true}
confirmButtonState={
attributeValueDeleteOpts.status
}
onClose={closeModal}
onConfirm={() =>
attributeValueDelete({
variables: {
id: params.id
}
})
}
/>
<AttributeValueEditDialog
attributeValue={null}
confirmButtonState={
attributeValueCreateOpts.status
}
disabled={loading}
errors={
attributeValueCreateOpts.data
?.attributeValueCreate.errors || []
}
open={params.action === "add-value"}
onClose={closeModal}
onSubmit={input =>
attributeValueCreate({
variables: {
id,
input
}
})
}
/>
<AttributeValueEditDialog
attributeValue={maybe(() =>
data.attribute.values.find(
value => params.id === value.id
)
)}
confirmButtonState={
attributeValueUpdateOpts.status
}
disabled={loading}
errors={
attributeValueUpdateOpts.data
?.attributeValueUpdate.errors || []
}
open={params.action === "edit-value"}
onClose={closeModal}
onSubmit={input =>
attributeValueUpdate({
variables: {
id: data.attribute.values.find(
value => params.id === value.id
).id,
input
}
})
}
/>
</>
);
}}
</AttributeValueReorderMutation>
)}
</AttributeValueCreateMutation>
)}
</AttributeValueUpdateMutation>
)}
</AttributeUpdateMutation>
)}
</AttributeValueDeleteMutation>
)}
</AttributeDeleteMutation>
)}
</AttributeDetailsQuery>
<>
<AttributePage
attribute={maybe(() => data.attribute)}
disabled={loading}
errors={attributeUpdateOpts.data?.attributeUpdate.errors || []}
onBack={() => navigate(attributeListUrl())}
onDelete={() => openModal("remove")}
onSubmit={handleSubmit}
onValueAdd={() => openModal("add-value")}
onValueDelete={id =>
openModal("remove-value", {
id
})
}
onValueReorder={handleValueReorder}
onValueUpdate={id =>
openModal("edit-value", {
id
})
}
saveButtonBarState={attributeUpdateOpts.status}
values={maybe(() => data.attribute.values)}
/>
<AttributeDeleteDialog
open={params.action === "remove"}
name={maybe(() => data.attribute.name, "...")}
confirmButtonState={attributeDeleteOpts.status}
onClose={closeModal}
onConfirm={() =>
attributeDelete({
variables: {
id
}
})
}
/>
<AttributeValueDeleteDialog
attributeName={maybe(() => data.attribute.name, "...")}
open={params.action === "remove-value"}
name={maybe(
() =>
data.attribute.values.find(value => params.id === value.id).name,
"..."
)}
useName={true}
confirmButtonState={attributeValueDeleteOpts.status}
onClose={closeModal}
onConfirm={() =>
attributeValueDelete({
variables: {
id: params.id
}
})
}
/>
<AttributeValueEditDialog
attributeValue={null}
confirmButtonState={attributeValueCreateOpts.status}
disabled={loading}
errors={
attributeValueCreateOpts.data?.attributeValueCreate.errors || []
}
open={params.action === "add-value"}
onClose={closeModal}
onSubmit={input =>
attributeValueCreate({
variables: {
id,
input
}
})
}
/>
<AttributeValueEditDialog
attributeValue={maybe(() =>
data.attribute.values.find(value => params.id === value.id)
)}
confirmButtonState={attributeValueUpdateOpts.status}
disabled={loading}
errors={
attributeValueUpdateOpts.data?.attributeValueUpdate.errors || []
}
open={params.action === "edit-value"}
onClose={closeModal}
onSubmit={input =>
attributeValueUpdate({
variables: {
id: data.attribute.values.find(value => params.id === value.id)
.id,
input
}
})
}
/>
</>
);
};
AttributeDetails.displayName = "AttributeDetails";

View file

@ -32,9 +32,8 @@ import useBulkActions from "../../../hooks/useBulkActions";
import { maybe } from "../../../misc";
import AttributeBulkDeleteDialog from "../../components/AttributeBulkDeleteDialog";
import AttributeListPage from "../../components/AttributeListPage";
import { AttributeBulkDeleteMutation } from "../../mutations";
import { useAttributeBulkDeleteMutation } from "../../mutations";
import { useAttributeListQuery } from "../../queries";
import { AttributeBulkDelete } from "../../types/AttributeBulkDelete";
import {
attributeAddUrl,
attributeListUrl,
@ -72,6 +71,26 @@ const AttributeList: React.FC<AttributeListProps> = ({ params }) => {
variables: queryVariables
});
const [
attributeBulkDelete,
attributeBulkDeleteOpts
] = useAttributeBulkDeleteMutation({
onCompleted: data => {
if (data.attributeBulkDelete.errors.length === 0) {
closeModal();
notify({
status: "success",
text: intl.formatMessage({
defaultMessage: "Attributes successfully delete",
description: "deleted multiple attributes"
})
});
reset();
refetch();
}
}
});
const tabs = getFilterTabs();
const currentTab =
@ -125,96 +144,73 @@ const AttributeList: React.FC<AttributeListProps> = ({ params }) => {
params
);
const handleBulkDelete = (data: AttributeBulkDelete) => {
if (data.attributeBulkDelete.errors.length === 0) {
closeModal();
notify({
status: "success",
text: intl.formatMessage({
defaultMessage: "Attributes successfully delete",
description: "deleted multiple attributes"
})
});
reset();
refetch();
}
};
const handleSort = createSortHandler(navigate, attributeListUrl, params);
const currencySymbol = maybe(() => shop.defaultCurrency, "USD");
return (
<AttributeBulkDeleteMutation onCompleted={handleBulkDelete}>
{(attributeBulkDelete, attributeBulkDeleteOpts) => (
<>
<AttributeListPage
attributes={maybe(() =>
data.attributes.edges.map(edge => edge.node)
)}
currencySymbol={currencySymbol}
currentTab={currentTab}
disabled={loading || attributeBulkDeleteOpts.loading}
filterOpts={getFilterOpts(params)}
initialSearch={params.query || ""}
isChecked={isSelected}
onAdd={() => navigate(attributeAddUrl())}
onAll={resetFilters}
onBack={() => navigate(configurationMenuUrl)}
onFilterChange={changeFilters}
onNextPage={loadNextPage}
onPreviousPage={loadPreviousPage}
onRowClick={id => () => navigate(attributeUrl(id))}
onSearchChange={handleSearchChange}
onSort={handleSort}
onTabChange={handleTabChange}
onTabDelete={() => openModal("delete-search")}
onTabSave={() => openModal("save-search")}
pageInfo={pageInfo}
selected={listElements.length}
sort={getSortParams(params)}
tabs={tabs.map(tab => tab.name)}
toggle={toggle}
toggleAll={toggleAll}
toolbar={
<IconButton
color="primary"
onClick={() =>
openModal("remove", {
ids: listElements
})
}
>
<DeleteIcon />
</IconButton>
<>
<AttributeListPage
attributes={maybe(() => data.attributes.edges.map(edge => edge.node))}
currencySymbol={currencySymbol}
currentTab={currentTab}
disabled={loading || attributeBulkDeleteOpts.loading}
filterOpts={getFilterOpts(params)}
initialSearch={params.query || ""}
isChecked={isSelected}
onAdd={() => navigate(attributeAddUrl())}
onAll={resetFilters}
onBack={() => navigate(configurationMenuUrl)}
onFilterChange={changeFilters}
onNextPage={loadNextPage}
onPreviousPage={loadPreviousPage}
onRowClick={id => () => navigate(attributeUrl(id))}
onSearchChange={handleSearchChange}
onSort={handleSort}
onTabChange={handleTabChange}
onTabDelete={() => openModal("delete-search")}
onTabSave={() => openModal("save-search")}
pageInfo={pageInfo}
selected={listElements.length}
sort={getSortParams(params)}
tabs={tabs.map(tab => tab.name)}
toggle={toggle}
toggleAll={toggleAll}
toolbar={
<IconButton
color="primary"
onClick={() =>
openModal("remove", {
ids: listElements
})
}
/>
<AttributeBulkDeleteDialog
confirmButtonState={attributeBulkDeleteOpts.status}
open={
params.action === "remove" && maybe(() => params.ids.length > 0)
}
onConfirm={() =>
attributeBulkDelete({ variables: { ids: params.ids } })
}
onClose={closeModal}
quantity={maybe(() => params.ids.length)}
/>
<SaveFilterTabDialog
open={params.action === "save-search"}
confirmButtonState="default"
onClose={closeModal}
onSubmit={handleTabSave}
/>
<DeleteFilterTabDialog
open={params.action === "delete-search"}
confirmButtonState="default"
onClose={closeModal}
onSubmit={handleTabDelete}
tabName={maybe(() => tabs[currentTab - 1].name, "...")}
/>
</>
)}
</AttributeBulkDeleteMutation>
>
<DeleteIcon />
</IconButton>
}
/>
<AttributeBulkDeleteDialog
confirmButtonState={attributeBulkDeleteOpts.status}
open={params.action === "remove" && maybe(() => params.ids.length > 0)}
onConfirm={() =>
attributeBulkDelete({ variables: { ids: params.ids } })
}
onClose={closeModal}
quantity={maybe(() => params.ids.length)}
/>
<SaveFilterTabDialog
open={params.action === "save-search"}
confirmButtonState="default"
onClose={closeModal}
onSubmit={handleTabSave}
/>
<DeleteFilterTabDialog
open={params.action === "delete-search"}
confirmButtonState="default"
onClose={closeModal}
onSubmit={handleTabDelete}
tabName={maybe(() => tabs[currentTab - 1].name, "...")}
/>
</>
);
};
AttributeList.displayName = "AttributeList";

View file

@ -6,12 +6,16 @@ import CardTitle from "@saleor/components/CardTitle";
import { ConfirmButtonTransitionState } from "@saleor/components/ConfirmButton";
import Container from "@saleor/components/Container";
import Form from "@saleor/components/Form";
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 SeoForm from "@saleor/components/SeoForm";
import { Tab, TabContainer } from "@saleor/components/Tab";
import { ProductErrorFragment } from "@saleor/fragments/types/ProductErrorFragment";
import { sectionNames } from "@saleor/intl";
import { mapMetadataItemToInput } from "@saleor/utils/maps";
import useMetadataChangeTrigger from "@saleor/utils/metadata/useMetadataChangeTrigger";
import { RawDraftContentState } from "draft-js";
import React from "react";
import { FormattedMessage, useIntl } from "react-intl";
@ -28,7 +32,7 @@ import {
import CategoryBackground from "../CategoryBackground";
import CategoryProducts from "../CategoryProducts";
export interface FormData {
export interface FormData extends MetadataFormData {
backgroundImageAlt: string;
description: RawDraftContentState;
name: string;
@ -100,144 +104,174 @@ export const CategoryUpdatePage: React.FC<CategoryUpdatePageProps> = ({
toggleAll
}: CategoryUpdatePageProps) => {
const intl = useIntl();
const {
isMetadataModified,
isPrivateMetadataModified,
makeChangeHandler: makeMetadataChangeHandler
} = useMetadataChangeTrigger();
const initialData: FormData = category
? {
backgroundImageAlt: maybe(() => category.backgroundImage.alt, ""),
description: maybe(() => JSON.parse(category.descriptionJson)),
metadata: category?.metadata?.map(mapMetadataItemToInput),
name: category.name || "",
privateMetadata: category?.privateMetadata?.map(mapMetadataItemToInput),
seoDescription: category.seoDescription || "",
seoTitle: category.seoTitle || ""
}
: {
backgroundImageAlt: "",
description: "",
metadata: undefined,
name: "",
privateMetadata: undefined,
seoDescription: "",
seoTitle: ""
};
const handleSubmit = (data: FormData) => {
const metadata = isMetadataModified ? data.metadata : undefined;
const privateMetadata = isPrivateMetadataModified
? data.privateMetadata
: undefined;
onSubmit({
...data,
metadata,
privateMetadata
});
};
return (
<Form onSubmit={onSubmit} initial={initialData} confirmLeave>
{({ data, change, submit, hasChanged }) => (
<Container>
<AppHeader onBack={onBack}>
{intl.formatMessage(sectionNames.categories)}
</AppHeader>
<PageHeader title={category ? category.name : undefined} />
<CategoryDetailsForm
category={category}
data={data}
disabled={disabled}
errors={errors}
onChange={change}
/>
<CardSpacer />
<CategoryBackground
data={data}
onImageUpload={onImageUpload}
onImageDelete={onImageDelete}
image={maybe(() => category.backgroundImage)}
onChange={change}
/>
<CardSpacer />
<SeoForm
helperText={intl.formatMessage({
defaultMessage:
"Add search engine title and description to make this category easier to find"
})}
title={data.seoTitle}
titlePlaceholder={data.name}
description={data.seoDescription}
descriptionPlaceholder={data.name}
loading={!category}
onChange={change}
disabled={disabled}
/>
<CardSpacer />
<TabContainer>
<CategoriesTab
isActive={currentTab === CategoryPageTab.categories}
changeTab={changeTab}
>
<FormattedMessage
defaultMessage="Subcategories"
description="number of subcategories in category"
/>
</CategoriesTab>
<ProductsTab
isActive={currentTab === CategoryPageTab.products}
changeTab={changeTab}
>
<FormattedMessage
defaultMessage="Products"
description="number of products in category"
/>
</ProductsTab>
</TabContainer>
<CardSpacer />
{currentTab === CategoryPageTab.categories && (
<Card>
<CardTitle
title={intl.formatMessage({
defaultMessage: "All Subcategories",
description: "section header"
})}
toolbar={
<Button
color="primary"
variant="text"
onClick={onAddCategory}
>
<FormattedMessage
defaultMessage="Create subcategory"
description="button"
/>
</Button>
}
/>
<CategoryList
categories={subcategories}
<Form onSubmit={handleSubmit} initial={initialData} confirmLeave>
{({ data, change, submit, hasChanged }) => {
const changeMetadata = makeMetadataChangeHandler(change);
return (
<Container>
<AppHeader onBack={onBack}>
{intl.formatMessage(sectionNames.categories)}
</AppHeader>
<PageHeader title={category ? category.name : undefined} />
<CategoryDetailsForm
category={category}
data={data}
disabled={disabled}
errors={errors}
onChange={change}
/>
<CardSpacer />
<CategoryBackground
data={data}
onImageUpload={onImageUpload}
onImageDelete={onImageDelete}
image={maybe(() => category.backgroundImage)}
onChange={change}
/>
<CardSpacer />
<SeoForm
helperText={intl.formatMessage({
defaultMessage:
"Add search engine title and description to make this category easier to find"
})}
title={data.seoTitle}
titlePlaceholder={data.name}
description={data.seoDescription}
descriptionPlaceholder={data.name}
loading={!category}
onChange={change}
disabled={disabled}
/>
<CardSpacer />
<Metadata data={data} onChange={changeMetadata} />
<CardSpacer />
<TabContainer>
<CategoriesTab
isActive={currentTab === CategoryPageTab.categories}
changeTab={changeTab}
>
<FormattedMessage
defaultMessage="Subcategories"
description="number of subcategories in category"
/>
</CategoriesTab>
<ProductsTab
isActive={currentTab === CategoryPageTab.products}
changeTab={changeTab}
>
<FormattedMessage
defaultMessage="Products"
description="number of products in category"
/>
</ProductsTab>
</TabContainer>
<CardSpacer />
{currentTab === CategoryPageTab.categories && (
<Card>
<CardTitle
title={intl.formatMessage({
defaultMessage: "All Subcategories",
description: "section header"
})}
toolbar={
<Button
color="primary"
variant="text"
onClick={onAddCategory}
>
<FormattedMessage
defaultMessage="Create subcategory"
description="button"
/>
</Button>
}
/>
<CategoryList
categories={subcategories}
disabled={disabled}
isChecked={isChecked}
isRoot={false}
pageInfo={pageInfo}
selected={selected}
sort={undefined}
toggle={toggle}
toggleAll={toggleAll}
toolbar={subcategoryListToolbar}
onNextPage={onNextPage}
onPreviousPage={onPreviousPage}
onRowClick={onCategoryClick}
onSort={() => undefined}
/>
</Card>
)}
{currentTab === CategoryPageTab.products && (
<CategoryProducts
categoryName={maybe(() => category.name)}
products={products}
disabled={disabled}
isChecked={isChecked}
isRoot={false}
pageInfo={pageInfo}
selected={selected}
sort={undefined}
toggle={toggle}
toggleAll={toggleAll}
toolbar={subcategoryListToolbar}
onNextPage={onNextPage}
onPreviousPage={onPreviousPage}
onRowClick={onCategoryClick}
onSort={() => undefined}
onRowClick={onProductClick}
onAdd={onAddProduct}
toggle={toggle}
toggleAll={toggleAll}
selected={selected}
isChecked={isChecked}
toolbar={productListToolbar}
/>
</Card>
)}
{currentTab === CategoryPageTab.products && (
<CategoryProducts
categoryName={maybe(() => category.name)}
products={products}
disabled={disabled}
pageInfo={pageInfo}
onNextPage={onNextPage}
onPreviousPage={onPreviousPage}
onRowClick={onProductClick}
onAdd={onAddProduct}
toggle={toggle}
toggleAll={toggleAll}
selected={selected}
isChecked={isChecked}
toolbar={productListToolbar}
)}
<SaveButtonBar
onCancel={onBack}
onDelete={onDelete}
onSave={submit}
state={saveButtonBarState}
disabled={disabled || !hasChanged}
/>
)}
<SaveButtonBar
onCancel={onBack}
onDelete={onDelete}
onSave={submit}
state={saveButtonBarState}
disabled={disabled || !hasChanged}
/>
</Container>
)}
</Container>
);
}}
</Form>
);
};

View file

@ -105,11 +105,19 @@ export const category: (
},
descriptionJson: JSON.stringify(content),
id: "Q2F0ZWdvcnk6NA==",
metadata: [
{
__typename: "MetadataItem",
key: "integration.id",
value: "100023123"
}
],
name: "Coffees",
parent: {
__typename: "Category",
id: "Q2F0ZWdvcnk6Mw=="
},
privateMetadata: [],
products: {
__typename: "ProductCountableConnection",
edges: [

View file

@ -8,6 +8,18 @@ import { CategoryInput, ProductErrorCode } from "./../../types/globalTypes";
// GraphQL mutation operation: CategoryCreate
// ====================================================
export interface CategoryCreate_categoryCreate_category_metadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface CategoryCreate_categoryCreate_category_privateMetadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface CategoryCreate_categoryCreate_category_backgroundImage {
__typename: "Image";
alt: string | null;
@ -22,6 +34,8 @@ export interface CategoryCreate_categoryCreate_category_parent {
export interface CategoryCreate_categoryCreate_category {
__typename: "Category";
id: string;
metadata: (CategoryCreate_categoryCreate_category_metadata | null)[];
privateMetadata: (CategoryCreate_categoryCreate_category_privateMetadata | null)[];
backgroundImage: CategoryCreate_categoryCreate_category_backgroundImage | null;
name: string;
descriptionJson: any;

View file

@ -6,6 +6,18 @@
// GraphQL query operation: CategoryDetails
// ====================================================
export interface CategoryDetails_category_metadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface CategoryDetails_category_privateMetadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface CategoryDetails_category_backgroundImage {
__typename: "Image";
alt: string | null;
@ -131,6 +143,8 @@ export interface CategoryDetails_category_products {
export interface CategoryDetails_category {
__typename: "Category";
id: string;
metadata: (CategoryDetails_category_metadata | null)[];
privateMetadata: (CategoryDetails_category_privateMetadata | null)[];
backgroundImage: CategoryDetails_category_backgroundImage | null;
name: string;
descriptionJson: any;

View file

@ -8,6 +8,18 @@ import { CategoryInput, ProductErrorCode } from "./../../types/globalTypes";
// GraphQL mutation operation: CategoryUpdate
// ====================================================
export interface CategoryUpdate_categoryUpdate_category_metadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface CategoryUpdate_categoryUpdate_category_privateMetadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface CategoryUpdate_categoryUpdate_category_backgroundImage {
__typename: "Image";
alt: string | null;
@ -22,6 +34,8 @@ export interface CategoryUpdate_categoryUpdate_category_parent {
export interface CategoryUpdate_categoryUpdate_category {
__typename: "Category";
id: string;
metadata: (CategoryUpdate_categoryUpdate_category_metadata | null)[];
privateMetadata: (CategoryUpdate_categoryUpdate_category_privateMetadata | null)[];
backgroundImage: CategoryUpdate_categoryUpdate_category_backgroundImage | null;
name: string;
descriptionJson: any;

View file

@ -12,6 +12,11 @@ import usePaginator, {
} from "@saleor/hooks/usePaginator";
import { commonMessages } from "@saleor/intl";
import createDialogActionHandlers from "@saleor/utils/handlers/dialogActionHandlers";
import createMetadataUpdateHandler from "@saleor/utils/handlers/metadataUpdateHandler";
import {
useMetadataUpdate,
usePrivateMetadataUpdate
} from "@saleor/utils/metadata/updateMetadata";
import React from "react";
import { FormattedMessage, useIntl } from "react-intl";
@ -22,7 +27,8 @@ import { productAddUrl, productUrl } from "../../products/urls";
import { CategoryInput } from "../../types/globalTypes";
import {
CategoryPageTab,
CategoryUpdatePage
CategoryUpdatePage,
FormData
} from "../components/CategoryUpdatePage/CategoryUpdatePage";
import {
useCategoryBulkDeleteMutation,
@ -63,6 +69,8 @@ export const CategoryDetails: React.FC<CategoryDetailsProps> = ({
params.ids
);
const intl = useIntl();
const [updateMetadata] = useMetadataUpdate({});
const [updatePrivateMetadata] = usePrivateMetadataUpdate({});
const paginationState = createPaginationState(PAGINATE_BY, params);
const { data, loading, refetch } = useCategoryDetailsQuery({
@ -167,6 +175,31 @@ export const CategoryDetails: React.FC<CategoryDetailsProps> = ({
params
);
const handleUpdate = async (formData: FormData) => {
const result = await updateCategory({
variables: {
id,
input: {
backgroundImageAlt: formData.backgroundImageAlt,
descriptionJson: JSON.stringify(formData.description),
name: formData.name,
seo: {
description: formData.seoDescription,
title: formData.seoTitle
}
}
}
});
return result.data.categoryUpdate.errors;
};
const handleSubmit = createMetadataUpdateHandler(
data?.category,
handleUpdate,
variables => updateMetadata({ variables }),
variables => updatePrivateMetadata({ variables })
);
return (
<>
<WindowTitle title={maybe(() => data.category.name)} />
@ -209,22 +242,7 @@ export const CategoryDetails: React.FC<CategoryDetailsProps> = ({
onPreviousPage={loadPreviousPage}
pageInfo={pageInfo}
onProductClick={id => () => navigate(productUrl(id))}
onSubmit={formData =>
updateCategory({
variables: {
id,
input: {
backgroundImageAlt: formData.backgroundImageAlt,
descriptionJson: JSON.stringify(formData.description),
name: formData.name,
seo: {
description: formData.seoDescription,
title: formData.seoTitle
}
}
}
})
}
onSubmit={handleSubmit}
products={maybe(() =>
data.category.products.edges.map(edge => edge.node)
)}

View file

@ -7,6 +7,8 @@ import Form from "@saleor/components/Form";
import FormSpacer from "@saleor/components/FormSpacer";
import Grid from "@saleor/components/Grid";
import Hr from "@saleor/components/Hr";
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 SeoForm from "@saleor/components/SeoForm";
@ -14,6 +16,8 @@ import VisibilityCard from "@saleor/components/VisibilityCard";
import { ProductErrorFragment } from "@saleor/fragments/types/ProductErrorFragment";
import useDateLocalize from "@saleor/hooks/useDateLocalize";
import { sectionNames } from "@saleor/intl";
import { mapMetadataItemToInput } from "@saleor/utils/maps";
import useMetadataChangeTrigger from "@saleor/utils/metadata/useMetadataChangeTrigger";
import { RawDraftContentState } from "draft-js";
import React from "react";
import { useIntl } from "react-intl";
@ -25,7 +29,7 @@ import CollectionDetails from "../CollectionDetails/CollectionDetails";
import { CollectionImage } from "../CollectionImage/CollectionImage";
import CollectionProducts from "../CollectionProducts/CollectionProducts";
export interface CollectionDetailsPageFormData {
export interface CollectionDetailsPageFormData extends MetadataFormData {
backgroundImageAlt: string;
description: RawDraftContentState;
name: string;
@ -64,6 +68,24 @@ const CollectionDetailsPage: React.FC<CollectionDetailsPageProps> = ({
}: CollectionDetailsPageProps) => {
const intl = useIntl();
const localizeDate = useDateLocalize();
const {
isMetadataModified,
isPrivateMetadataModified,
makeChangeHandler: makeMetadataChangeHandler
} = useMetadataChangeTrigger();
const handleSubmit = (data: CollectionDetailsPageFormData) => {
const metadata = isMetadataModified ? data.metadata : undefined;
const privateMetadata = isPrivateMetadataModified
? data.privateMetadata
: undefined;
onSubmit({
...data,
metadata,
privateMetadata
});
};
return (
<Form
@ -72,108 +94,118 @@ const CollectionDetailsPage: React.FC<CollectionDetailsPageProps> = ({
description: maybe(() => JSON.parse(collection.descriptionJson)),
isFeatured,
isPublished: maybe(() => collection.isPublished, false),
metadata: collection?.metadata?.map(mapMetadataItemToInput),
name: maybe(() => collection.name, ""),
privateMetadata: collection?.privateMetadata?.map(
mapMetadataItemToInput
),
publicationDate: maybe(() => collection.publicationDate, ""),
seoDescription: maybe(() => collection.seoDescription, ""),
seoTitle: maybe(() => collection.seoTitle, "")
}}
onSubmit={onSubmit}
onSubmit={handleSubmit}
confirmLeave
>
{({ change, data, hasChanged, submit }) => (
<Container>
<AppHeader onBack={onBack}>
{intl.formatMessage(sectionNames.collections)}
</AppHeader>
<PageHeader title={maybe(() => collection.name)} />
<Grid>
<div>
<CollectionDetails
collection={collection}
data={data}
disabled={disabled}
errors={errors}
onChange={change}
/>
<CardSpacer />
<CollectionImage
data={data}
image={maybe(() => collection.backgroundImage)}
onImageDelete={onImageDelete}
onImageUpload={onImageUpload}
onChange={change}
/>
<CardSpacer />
<CollectionProducts
disabled={disabled}
collection={collection}
{...collectionProductsProps}
/>
<CardSpacer />
<SeoForm
description={data.seoDescription}
disabled={disabled}
descriptionPlaceholder=""
helperText={intl.formatMessage({
defaultMessage:
"Add search engine title and description to make this collection easier to find"
})}
title={data.seoTitle}
titlePlaceholder={maybe(() => collection.name)}
onChange={change}
/>
</div>
<div>
{({ change, data, hasChanged, submit }) => {
const changeMetadata = makeMetadataChangeHandler(change);
return (
<Container>
<AppHeader onBack={onBack}>
{intl.formatMessage(sectionNames.collections)}
</AppHeader>
<PageHeader title={maybe(() => collection.name)} />
<Grid>
<div>
<VisibilityCard
<CollectionDetails
collection={collection}
data={data}
errors={errors}
disabled={disabled}
hiddenMessage={intl.formatMessage(
{
defaultMessage: "will be visible from {date}",
description: "collection"
},
{
date: localizeDate(data.publicationDate)
}
)}
errors={errors}
onChange={change}
visibleMessage={intl.formatMessage(
{
defaultMessage: "since {date}",
description: "collection"
},
{
date: localizeDate(data.publicationDate)
}
)}
>
<FormSpacer />
<Hr />
<ControlledCheckbox
name={"isFeatured" as keyof CollectionDetailsPageFormData}
label={intl.formatMessage({
defaultMessage: "Feature on Homepage",
description: "switch button"
})}
checked={data.isFeatured}
onChange={change}
disabled={disabled}
/>
</VisibilityCard>
/>
<CardSpacer />
<CollectionImage
data={data}
image={maybe(() => collection.backgroundImage)}
onImageDelete={onImageDelete}
onImageUpload={onImageUpload}
onChange={change}
/>
<CardSpacer />
<Metadata data={data} onChange={changeMetadata} />
<CardSpacer />
<CollectionProducts
disabled={disabled}
collection={collection}
{...collectionProductsProps}
/>
<CardSpacer />
<SeoForm
description={data.seoDescription}
disabled={disabled}
descriptionPlaceholder=""
helperText={intl.formatMessage({
defaultMessage:
"Add search engine title and description to make this collection easier to find"
})}
title={data.seoTitle}
titlePlaceholder={maybe(() => collection.name)}
onChange={change}
/>
</div>
</div>
</Grid>
<SaveButtonBar
state={saveButtonBarState}
disabled={disabled || !hasChanged}
onCancel={onBack}
onDelete={onCollectionRemove}
onSave={submit}
/>
</Container>
)}
<div>
<div>
<VisibilityCard
data={data}
errors={errors}
disabled={disabled}
hiddenMessage={intl.formatMessage(
{
defaultMessage: "will be visible from {date}",
description: "collection"
},
{
date: localizeDate(data.publicationDate)
}
)}
onChange={change}
visibleMessage={intl.formatMessage(
{
defaultMessage: "since {date}",
description: "collection"
},
{
date: localizeDate(data.publicationDate)
}
)}
>
<FormSpacer />
<Hr />
<ControlledCheckbox
name={"isFeatured" as keyof CollectionDetailsPageFormData}
label={intl.formatMessage({
defaultMessage: "Feature on Homepage",
description: "switch button"
})}
checked={data.isFeatured}
onChange={change}
disabled={disabled}
/>
</VisibilityCard>
</div>
</div>
</Grid>
<SaveButtonBar
state={saveButtonBarState}
disabled={disabled || !hasChanged}
onCancel={onBack}
onDelete={onCollectionRemove}
onSave={submit}
/>
</Container>
);
}}
</Form>
);
};

View file

@ -1,113 +0,0 @@
import React from "react";
import { getMutationProviderData } from "../../misc";
import { PartialMutationProviderOutput } from "../../types";
import {
TypedCollectionAssignProductMutation,
TypedCollectionRemoveMutation,
TypedCollectionUpdateMutation,
TypedCollectionUpdateWithHomepageMutation,
TypedUnassignCollectionProductMutation
} from "../mutations";
import {
CollectionAssignProduct,
CollectionAssignProductVariables
} from "../types/CollectionAssignProduct";
import {
CollectionUpdate,
CollectionUpdateVariables
} from "../types/CollectionUpdate";
import {
CollectionUpdateWithHomepage,
CollectionUpdateWithHomepageVariables
} from "../types/CollectionUpdateWithHomepage";
import {
RemoveCollection,
RemoveCollectionVariables
} from "../types/RemoveCollection";
import {
UnassignCollectionProduct,
UnassignCollectionProductVariables
} from "../types/UnassignCollectionProduct";
interface CollectionUpdateOperationsProps {
children: (props: {
updateCollectionWithHomepage: PartialMutationProviderOutput<
CollectionUpdateWithHomepage,
CollectionUpdateWithHomepageVariables
>;
assignProduct: PartialMutationProviderOutput<
CollectionAssignProduct,
CollectionAssignProductVariables
>;
unassignProduct: PartialMutationProviderOutput<
UnassignCollectionProduct,
UnassignCollectionProductVariables
>;
updateCollection: PartialMutationProviderOutput<
CollectionUpdate,
CollectionUpdateVariables
>;
removeCollection: PartialMutationProviderOutput<
RemoveCollection,
RemoveCollectionVariables
>;
}) => React.ReactNode;
onUpdate: (data: CollectionUpdate) => void;
onUpdateWithCollection: (data: CollectionUpdateWithHomepage) => void;
onProductAssign: (data: CollectionAssignProduct) => void;
onProductUnassign: (data: UnassignCollectionProduct) => void;
onRemove: (data: RemoveCollection) => void;
}
const CollectionOperations: React.FC<CollectionUpdateOperationsProps> = ({
children,
onUpdate,
onUpdateWithCollection,
onProductAssign,
onProductUnassign,
onRemove
}) => (
<TypedCollectionUpdateMutation onCompleted={onUpdate}>
{(...updateCollection) => (
<TypedCollectionRemoveMutation onCompleted={onRemove}>
{(...removeCollection) => (
<TypedCollectionAssignProductMutation onCompleted={onProductAssign}>
{(...assignProduct) => (
<TypedCollectionUpdateWithHomepageMutation
onCompleted={onUpdateWithCollection}
>
{(...updateWithHomepage) => (
<TypedUnassignCollectionProductMutation
onCompleted={onProductUnassign}
>
{(...unassignProduct) =>
children({
assignProduct: getMutationProviderData(
...assignProduct
),
removeCollection: getMutationProviderData(
...removeCollection
),
unassignProduct: getMutationProviderData(
...unassignProduct
),
updateCollection: getMutationProviderData(
...updateCollection
),
updateCollectionWithHomepage: getMutationProviderData(
...updateWithHomepage
)
})
}
</TypedUnassignCollectionProductMutation>
)}
</TypedCollectionUpdateWithHomepageMutation>
)}
</TypedCollectionAssignProductMutation>
)}
</TypedCollectionRemoveMutation>
)}
</TypedCollectionUpdateMutation>
);
export default CollectionOperations;

View file

@ -80,7 +80,15 @@ export const collection: (
descriptionJson: JSON.stringify(content),
id: "Q29sbGVjdGlvbjox",
isPublished: true,
metadata: [
{
__typename: "MetadataItem",
key: "integration.id",
value: "100023123"
}
],
name: "Summer collection",
privateMetadata: [],
products: {
__typename: "ProductCountableConnection",
edges: [

View file

@ -6,9 +6,9 @@ import {
productErrorFragment,
shopErrorFragment
} from "@saleor/fragments/errors";
import makeMutation from "@saleor/hooks/makeMutation";
import gql from "graphql-tag";
import { TypedMutation } from "../mutations";
import {
CollectionAssignProduct,
CollectionAssignProductVariables
@ -56,7 +56,7 @@ const collectionUpdate = gql`
}
}
`;
export const TypedCollectionUpdateMutation = TypedMutation<
export const useCollectionUpdateMutation = makeMutation<
CollectionUpdate,
CollectionUpdateVariables
>(collectionUpdate);
@ -90,7 +90,7 @@ const collectionUpdateWithHomepage = gql`
}
}
`;
export const TypedCollectionUpdateWithHomepageMutation = TypedMutation<
export const useCollectionUpdateWithHomepageMutation = makeMutation<
CollectionUpdateWithHomepage,
CollectionUpdateWithHomepageVariables
>(collectionUpdateWithHomepage);
@ -129,7 +129,7 @@ const assignCollectionProduct = gql`
}
}
`;
export const TypedCollectionAssignProductMutation = TypedMutation<
export const useCollectionAssignProductMutation = makeMutation<
CollectionAssignProduct,
CollectionAssignProductVariables
>(assignCollectionProduct);
@ -148,7 +148,7 @@ const createCollection = gql`
}
}
`;
export const TypedCollectionCreateMutation = TypedMutation<
export const useCollectionCreateMutation = makeMutation<
CreateCollection,
CreateCollectionVariables
>(createCollection);
@ -163,7 +163,7 @@ const removeCollection = gql`
}
}
`;
export const TypedCollectionRemoveMutation = TypedMutation<
export const useCollectionRemoveMutation = makeMutation<
RemoveCollection,
RemoveCollectionVariables
>(removeCollection);
@ -213,7 +213,7 @@ const unassignCollectionProduct = gql`
}
}
`;
export const TypedUnassignCollectionProductMutation = TypedMutation<
export const useUnassignCollectionProductMutation = makeMutation<
UnassignCollectionProduct,
UnassignCollectionProductVariables
>(unassignCollectionProduct);
@ -228,7 +228,7 @@ const collectionBulkDelete = gql`
}
}
`;
export const TypedCollectionBulkDelete = TypedMutation<
export const useCollectionBulkDelete = makeMutation<
CollectionBulkDelete,
CollectionBulkDeleteVariables
>(collectionBulkDelete);
@ -243,7 +243,7 @@ const collectionBulkPublish = gql`
}
}
`;
export const TypedCollectionBulkPublish = TypedMutation<
export const useCollectionBulkPublish = makeMutation<
CollectionBulkPublish,
CollectionBulkPublishVariables
>(collectionBulkPublish);

View file

@ -6,6 +6,18 @@
// GraphQL query operation: CollectionDetails
// ====================================================
export interface CollectionDetails_collection_metadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface CollectionDetails_collection_privateMetadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface CollectionDetails_collection_backgroundImage {
__typename: "Image";
alt: string | null;
@ -56,6 +68,8 @@ export interface CollectionDetails_collection {
id: string;
isPublished: boolean;
name: string;
metadata: (CollectionDetails_collection_metadata | null)[];
privateMetadata: (CollectionDetails_collection_privateMetadata | null)[];
backgroundImage: CollectionDetails_collection_backgroundImage | null;
descriptionJson: any;
publicationDate: any | null;

View file

@ -8,6 +8,18 @@ import { CollectionInput, ProductErrorCode } from "./../../types/globalTypes";
// GraphQL mutation operation: CollectionUpdate
// ====================================================
export interface CollectionUpdate_collectionUpdate_collection_metadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface CollectionUpdate_collectionUpdate_collection_privateMetadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface CollectionUpdate_collectionUpdate_collection_backgroundImage {
__typename: "Image";
alt: string | null;
@ -19,6 +31,8 @@ export interface CollectionUpdate_collectionUpdate_collection {
id: string;
isPublished: boolean;
name: string;
metadata: (CollectionUpdate_collectionUpdate_collection_metadata | null)[];
privateMetadata: (CollectionUpdate_collectionUpdate_collection_privateMetadata | null)[];
backgroundImage: CollectionUpdate_collectionUpdate_collection_backgroundImage | null;
descriptionJson: any;
publicationDate: any | null;

View file

@ -30,6 +30,18 @@ export interface CollectionUpdateWithHomepage_homepageCollectionUpdate {
shop: CollectionUpdateWithHomepage_homepageCollectionUpdate_shop | null;
}
export interface CollectionUpdateWithHomepage_collectionUpdate_collection_metadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface CollectionUpdateWithHomepage_collectionUpdate_collection_privateMetadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface CollectionUpdateWithHomepage_collectionUpdate_collection_backgroundImage {
__typename: "Image";
alt: string | null;
@ -41,6 +53,8 @@ export interface CollectionUpdateWithHomepage_collectionUpdate_collection {
id: string;
isPublished: boolean;
name: string;
metadata: (CollectionUpdateWithHomepage_collectionUpdate_collection_metadata | null)[];
privateMetadata: (CollectionUpdateWithHomepage_collectionUpdate_collection_privateMetadata | null)[];
backgroundImage: CollectionUpdateWithHomepage_collectionUpdate_collection_backgroundImage | null;
descriptionJson: any;
publicationDate: any | null;

View file

@ -8,6 +8,18 @@ import { CollectionCreateInput, ProductErrorCode } from "./../../types/globalTyp
// GraphQL mutation operation: CreateCollection
// ====================================================
export interface CreateCollection_collectionCreate_collection_metadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface CreateCollection_collectionCreate_collection_privateMetadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface CreateCollection_collectionCreate_collection_backgroundImage {
__typename: "Image";
alt: string | null;
@ -19,6 +31,8 @@ export interface CreateCollection_collectionCreate_collection {
id: string;
isPublished: boolean;
name: string;
metadata: (CreateCollection_collectionCreate_collection_metadata | null)[];
privateMetadata: (CreateCollection_collectionCreate_collection_privateMetadata | null)[];
backgroundImage: CreateCollection_collectionCreate_collection_backgroundImage | null;
descriptionJson: any;
publicationDate: any | null;

View file

@ -7,8 +7,7 @@ import { useIntl } from "react-intl";
import { CollectionCreateInput } from "../../types/globalTypes";
import CollectionCreatePage from "../components/CollectionCreatePage/CollectionCreatePage";
import { TypedCollectionCreateMutation } from "../mutations";
import { CreateCollection } from "../types/CreateCollection";
import { useCollectionCreateMutation } from "../mutations";
import { collectionListUrl, collectionUrl } from "../urls";
export const CollectionCreate: React.FC = () => {
@ -16,62 +15,61 @@ export const CollectionCreate: React.FC = () => {
const notify = useNotifier();
const intl = useIntl();
const handleCollectionCreateSuccess = (data: CreateCollection) => {
if (data.collectionCreate.errors.length === 0) {
notify({
status: "success",
text: intl.formatMessage(commonMessages.savedChanges)
});
navigate(collectionUrl(data.collectionCreate.collection.id));
} else {
const backgroundImageError = data.collectionCreate.errors.find(
error =>
error.field === ("backgroundImage" as keyof CollectionCreateInput)
);
if (backgroundImageError) {
const [createCollection, createCollectionOpts] = useCollectionCreateMutation({
onCompleted: data => {
if (data.collectionCreate.errors.length === 0) {
notify({
status: "error",
text: intl.formatMessage(commonMessages.somethingWentWrong)
status: "success",
text: intl.formatMessage(commonMessages.savedChanges)
});
navigate(collectionUrl(data.collectionCreate.collection.id));
} else {
const backgroundImageError = data.collectionCreate.errors.find(
error =>
error.field === ("backgroundImage" as keyof CollectionCreateInput)
);
if (backgroundImageError) {
notify({
status: "error",
text: intl.formatMessage(commonMessages.somethingWentWrong)
});
}
}
}
};
});
return (
<TypedCollectionCreateMutation onCompleted={handleCollectionCreateSuccess}>
{(createCollection, createCollectionOpts) => (
<>
<WindowTitle
title={intl.formatMessage({
defaultMessage: "Create collection",
description: "window title"
})}
/>
<CollectionCreatePage
errors={createCollectionOpts.data?.collectionCreate.errors || []}
onBack={() => navigate(collectionListUrl())}
disabled={createCollectionOpts.loading}
onSubmit={formData =>
createCollection({
variables: {
input: {
backgroundImage: formData.backgroundImage.value,
backgroundImageAlt: formData.backgroundImageAlt,
descriptionJson: JSON.stringify(formData.description),
isPublished: formData.isPublished,
name: formData.name,
seo: {
description: formData.seoDescription,
title: formData.seoTitle
}
}
<>
<WindowTitle
title={intl.formatMessage({
defaultMessage: "Create collection",
description: "window title"
})}
/>
<CollectionCreatePage
errors={createCollectionOpts.data?.collectionCreate.errors || []}
onBack={() => navigate(collectionListUrl())}
disabled={createCollectionOpts.loading}
onSubmit={formData =>
createCollection({
variables: {
input: {
backgroundImage: formData.backgroundImage.value,
backgroundImageAlt: formData.backgroundImageAlt,
descriptionJson: JSON.stringify(formData.description),
isPublished: formData.isPublished,
name: formData.name,
seo: {
description: formData.seoDescription,
title: formData.seoTitle
}
})
}
}
saveButtonBarState={createCollectionOpts.status}
/>
</>
)}
</TypedCollectionCreateMutation>
})
}
saveButtonBarState={createCollectionOpts.status}
/>
</>
);
};
export default CollectionCreate;

View file

@ -14,6 +14,11 @@ import usePaginator, {
import { commonMessages } from "@saleor/intl";
import useProductSearch from "@saleor/searches/useProductSearch";
import createDialogActionHandlers from "@saleor/utils/handlers/dialogActionHandlers";
import createMetadataUpdateHandler from "@saleor/utils/handlers/metadataUpdateHandler";
import {
useMetadataUpdate,
usePrivateMetadataUpdate
} from "@saleor/utils/metadata/updateMetadata";
import React from "react";
import { FormattedMessage, useIntl } from "react-intl";
@ -23,13 +28,15 @@ import { CollectionInput } from "../../types/globalTypes";
import CollectionDetailsPage, {
CollectionDetailsPageFormData
} from "../components/CollectionDetailsPage/CollectionDetailsPage";
import CollectionOperations from "../containers/CollectionOperations";
import {
useCollectionAssignProductMutation,
useCollectionRemoveMutation,
useCollectionUpdateMutation,
useCollectionUpdateWithHomepageMutation,
useUnassignCollectionProductMutation
} from "../mutations";
import { TypedCollectionDetailsQuery } from "../queries";
import { CollectionAssignProduct } from "../types/CollectionAssignProduct";
import { CollectionUpdate } from "../types/CollectionUpdate";
import { CollectionUpdateWithHomepage } from "../types/CollectionUpdateWithHomepage";
import { RemoveCollection } from "../types/RemoveCollection";
import { UnassignCollectionProduct } from "../types/UnassignCollectionProduct";
import {
collectionListUrl,
collectionUrl,
@ -56,6 +63,90 @@ export const CollectionDetails: React.FC<CollectionDetailsProps> = ({
const { search, result } = useProductSearch({
variables: DEFAULT_INITIAL_SEARCH_DATA
});
const [updateMetadata] = useMetadataUpdate({});
const [updatePrivateMetadata] = usePrivateMetadataUpdate({});
const handleCollectionUpdate = (data: CollectionUpdate) => {
if (data.collectionUpdate.errors.length === 0) {
notify({
status: "success",
text: intl.formatMessage(commonMessages.savedChanges)
});
navigate(collectionUrl(id));
} else {
const backgroundImageError = data.collectionUpdate.errors.find(
error => error.field === ("backgroundImage" as keyof CollectionInput)
);
if (backgroundImageError) {
notify({
status: "error",
text: intl.formatMessage(commonMessages.somethingWentWrong)
});
}
}
};
const [updateCollection, updateCollectionOpts] = useCollectionUpdateMutation({
onCompleted: handleCollectionUpdate
});
const [
updateCollectionWithHomepage,
updateCollectionWithHomepageOpts
] = useCollectionUpdateWithHomepageMutation({
onCompleted: data => {
if (data.homepageCollectionUpdate.errors.length === 0) {
handleCollectionUpdate(data);
}
}
});
const [assignProduct, assignProductOpts] = useCollectionAssignProductMutation(
{
onCompleted: data => {
if (data.collectionAddProducts.errors.length === 0) {
notify({
status: "success",
text: intl.formatMessage({
defaultMessage: "Added product to collection"
})
});
navigate(collectionUrl(id), true);
}
}
}
);
const [
unassignProduct,
unassignProductOpts
] = useUnassignCollectionProductMutation({
onCompleted: data => {
if (data.collectionRemoveProducts.errors.length === 0) {
notify({
status: "success",
text: intl.formatMessage({
defaultMessage: "Deleted product from collection"
})
});
reset();
closeModal();
}
}
});
const [removeCollection, removeCollectionOpts] = useCollectionRemoveMutation({
onCompleted: data => {
if (data.collectionDelete.errors.length === 0) {
notify({
status: "success",
text: intl.formatMessage({
defaultMessage: "Deleted collection"
})
});
navigate(collectionListUrl());
}
}
});
const [openModal, closeModal] = createDialogActionHandlers<
CollectionUrlDialog,
@ -76,298 +167,236 @@ export const CollectionDetails: React.FC<CollectionDetailsProps> = ({
if (collection === null) {
return <NotFoundPage onBack={handleBack} />;
}
const handleCollectionUpdate = (data: CollectionUpdate) => {
if (data.collectionUpdate.errors.length === 0) {
notify({
status: "success",
text: intl.formatMessage(commonMessages.savedChanges)
});
navigate(collectionUrl(id));
} else {
const backgroundImageError = data.collectionUpdate.errors.find(
error =>
error.field === ("backgroundImage" as keyof CollectionInput)
);
if (backgroundImageError) {
notify({
status: "error",
text: intl.formatMessage(commonMessages.somethingWentWrong)
});
}
}
};
const handleCollectioUpdateWithHomepage = (
data: CollectionUpdateWithHomepage
const handleUpdate = async (
formData: CollectionDetailsPageFormData
) => {
if (data.homepageCollectionUpdate.errors.length === 0) {
handleCollectionUpdate(data);
}
};
const input: CollectionInput = {
backgroundImageAlt: formData.backgroundImageAlt,
descriptionJson: JSON.stringify(formData.description),
isPublished: formData.isPublished,
name: formData.name,
publicationDate: formData.publicationDate,
seo: {
description: formData.seoDescription,
title: formData.seoTitle
}
};
const isFeatured = data.shop.homepageCollection
? data.shop.homepageCollection.id === data.collection.id
: false;
const handleProductAssign = (data: CollectionAssignProduct) => {
if (data.collectionAddProducts.errors.length === 0) {
notify({
status: "success",
text: intl.formatMessage({
defaultMessage: "Added product to collection"
})
if (formData.isFeatured !== isFeatured) {
const result = await updateCollectionWithHomepage({
variables: {
homepageId: formData.isFeatured ? id : null,
id,
input
}
});
return [
...result.data.collectionUpdate.errors,
...result.data.homepageCollectionUpdate.errors
];
} else {
const result = await updateCollection({
variables: {
id,
input
}
});
navigate(collectionUrl(id), true);
}
};
const handleProductUnassign = (data: UnassignCollectionProduct) => {
if (data.collectionRemoveProducts.errors.length === 0) {
notify({
status: "success",
text: intl.formatMessage({
defaultMessage: "Deleted product from collection"
})
});
reset();
closeModal();
return result.data.collectionUpdate.errors;
}
};
const handleSubmit = createMetadataUpdateHandler(
data?.collection,
handleUpdate,
variables => updateMetadata({ variables }),
variables => updatePrivateMetadata({ variables })
);
const formTransitionState = getMutationState(
updateCollectionOpts.called ||
updateCollectionWithHomepageOpts.called,
updateCollectionOpts.loading ||
updateCollectionWithHomepageOpts.loading,
updateCollectionOpts.data?.collectionUpdate.errors,
updateCollectionWithHomepageOpts.data?.collectionUpdate.errors,
updateCollectionWithHomepageOpts.data?.homepageCollectionUpdate.errors
);
const { loadNextPage, loadPreviousPage, pageInfo } = paginate(
data?.collection?.products?.pageInfo,
paginationState,
params
);
const handleCollectionRemove = (data: RemoveCollection) => {
if (data.collectionDelete.errors.length === 0) {
notify({
status: "success",
text: intl.formatMessage({
defaultMessage: "Deleted collection"
})
});
navigate(collectionListUrl());
}
};
return (
<CollectionOperations
onUpdate={handleCollectionUpdate}
onUpdateWithCollection={handleCollectioUpdateWithHomepage}
onProductAssign={handleProductAssign}
onProductUnassign={handleProductUnassign}
onRemove={handleCollectionRemove}
>
{({
updateCollection,
updateCollectionWithHomepage,
assignProduct,
unassignProduct,
removeCollection
}) => {
const handleSubmit = (
formData: CollectionDetailsPageFormData
) => {
const input: CollectionInput = {
backgroundImageAlt: formData.backgroundImageAlt,
descriptionJson: JSON.stringify(formData.description),
isPublished: formData.isPublished,
name: formData.name,
publicationDate: formData.publicationDate,
seo: {
description: formData.seoDescription,
title: formData.seoTitle
<>
<WindowTitle title={maybe(() => data.collection.name)} />
<CollectionDetailsPage
onAdd={() => openModal("assign")}
onBack={handleBack}
disabled={loading}
collection={data?.collection}
errors={updateCollectionOpts?.data?.collectionUpdate.errors || []}
isFeatured={maybe(
() => data.shop.homepageCollection.id === data.collection.id,
false
)}
onCollectionRemove={() => openModal("remove")}
onImageDelete={() => openModal("removeImage")}
onImageUpload={file =>
updateCollection({
variables: {
id,
input: {
backgroundImage: file
}
}
};
const isFeatured = data.shop.homepageCollection
? data.shop.homepageCollection.id === data.collection.id
: false;
if (formData.isFeatured !== isFeatured) {
updateCollectionWithHomepage.mutate({
homepageId: formData.isFeatured ? id : null,
id,
input
});
} else {
updateCollection.mutate({
id,
input
});
}
};
const formTransitionState = getMutationState(
updateCollection.opts.called ||
updateCollectionWithHomepage.opts.called,
updateCollection.opts.loading ||
updateCollectionWithHomepage.opts.loading,
maybe(() => updateCollection.opts.data.collectionUpdate.errors),
maybe(
() =>
updateCollectionWithHomepage.opts.data.collectionUpdate
.errors
),
maybe(
() =>
updateCollectionWithHomepage.opts.data
.homepageCollectionUpdate.errors
)
);
const { loadNextPage, loadPreviousPage, pageInfo } = paginate(
maybe(() => data.collection.products.pageInfo),
paginationState,
params
);
return (
<>
<WindowTitle title={maybe(() => data.collection.name)} />
<CollectionDetailsPage
onAdd={() => openModal("assign")}
onBack={handleBack}
disabled={loading}
collection={data?.collection}
errors={
updateCollection.opts?.data?.collectionUpdate.errors || []
}
isFeatured={maybe(
() =>
data.shop.homepageCollection.id === data.collection.id,
false
)}
onCollectionRemove={() => openModal("remove")}
onImageDelete={() => openModal("removeImage")}
onImageUpload={file =>
updateCollection.mutate({
id,
input: {
backgroundImage: file
}
})
}
onSubmit={handleSubmit}
onNextPage={loadNextPage}
onPreviousPage={loadPreviousPage}
pageInfo={pageInfo}
onProductUnassign={(productId, event) => {
event.stopPropagation();
unassignProduct.mutate({
collectionId: id,
productIds: [productId],
...paginationState
});
}}
onRowClick={id => () => navigate(productUrl(id))}
saveButtonBarState={formTransitionState}
toolbar={
<Button
color="primary"
onClick={() =>
openModal("unassign", {
ids: listElements
})
}
>
<FormattedMessage
defaultMessage="Unassign"
description="unassign product from collection, button"
/>
</Button>
}
isChecked={isSelected}
selected={listElements.length}
toggle={toggle}
toggleAll={toggleAll}
})
}
onSubmit={handleSubmit}
onNextPage={loadNextPage}
onPreviousPage={loadPreviousPage}
pageInfo={pageInfo}
onProductUnassign={(productId, event) => {
event.stopPropagation();
unassignProduct({
variables: {
collectionId: id,
productIds: [productId],
...paginationState
}
});
}}
onRowClick={id => () => navigate(productUrl(id))}
saveButtonBarState={formTransitionState}
toolbar={
<Button
color="primary"
onClick={() =>
openModal("unassign", {
ids: listElements
})
}
>
<FormattedMessage
defaultMessage="Unassign"
description="unassign product from collection, button"
/>
<AssignProductDialog
confirmButtonState={assignProduct.opts.status}
open={params.action === "assign"}
onFetch={search}
loading={result.loading}
onClose={closeModal}
onSubmit={products =>
assignProduct.mutate({
...paginationState,
collectionId: id,
productIds: products.map(product => product.id)
})
</Button>
}
isChecked={isSelected}
selected={listElements.length}
toggle={toggle}
toggleAll={toggleAll}
/>
<AssignProductDialog
confirmButtonState={assignProductOpts.status}
open={params.action === "assign"}
onFetch={search}
loading={result.loading}
onClose={closeModal}
onSubmit={products =>
assignProduct({
variables: {
...paginationState,
collectionId: id,
productIds: products.map(product => product.id)
}
})
}
products={maybe(() =>
result.data.search.edges
.map(edge => edge.node)
.filter(suggestedProduct => suggestedProduct.id)
)}
/>
<ActionDialog
confirmButtonState={removeCollectionOpts.status}
onClose={closeModal}
onConfirm={() =>
removeCollection({
variables: { id }
})
}
open={params.action === "remove"}
title={intl.formatMessage({
defaultMessage: "Delete Collection",
description: "dialog title"
})}
variant="delete"
>
<DialogContentText>
<FormattedMessage
defaultMessage="Are you sure you want to delete {collectionName}?"
values={{
collectionName: (
<strong>
{maybe(() => data.collection.name, "...")}
</strong>
)
}}
/>
</DialogContentText>
</ActionDialog>
<ActionDialog
confirmButtonState={unassignProductOpts.status}
onClose={closeModal}
onConfirm={() =>
unassignProduct({
variables: {
...paginationState,
collectionId: id,
productIds: params.ids
}
})
}
open={params.action === "unassign"}
title={intl.formatMessage({
defaultMessage: "Unassign products from collection",
description: "dialog title"
})}
>
<DialogContentText>
<FormattedMessage
defaultMessage="{counter,plural,one{Are you sure you want to unassign this product?} other{Are you sure you want to unassign {displayQuantity} products?}}"
values={{
counter: maybe(() => params.ids.length),
displayQuantity: (
<strong>{maybe(() => params.ids.length)}</strong>
)
}}
/>
</DialogContentText>
</ActionDialog>
<ActionDialog
confirmButtonState={updateCollectionOpts.status}
onClose={closeModal}
onConfirm={() =>
updateCollection({
variables: {
id,
input: {
backgroundImage: null
}
products={maybe(() =>
result.data.search.edges
.map(edge => edge.node)
.filter(suggestedProduct => suggestedProduct.id)
)}
/>
<ActionDialog
confirmButtonState={removeCollection.opts.status}
onClose={closeModal}
onConfirm={() => removeCollection.mutate({ id })}
open={params.action === "remove"}
title={intl.formatMessage({
defaultMessage: "Delete Collection",
description: "dialog title"
})}
variant="delete"
>
<DialogContentText>
<FormattedMessage
defaultMessage="Are you sure you want to delete {collectionName}?"
values={{
collectionName: (
<strong>
{maybe(() => data.collection.name, "...")}
</strong>
)
}}
/>
</DialogContentText>
</ActionDialog>
<ActionDialog
confirmButtonState={unassignProduct.opts.status}
onClose={closeModal}
onConfirm={() =>
unassignProduct.mutate({
...paginationState,
collectionId: id,
productIds: params.ids
})
}
open={params.action === "unassign"}
title={intl.formatMessage({
defaultMessage: "Unassign products from collection",
description: "dialog title"
})}
>
<DialogContentText>
<FormattedMessage
defaultMessage="{counter,plural,one{Are you sure you want to unassign this product?} other{Are you sure you want to unassign {displayQuantity} products?}}"
values={{
counter: maybe(() => params.ids.length),
displayQuantity: (
<strong>{maybe(() => params.ids.length)}</strong>
)
}}
/>
</DialogContentText>
</ActionDialog>
<ActionDialog
confirmButtonState={updateCollection.opts.status}
onClose={closeModal}
onConfirm={() =>
updateCollection.mutate({
id,
input: {
backgroundImage: null
}
})
}
open={params.action === "removeImage"}
title={intl.formatMessage({
defaultMessage: "Delete image",
description: "dialog title"
})}
variant="delete"
>
<DialogContentText>
<FormattedMessage defaultMessage="Are you sure you want to delete collection's image?" />
</DialogContentText>
</ActionDialog>
</>
);
}}
</CollectionOperations>
}
})
}
open={params.action === "removeImage"}
title={intl.formatMessage({
defaultMessage: "Delete image",
description: "dialog title"
})}
variant="delete"
>
<DialogContentText>
<FormattedMessage defaultMessage="Are you sure you want to delete collection's image?" />
</DialogContentText>
</ActionDialog>
</>
);
}}
</TypedCollectionDetailsQuery>

View file

@ -27,12 +27,10 @@ import { FormattedMessage, useIntl } from "react-intl";
import CollectionListPage from "../../components/CollectionListPage/CollectionListPage";
import {
TypedCollectionBulkDelete,
TypedCollectionBulkPublish
useCollectionBulkDelete,
useCollectionBulkPublish
} from "../../mutations";
import { useCollectionListQuery } from "../../queries";
import { CollectionBulkDelete } from "../../types/CollectionBulkDelete";
import { CollectionBulkPublish } from "../../types/CollectionBulkPublish";
import {
collectionAddUrl,
collectionListUrl,
@ -83,6 +81,40 @@ export const CollectionList: React.FC<CollectionListProps> = ({ params }) => {
variables: queryVariables
});
const [
collectionBulkDelete,
collectionBulkDeleteOpts
] = useCollectionBulkDelete({
onCompleted: data => {
if (data.collectionBulkDelete.errors.length === 0) {
notify({
status: "success",
text: intl.formatMessage(commonMessages.savedChanges)
});
refetch();
reset();
closeModal();
}
}
});
const [
collectionBulkPublish,
collectionBulkPublishOpts
] = useCollectionBulkPublish({
onCompleted: data => {
if (data.collectionBulkPublish.errors.length === 0) {
notify({
status: "success",
text: intl.formatMessage(commonMessages.savedChanges)
});
refetch();
reset();
closeModal();
}
}
});
const tabs = getFilterTabs();
const currentTab =
@ -136,225 +168,178 @@ export const CollectionList: React.FC<CollectionListProps> = ({ params }) => {
params
);
const handleCollectionBulkDelete = (data: CollectionBulkDelete) => {
if (data.collectionBulkDelete.errors.length === 0) {
notify({
status: "success",
text: intl.formatMessage(commonMessages.savedChanges)
});
refetch();
reset();
closeModal();
}
};
const handleCollectionBulkPublish = (data: CollectionBulkPublish) => {
if (data.collectionBulkPublish.errors.length === 0) {
notify({
status: "success",
text: intl.formatMessage(commonMessages.savedChanges)
});
refetch();
reset();
closeModal();
}
};
const handleSort = createSortHandler(navigate, collectionListUrl, params);
const currencySymbol = maybe(() => shop.defaultCurrency, "USD");
return (
<TypedCollectionBulkDelete onCompleted={handleCollectionBulkDelete}>
{(collectionBulkDelete, collectionBulkDeleteOpts) => (
<TypedCollectionBulkPublish onCompleted={handleCollectionBulkPublish}>
{(collectionBulkPublish, collectionBulkPublishOpts) => (
<>
<CollectionListPage
currencySymbol={currencySymbol}
currentTab={currentTab}
filterOpts={getFilterOpts(params)}
initialSearch={params.query || ""}
onSearchChange={handleSearchChange}
onFilterChange={changeFilters}
onAdd={() => navigate(collectionAddUrl)}
onAll={resetFilters}
onTabChange={handleTabChange}
onTabDelete={() => openModal("delete-search")}
onTabSave={() => openModal("save-search")}
tabs={tabs.map(tab => tab.name)}
disabled={loading}
collections={maybe(() =>
data.collections.edges.map(edge => edge.node)
)}
settings={settings}
onNextPage={loadNextPage}
onPreviousPage={loadPreviousPage}
onSort={handleSort}
onUpdateListSettings={updateListSettings}
pageInfo={pageInfo}
sort={getSortParams(params)}
onRowClick={id => () => navigate(collectionUrl(id))}
toolbar={
<>
<Button
color="primary"
onClick={() =>
openModal("unpublish", {
ids: listElements
})
}
>
<FormattedMessage
defaultMessage="Unpublish"
description="unpublish collections"
/>
</Button>
<Button
color="primary"
onClick={() =>
openModal("publish", {
ids: listElements
})
}
>
<FormattedMessage
defaultMessage="Publish"
description="publish collections"
/>
</Button>
<IconButton
color="primary"
onClick={() =>
openModal("remove", {
ids: listElements
})
}
>
<DeleteIcon />
</IconButton>
</>
}
isChecked={isSelected}
selected={listElements.length}
toggle={toggle}
toggleAll={toggleAll}
<>
<CollectionListPage
currencySymbol={currencySymbol}
currentTab={currentTab}
filterOpts={getFilterOpts(params)}
initialSearch={params.query || ""}
onSearchChange={handleSearchChange}
onFilterChange={changeFilters}
onAdd={() => navigate(collectionAddUrl)}
onAll={resetFilters}
onTabChange={handleTabChange}
onTabDelete={() => openModal("delete-search")}
onTabSave={() => openModal("save-search")}
tabs={tabs.map(tab => tab.name)}
disabled={loading}
collections={maybe(() => data.collections.edges.map(edge => edge.node))}
settings={settings}
onNextPage={loadNextPage}
onPreviousPage={loadPreviousPage}
onSort={handleSort}
onUpdateListSettings={updateListSettings}
pageInfo={pageInfo}
sort={getSortParams(params)}
onRowClick={id => () => navigate(collectionUrl(id))}
toolbar={
<>
<Button
color="primary"
onClick={() =>
openModal("unpublish", {
ids: listElements
})
}
>
<FormattedMessage
defaultMessage="Unpublish"
description="unpublish collections"
/>
<ActionDialog
open={
params.action === "publish" &&
maybe(() => params.ids.length > 0)
}
onClose={closeModal}
confirmButtonState={collectionBulkPublishOpts.status}
onConfirm={() =>
collectionBulkPublish({
variables: {
ids: params.ids,
isPublished: true
}
})
}
variant="default"
title={intl.formatMessage({
defaultMessage: "Publish collections",
description: "dialog title"
})}
>
<DialogContentText>
<FormattedMessage
defaultMessage="{counter,plural,one{Are you sure you want to publish this collection?} other{Are you sure you want to publish {displayQuantity} collections?}}"
values={{
counter: maybe(() => params.ids.length),
displayQuantity: (
<strong>{maybe(() => params.ids.length)}</strong>
)
}}
/>
</DialogContentText>
</ActionDialog>
<ActionDialog
open={
params.action === "unpublish" &&
maybe(() => params.ids.length > 0)
}
onClose={closeModal}
confirmButtonState={collectionBulkPublishOpts.status}
onConfirm={() =>
collectionBulkPublish({
variables: {
ids: params.ids,
isPublished: false
}
})
}
variant="default"
title={intl.formatMessage({
defaultMessage: "Unpublish collections",
description: "dialog title"
})}
>
<DialogContentText>
<FormattedMessage
defaultMessage="{counter,plural,one{Are you sure you want to unpublish this collection?} other{Are you sure you want to unpublish {displayQuantity} collections?}}"
values={{
counter: maybe(() => params.ids.length),
displayQuantity: (
<strong>{maybe(() => params.ids.length)}</strong>
)
}}
/>
</DialogContentText>
</ActionDialog>
<ActionDialog
open={
params.action === "remove" &&
maybe(() => params.ids.length > 0)
}
onClose={closeModal}
confirmButtonState={collectionBulkDeleteOpts.status}
onConfirm={() =>
collectionBulkDelete({
variables: {
ids: params.ids
}
})
}
variant="delete"
title={intl.formatMessage({
defaultMessage: "Delete collections",
description: "dialog title"
})}
>
<DialogContentText>
<FormattedMessage
defaultMessage="{counter,plural,one{Are you sure you want to delete this collection?} other{Are you sure you want to delete {displayQuantity} collections?}}"
values={{
counter: maybe(() => params.ids.length),
displayQuantity: (
<strong>{maybe(() => params.ids.length)}</strong>
)
}}
/>
</DialogContentText>
</ActionDialog>
<SaveFilterTabDialog
open={params.action === "save-search"}
confirmButtonState="default"
onClose={closeModal}
onSubmit={handleTabSave}
</Button>
<Button
color="primary"
onClick={() =>
openModal("publish", {
ids: listElements
})
}
>
<FormattedMessage
defaultMessage="Publish"
description="publish collections"
/>
<DeleteFilterTabDialog
open={params.action === "delete-search"}
confirmButtonState="default"
onClose={closeModal}
onSubmit={handleTabDelete}
tabName={maybe(() => tabs[currentTab - 1].name, "...")}
/>
</>
)}
</TypedCollectionBulkPublish>
)}
</TypedCollectionBulkDelete>
</Button>
<IconButton
color="primary"
onClick={() =>
openModal("remove", {
ids: listElements
})
}
>
<DeleteIcon />
</IconButton>
</>
}
isChecked={isSelected}
selected={listElements.length}
toggle={toggle}
toggleAll={toggleAll}
/>
<ActionDialog
open={params.action === "publish" && maybe(() => params.ids.length > 0)}
onClose={closeModal}
confirmButtonState={collectionBulkPublishOpts.status}
onConfirm={() =>
collectionBulkPublish({
variables: {
ids: params.ids,
isPublished: true
}
})
}
variant="default"
title={intl.formatMessage({
defaultMessage: "Publish collections",
description: "dialog title"
})}
>
<DialogContentText>
<FormattedMessage
defaultMessage="{counter,plural,one{Are you sure you want to publish this collection?} other{Are you sure you want to publish {displayQuantity} collections?}}"
values={{
counter: maybe(() => params.ids.length),
displayQuantity: <strong>{maybe(() => params.ids.length)}</strong>
}}
/>
</DialogContentText>
</ActionDialog>
<ActionDialog
open={
params.action === "unpublish" && maybe(() => params.ids.length > 0)
}
onClose={closeModal}
confirmButtonState={collectionBulkPublishOpts.status}
onConfirm={() =>
collectionBulkPublish({
variables: {
ids: params.ids,
isPublished: false
}
})
}
variant="default"
title={intl.formatMessage({
defaultMessage: "Unpublish collections",
description: "dialog title"
})}
>
<DialogContentText>
<FormattedMessage
defaultMessage="{counter,plural,one{Are you sure you want to unpublish this collection?} other{Are you sure you want to unpublish {displayQuantity} collections?}}"
values={{
counter: maybe(() => params.ids.length),
displayQuantity: <strong>{maybe(() => params.ids.length)}</strong>
}}
/>
</DialogContentText>
</ActionDialog>
<ActionDialog
open={params.action === "remove" && maybe(() => params.ids.length > 0)}
onClose={closeModal}
confirmButtonState={collectionBulkDeleteOpts.status}
onConfirm={() =>
collectionBulkDelete({
variables: {
ids: params.ids
}
})
}
variant="delete"
title={intl.formatMessage({
defaultMessage: "Delete collections",
description: "dialog title"
})}
>
<DialogContentText>
<FormattedMessage
defaultMessage="{counter,plural,one{Are you sure you want to delete this collection?} other{Are you sure you want to delete {displayQuantity} collections?}}"
values={{
counter: maybe(() => params.ids.length),
displayQuantity: <strong>{maybe(() => params.ids.length)}</strong>
}}
/>
</DialogContentText>
</ActionDialog>
<SaveFilterTabDialog
open={params.action === "save-search"}
confirmButtonState="default"
onClose={closeModal}
onSubmit={handleTabSave}
/>
<DeleteFilterTabDialog
open={params.action === "delete-search"}
confirmButtonState="default"
onClose={closeModal}
onSubmit={handleTabDelete}
tabName={maybe(() => tabs[currentTab - 1].name, "...")}
/>
</>
);
};
export default CollectionList;

View file

@ -0,0 +1,19 @@
import useForm from "@saleor/hooks/useForm";
import Decorator from "@saleor/storybook/Decorator";
import { storiesOf } from "@storybook/react";
import React from "react";
import { props } from "./fixtures";
import Metadata from "./Metadata";
const InteractiveStory: React.FC = () => {
const { change, data } = useForm(props.data, () => undefined);
return <Metadata data={data} onChange={change} />;
};
storiesOf("Generics / Metadata", module)
.addDecorator(Decorator)
.add("default", () => <Metadata {...props} />)
.add("loading", () => <Metadata {...props} data={undefined} />)
.add("interactive", () => <InteractiveStory />);

View file

@ -0,0 +1,162 @@
import useForm from "@saleor/hooks/useForm";
import Wrapper from "@test/wrapper";
import { configure } from "enzyme";
import { mount } from "enzyme";
import Adapter from "enzyme-adapter-react-16";
import React from "react";
import { props } from "./fixtures";
import Metadata from "./Metadata";
configure({ adapter: new Adapter() });
const expandButton = 'data-test="expand"';
const Component: React.FC = () => {
const { change, data } = useForm(props.data, jest.fn());
return (
<Wrapper>
<Metadata data={data} onChange={change} />
</Wrapper>
);
};
describe("Metadata editor", () => {
it("can expand field", () => {
const wrapper = mount(<Component />);
const expandDataEl = "data-test-expanded";
expect(
wrapper
.find(`[${expandDataEl}]`)
.first()
.prop(expandDataEl)
).toEqual(false);
wrapper
.find(`[${expandButton}]`)
.first()
.simulate("click");
expect(
wrapper
.find(`[${expandDataEl}]`)
.first()
.prop(expandDataEl)
).toEqual(true);
});
it("can edit field name", () => {
const wrapper = mount(<Component />);
const inputNameSelector = '[name="name:1"] input';
// Expand to reveal fields
wrapper
.find(`[${expandButton}]`)
.first()
.simulate("click");
expect(
wrapper
.find(inputNameSelector)
.first()
.prop("value")
).toEqual(props.data.metadata[1].key);
wrapper
.find(inputNameSelector)
.first()
.simulate("change", { target: { name: "name:1", value: "x" } });
expect(
wrapper
.find(inputNameSelector)
.first()
.prop("value")
).toEqual("x");
});
it("can edit field value", () => {
const wrapper = mount(<Component />);
const inputNameSelector = '[name="value:1"] textarea';
// Expand to reveal fields
wrapper
.find(`[${expandButton}]`)
.first()
.simulate("click");
expect(
wrapper
.find(inputNameSelector)
.first()
.prop("value")
).toEqual(props.data.metadata[1].value);
wrapper
.find(inputNameSelector)
.first()
.simulate("change", { target: { name: "value:1", value: "x" } });
expect(
wrapper
.find(inputNameSelector)
.first()
.prop("value")
).toEqual("x");
});
it("can delete field", () => {
const wrapper = mount(<Component />);
const fieldSelector = 'tr[data-test="field"]';
const deleteButtonSelector = '[data-test="deleteField"]';
// Expand to reveal fields
wrapper
.find(`[${expandButton}]`)
.first()
.simulate("click");
expect(wrapper.find(fieldSelector).length).toEqual(
props.data.metadata.length
);
wrapper
.find(deleteButtonSelector)
.first()
.simulate("click");
expect(wrapper.find(fieldSelector).length).toEqual(
props.data.metadata.length - 1
);
});
it("can add field", () => {
const wrapper = mount(<Component />);
const fieldSelector = 'tr[data-test="field"]';
const addButtonSelector = '[data-test="addField"]';
// Expand to reveal fields
wrapper
.find(`[${expandButton}]`)
.first()
.simulate("click");
expect(wrapper.find(fieldSelector).length).toEqual(
props.data.metadata.length
);
wrapper
.find(addButtonSelector)
.first()
.simulate("click");
expect(wrapper.find(fieldSelector).length).toEqual(
props.data.metadata.length + 1
);
});
});

View file

@ -0,0 +1,73 @@
import { ChangeEvent } from "@saleor/hooks/useForm";
import { MetadataInput } from "@saleor/types/globalTypes";
import { removeAtIndex, updateAtIndex } from "@saleor/utils/lists";
import React from "react";
import CardSpacer from "../CardSpacer";
import MetadataCard, { MetadataCardProps } from "./MetadataCard";
import { EventDataAction, EventDataField } from "./types";
import { getDataKey, parseEventData } from "./utils";
export interface MetadataProps
extends Omit<MetadataCardProps, "data" | "isPrivate"> {
data: Record<"metadata" | "privateMetadata", MetadataInput[]>;
}
const Metadata: React.FC<MetadataProps> = ({ data, onChange }) => {
const change = (event: ChangeEvent, isPrivate: boolean) => {
const { action, field, fieldIndex, value } = parseEventData(event);
const key = getDataKey(isPrivate);
const dataToUpdate = data[key];
onChange({
target: {
name: key,
value:
action === EventDataAction.update
? updateAtIndex(
{
...dataToUpdate[fieldIndex],
key:
field === EventDataField.name
? value
: dataToUpdate[fieldIndex].key,
value:
field === EventDataField.value
? value
: dataToUpdate[fieldIndex].value
},
dataToUpdate,
fieldIndex
)
: action === EventDataAction.add
? [
...dataToUpdate,
{
key: "",
value: ""
}
]
: removeAtIndex(dataToUpdate, fieldIndex)
}
});
};
return (
<>
<MetadataCard
data={data?.metadata}
isPrivate={false}
onChange={event => change(event, false)}
/>
<CardSpacer />
<MetadataCard
data={data?.privateMetadata}
isPrivate={true}
onChange={event => change(event, true)}
/>
</>
);
};
Metadata.displayName = "Metadata";
export default Metadata;

View file

@ -0,0 +1,232 @@
import emptyMetadata from "@assets/images/empty-metadata.svg";
import Button from "@material-ui/core/Button";
import Card from "@material-ui/core/Card";
import CardActions from "@material-ui/core/CardActions";
import CardContent from "@material-ui/core/CardContent";
import IconButton from "@material-ui/core/IconButton";
import Table from "@material-ui/core/Table";
import TableBody from "@material-ui/core/TableBody";
import TableCell from "@material-ui/core/TableCell";
import TableHead from "@material-ui/core/TableHead";
import TableRow from "@material-ui/core/TableRow";
import TextField from "@material-ui/core/TextField";
import Typography from "@material-ui/core/Typography";
import ToggleIcon from "@material-ui/icons/ArrowDropDown";
import DeleteIcon from "@material-ui/icons/Delete";
import { FormChange } from "@saleor/hooks/useForm";
import { MetadataInput } from "@saleor/types/globalTypes";
import React, { useEffect } from "react";
import SVG from "react-inlinesvg";
import { useIntl } from "react-intl";
import { FormattedMessage } from "react-intl";
import CardTitle from "../CardTitle";
import Skeleton from "../Skeleton";
import useStyles from "./styles";
import { EventDataAction, EventDataField } from "./types";
export interface MetadataCardProps {
data: MetadataInput[];
isPrivate: boolean;
onChange: FormChange;
}
export const nameSeparator = ":";
export const nameInputPrefix = EventDataField.name;
export const valueInputPrefix = EventDataField.value;
const MetadataCard: React.FC<MetadataCardProps> = ({
data,
isPrivate,
onChange
}) => {
const intl = useIntl();
const loaded = React.useRef(false);
const [expanded, setExpanded] = React.useState(true);
const classes = useStyles({});
useEffect(() => {
if (data !== undefined) {
loaded.current = true;
if (data.length > 0) {
setExpanded(false);
}
}
}, [data === undefined]);
return (
<Card
data-test="metadataEditor"
data-test-is-private={isPrivate}
data-test-expanded={expanded}
>
<CardTitle
title={
isPrivate
? intl.formatMessage({
defaultMessage: "Private Metadata",
description: "header"
})
: intl.formatMessage({
defaultMessage: "Metadata",
description: "header"
})
}
/>
{data === undefined ? (
<CardContent>
<Skeleton />
</CardContent>
) : (
<>
<CardContent className={classes.content}>
{data.length > 0 && (
<div className={classes.togglable}>
<Typography color="textSecondary" variant="body2">
<FormattedMessage
defaultMessage="{number,plural,one{{number} Field} other{{number} Fields}}"
description="number of metadata fields in model"
values={{
number: data.length
}}
/>
</Typography>
<IconButton
data-test="expand"
onClick={() => setExpanded(!expanded)}
>
<ToggleIcon />
</IconButton>
</div>
)}
</CardContent>
{expanded && (
<>
{data.length === 0 ? (
<div className={classes.emptyContainer}>
<SVG className={classes.emptyImage} src={emptyMetadata} />
<Typography color="textSecondary">
{isPrivate ? (
<FormattedMessage
defaultMessage="There is no private metadata created for this element."
description="empty metadata text"
/>
) : (
<FormattedMessage
defaultMessage="There is no metadata created for this element."
description="empty metadata text"
/>
)}
</Typography>
<Typography color="textSecondary">
<FormattedMessage
defaultMessage="Use the button below to add new metadata field"
description="empty metadata text"
/>
</Typography>
</div>
) : (
<Table className={classes.table}>
<TableHead>
<TableRow>
<TableCell className={classes.colNameHeader}>
<FormattedMessage
defaultMessage="Field"
description="metadata field name, header"
/>
</TableCell>
<TableCell className={classes.colValue}>
<FormattedMessage
defaultMessage="Value"
description="metadata field value, header"
/>
</TableCell>
<TableCell className={classes.colActionHeader}>
<FormattedMessage
defaultMessage="Actions"
description="table action"
/>
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{data.map((field, fieldIndex) => (
<TableRow data-test="field" key={fieldIndex}>
<TableCell className={classes.colName}>
<TextField
InputProps={{
classes: {
input: classes.nameInput
}
}}
name={`${nameInputPrefix}${nameSeparator}${fieldIndex}`}
fullWidth
onChange={onChange}
value={field.key}
/>
</TableCell>
<TableCell className={classes.colValue}>
<TextField
InputProps={{
classes: {
root: classes.input
}
}}
multiline
name={`${valueInputPrefix}${nameSeparator}${fieldIndex}`}
fullWidth
onChange={onChange}
value={field.value}
/>
</TableCell>
<TableCell className={classes.colAction}>
<IconButton
color="primary"
data-test="deleteField"
data-test-id={fieldIndex}
onClick={() =>
onChange({
target: {
name: EventDataAction.delete,
value: fieldIndex
}
})
}
>
<DeleteIcon />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<CardActions>
<Button
color="primary"
data-test="addField"
onClick={() =>
onChange({
target: {
name: EventDataAction.add,
value: null
}
})
}
>
<FormattedMessage
defaultMessage="Add Field"
description="add metadata field,button"
/>
</Button>
</CardActions>
</>
)}
</>
)}
</Card>
);
};
MetadataCard.displayName = "MetadataCard";
export default MetadataCard;

View file

@ -0,0 +1,22 @@
import { MetadataProps } from "./Metadata";
export const props: MetadataProps = {
data: {
metadata: [
{
key: "key",
value: "value"
},
{
key: "key2",
value: '{\n "jsonValue": "some-value"\n}'
},
{
key: "key3",
value: "some-value"
}
],
privateMetadata: []
},
onChange: () => undefined
};

View file

@ -0,0 +1,5 @@
export * from "./Metadata";
export * from "./MetadataCard";
export * from "./types";
export { default } from "./Metadata";
export { default as MetadataCard } from "./MetadataCard";

View file

@ -0,0 +1,66 @@
import makeStyles from "@material-ui/core/styles/makeStyles";
const useStyles = makeStyles(
theme => {
const colAction: React.CSSProperties = {
textAlign: "right",
width: 130
};
const colName: React.CSSProperties = {
width: 220
};
return {
colAction: {
"&:last-child": {
...colAction,
paddingRight: theme.spacing()
}
},
colActionHeader: {
...colAction
},
colName: {
...colName,
verticalAlign: "top"
},
colNameHeader: {
...colName
},
colValue: {},
content: {
paddingBottom: 0,
paddingTop: theme.spacing()
},
emptyContainer: {
borderBottom: `1px solid ${theme.palette.divider}`,
paddingBottom: theme.spacing(4),
paddingTop: theme.spacing(3),
textAlign: "center"
},
emptyImage: {
display: "block",
marginBottom: theme.spacing(2)
},
input: {
padding: theme.spacing(0.5, 2)
},
nameInput: {
padding: `13px 16px`
},
table: {
tableLayout: "fixed"
},
togglable: {
alignItems: "center",
display: "flex",
justifyContent: "space-between"
}
};
},
{
name: "Metadata"
}
);
export default useStyles;

View file

@ -0,0 +1,21 @@
import { MetadataInput } from "@saleor/types/globalTypes";
export enum EventDataAction {
add = "add",
delete = "delete",
update = "update"
}
export enum EventDataField {
name = "name",
value = "value"
}
export interface EventData {
action: EventDataAction;
field: EventDataField | null;
fieldIndex: number | null;
value: string;
}
export interface MetadataFormData {
metadata: MetadataInput[];
privateMetadata: MetadataInput[];
}

View file

@ -0,0 +1,42 @@
import { ChangeEvent } from "@saleor/hooks/useForm";
import { nameSeparator } from "./MetadataCard";
import { EventData, EventDataAction, EventDataField } from "./types";
export function parseEventData(event: ChangeEvent): EventData {
let action: EventDataAction;
let field: EventDataField = null;
let fieldIndex: number = null;
let value: string = null;
if (event.target.name.includes(EventDataField.name)) {
action = EventDataAction.update;
field = EventDataField.name;
fieldIndex = parseInt(event.target.name.split(nameSeparator)[1], 0);
value = event.target.value;
}
if (event.target.name.includes(EventDataField.value)) {
action = EventDataAction.update;
field = EventDataField.value;
fieldIndex = parseInt(event.target.name.split(nameSeparator)[1], 0);
value = event.target.value;
}
if (event.target.name === EventDataAction.add) {
action = EventDataAction.add;
}
if (event.target.name === EventDataAction.delete) {
action = EventDataAction.delete;
fieldIndex = event.target.value;
}
return {
action,
field,
fieldIndex,
value
};
}
export function getDataKey(isPrivate: boolean) {
return isPrivate ? "privateMetadata" : "metadata";
}

View file

@ -1,5 +1,7 @@
import gql from "graphql-tag";
import { metadataFragment } from "./metadata";
export const attributeFragment = gql`
fragment AttributeFragment on Attribute {
id
@ -13,8 +15,10 @@ export const attributeFragment = gql`
export const attributeDetailsFragment = gql`
${attributeFragment}
${metadataFragment}
fragment AttributeDetailsFragment on Attribute {
...AttributeFragment
...MetadataFragment
availableInGrid
inputType
storefrontSearchPosition

View file

@ -1,5 +1,7 @@
import gql from "graphql-tag";
import { metadataFragment } from "./metadata";
export const categoryFragment = gql`
fragment CategoryFragment on Category {
id
@ -13,8 +15,10 @@ export const categoryFragment = gql`
}
`;
export const categoryDetailsFragment = gql`
${metadataFragment}
fragment CategoryDetailsFragment on Category {
id
...MetadataFragment
backgroundImage {
alt
url

View file

@ -1,5 +1,7 @@
import gql from "graphql-tag";
import { metadataFragment } from "./metadata";
export const collectionFragment = gql`
fragment CollectionFragment on Collection {
id
@ -10,8 +12,10 @@ export const collectionFragment = gql`
export const collectionDetailsFragment = gql`
${collectionFragment}
${metadataFragment}
fragment CollectionDetailsFragment on Collection {
...CollectionFragment
...MetadataFragment
backgroundImage {
alt
url

View file

@ -134,3 +134,10 @@ export const pluginErrorFragment = gql`
field
}
`;
export const metadataErrorFragment = gql`
fragment MetadataErrorFragment on MetadataError {
code
field
}
`;

View file

@ -5,7 +5,7 @@ export const metadataFragment = gql`
key
value
}
fragment Metadata on ObjectWithMetadata {
fragment MetadataFragment on ObjectWithMetadata {
metadata {
...MetadataItem
}

View file

@ -1,6 +1,7 @@
import gql from "graphql-tag";
import { attributeFragment } from "./attributes";
import { metadataFragment } from "./metadata";
export const productTypeFragment = gql`
fragment ProductTypeFragment on ProductType {
@ -18,8 +19,10 @@ export const productTypeFragment = gql`
export const productTypeDetailsFragment = gql`
${attributeFragment}
${productTypeFragment}
${metadataFragment}
fragment ProductTypeDetailsFragment on ProductType {
...ProductTypeFragment
...MetadataFragment
productAttributes {
...AttributeFragment
}

View file

@ -1,5 +1,6 @@
import gql from "graphql-tag";
import { metadataFragment } from "./metadata";
import { weightFragment } from "./weight";
export const stockFragment = gql`
@ -105,8 +106,10 @@ export const productFragmentDetails = gql`
${productVariantAttributesFragment}
${stockFragment}
${weightFragment}
${metadataFragment}
fragment Product on Product {
...ProductVariantAttributesFragment
...MetadataFragment
name
descriptionJson
seoTitle
@ -181,8 +184,10 @@ export const fragmentVariant = gql`
${fragmentProductImage}
${stockFragment}
${weightFragment}
${metadataFragment}
fragment ProductVariant on ProductVariant {
id
...MetadataFragment
attributes {
attribute {
id

View file

@ -8,6 +8,18 @@ import { AttributeInputTypeEnum, AttributeValueType } from "./../../types/global
// GraphQL fragment: AttributeDetailsFragment
// ====================================================
export interface AttributeDetailsFragment_metadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface AttributeDetailsFragment_privateMetadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface AttributeDetailsFragment_values {
__typename: "AttributeValue";
id: string;
@ -24,6 +36,8 @@ export interface AttributeDetailsFragment {
visibleInStorefront: boolean;
filterableInDashboard: boolean;
filterableInStorefront: boolean;
metadata: (AttributeDetailsFragment_metadata | null)[];
privateMetadata: (AttributeDetailsFragment_privateMetadata | null)[];
availableInGrid: boolean;
inputType: AttributeInputTypeEnum | null;
storefrontSearchPosition: number;

View file

@ -6,6 +6,18 @@
// GraphQL fragment: CategoryDetailsFragment
// ====================================================
export interface CategoryDetailsFragment_metadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface CategoryDetailsFragment_privateMetadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface CategoryDetailsFragment_backgroundImage {
__typename: "Image";
alt: string | null;
@ -20,6 +32,8 @@ export interface CategoryDetailsFragment_parent {
export interface CategoryDetailsFragment {
__typename: "Category";
id: string;
metadata: (CategoryDetailsFragment_metadata | null)[];
privateMetadata: (CategoryDetailsFragment_privateMetadata | null)[];
backgroundImage: CategoryDetailsFragment_backgroundImage | null;
name: string;
descriptionJson: any;

View file

@ -6,6 +6,18 @@
// GraphQL fragment: CollectionDetailsFragment
// ====================================================
export interface CollectionDetailsFragment_metadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface CollectionDetailsFragment_privateMetadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface CollectionDetailsFragment_backgroundImage {
__typename: "Image";
alt: string | null;
@ -17,6 +29,8 @@ export interface CollectionDetailsFragment {
id: string;
isPublished: boolean;
name: string;
metadata: (CollectionDetailsFragment_metadata | null)[];
privateMetadata: (CollectionDetailsFragment_privateMetadata | null)[];
backgroundImage: CollectionDetailsFragment_backgroundImage | null;
descriptionJson: any;
publicationDate: any | null;

View file

@ -0,0 +1,15 @@
/* tslint:disable */
/* eslint-disable */
// This file was automatically generated and should not be edited.
import { MetadataErrorCode } from "./../../types/globalTypes";
// ====================================================
// GraphQL fragment: MetadataErrorFragment
// ====================================================
export interface MetadataErrorFragment {
__typename: "MetadataError";
code: MetadataErrorCode;
field: string | null;
}

View file

@ -0,0 +1,25 @@
/* tslint:disable */
/* eslint-disable */
// This file was automatically generated and should not be edited.
// ====================================================
// GraphQL fragment: MetadataFragment
// ====================================================
export interface MetadataFragment_metadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface MetadataFragment_privateMetadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface MetadataFragment {
__typename: "ServiceAccount" | "App" | "Product" | "ProductType" | "Attribute" | "Category" | "ProductVariant" | "DigitalContent" | "Collection" | "User" | "Checkout" | "Order" | "Fulfillment" | "Invoice";
metadata: (MetadataFragment_metadata | null)[];
privateMetadata: (MetadataFragment_privateMetadata | null)[];
}

View file

@ -93,6 +93,18 @@ export interface Product_pricing {
priceRangeUndiscounted: Product_pricing_priceRangeUndiscounted | null;
}
export interface Product_metadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface Product_privateMetadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface Product_category {
__typename: "Category";
id: string;
@ -180,6 +192,8 @@ export interface Product {
attributes: Product_attributes[];
productType: Product_productType;
pricing: Product_pricing | null;
metadata: (Product_metadata | null)[];
privateMetadata: (Product_privateMetadata | null)[];
name: string;
descriptionJson: any;
seoTitle: string | null;

View file

@ -14,6 +14,18 @@ export interface ProductTypeDetailsFragment_taxType {
taxCode: string | null;
}
export interface ProductTypeDetailsFragment_metadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface ProductTypeDetailsFragment_privateMetadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface ProductTypeDetailsFragment_productAttributes {
__typename: "Attribute";
id: string;
@ -47,6 +59,8 @@ export interface ProductTypeDetailsFragment {
hasVariants: boolean;
isShippingRequired: boolean;
taxType: ProductTypeDetailsFragment_taxType | null;
metadata: (ProductTypeDetailsFragment_metadata | null)[];
privateMetadata: (ProductTypeDetailsFragment_privateMetadata | null)[];
productAttributes: (ProductTypeDetailsFragment_productAttributes | null)[] | null;
variantAttributes: (ProductTypeDetailsFragment_variantAttributes | null)[] | null;
weight: ProductTypeDetailsFragment_weight | null;

View file

@ -8,6 +8,18 @@ import { WeightUnitsEnum } from "./../../types/globalTypes";
// GraphQL fragment: ProductVariant
// ====================================================
export interface ProductVariant_metadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface ProductVariant_privateMetadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface ProductVariant_attributes_attribute_values {
__typename: "AttributeValue";
id: string;
@ -114,6 +126,8 @@ export interface ProductVariant_weight {
export interface ProductVariant {
__typename: "ProductVariant";
id: string;
metadata: (ProductVariant_metadata | null)[];
privateMetadata: (ProductVariant_privateMetadata | null)[];
attributes: ProductVariant_attributes[];
costPrice: ProductVariant_costPrice | null;
images: (ProductVariant_images | null)[] | null;

View file

@ -5,6 +5,8 @@ import Container from "@saleor/components/Container";
import ControlledSwitch from "@saleor/components/ControlledSwitch";
import Form from "@saleor/components/Form";
import Grid from "@saleor/components/Grid";
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";
@ -13,6 +15,8 @@ import { sectionNames } from "@saleor/intl";
import { maybe } from "@saleor/misc";
import { ListActions, ReorderEvent, UserError } from "@saleor/types";
import { AttributeTypeEnum, WeightUnitsEnum } from "@saleor/types/globalTypes";
import { mapMetadataItemToInput } from "@saleor/utils/maps";
import useMetadataChangeTrigger from "@saleor/utils/metadata/useMetadataChangeTrigger";
import React from "react";
import { useIntl } from "react-intl";
@ -30,7 +34,7 @@ interface ChoiceType {
value: string;
}
export interface ProductTypeForm {
export interface ProductTypeForm extends MetadataFormData {
name: string;
hasVariants: boolean;
isShippingRequired: boolean;
@ -92,6 +96,12 @@ const ProductTypeDetailsPage: React.FC<ProductTypeDetailsPageProps> = ({
onSubmit
}) => {
const intl = useIntl();
const {
isMetadataModified,
isPrivateMetadataModified,
makeChangeHandler: makeMetadataChangeHandler
} = useMetadataChangeTrigger();
const [taxTypeDisplayName, setTaxTypeDisplayName] = useStateFromProps(
maybe(() => productType.taxType.description, "")
);
@ -104,7 +114,9 @@ const ProductTypeDetailsPage: React.FC<ProductTypeDetailsPageProps> = ({
maybe(() => productType.isShippingRequired) !== undefined
? productType.isShippingRequired
: false,
metadata: productType?.metadata?.map(mapMetadataItemToInput),
name: maybe(() => productType.name) !== undefined ? productType.name : "",
privateMetadata: productType?.privateMetadata?.map(mapMetadataItemToInput),
productAttributes:
maybe(() => productType.productAttributes) !== undefined
? productType.productAttributes.map(attribute => ({
@ -122,97 +134,117 @@ const ProductTypeDetailsPage: React.FC<ProductTypeDetailsPageProps> = ({
: [],
weight: maybe(() => productType.weight.value)
};
const handleSubmit = (data: ProductTypeForm) => {
const metadata = isMetadataModified ? data.metadata : undefined;
const privateMetadata = isPrivateMetadataModified
? data.privateMetadata
: undefined;
onSubmit({
...data,
metadata,
privateMetadata
});
};
return (
<Form initial={formInitialData} onSubmit={onSubmit} confirmLeave>
{({ change, data, hasChanged, submit }) => (
<Container>
<AppHeader onBack={onBack}>
{intl.formatMessage(sectionNames.productTypes)}
</AppHeader>
<PageHeader title={pageTitle} />
<Grid>
<div>
<ProductTypeDetails
data={data}
disabled={disabled}
errors={errors}
onChange={change}
/>
<CardSpacer />
<ProductTypeTaxes
disabled={disabled}
data={data}
taxTypes={taxTypes}
taxTypeDisplayName={taxTypeDisplayName}
onChange={event =>
handleTaxTypeChange(
event,
taxTypes,
change,
setTaxTypeDisplayName
)
}
/>
<CardSpacer />
<ProductTypeAttributes
attributes={maybe(() => productType.productAttributes)}
disabled={disabled}
type={AttributeTypeEnum.PRODUCT}
onAttributeAssign={onAttributeAdd}
onAttributeClick={onAttributeClick}
onAttributeReorder={(event: ReorderEvent) =>
onAttributeReorder(event, AttributeTypeEnum.PRODUCT)
}
onAttributeUnassign={onAttributeUnassign}
{...productAttributeList}
/>
<CardSpacer />
<ControlledSwitch
checked={data.hasVariants}
disabled={disabled}
label={intl.formatMessage({
defaultMessage: "Product type uses Variant Attributes",
description: "switch button"
})}
name="hasVariants"
onChange={event => onHasVariantsToggle(event.target.value)}
/>
{data.hasVariants && (
<>
<CardSpacer />
<ProductTypeAttributes
attributes={maybe(() => productType.variantAttributes)}
disabled={disabled}
type={AttributeTypeEnum.VARIANT}
onAttributeAssign={onAttributeAdd}
onAttributeClick={onAttributeClick}
onAttributeReorder={(event: ReorderEvent) =>
onAttributeReorder(event, AttributeTypeEnum.VARIANT)
}
onAttributeUnassign={onAttributeUnassign}
{...variantAttributeList}
/>
</>
)}
</div>
<div>
<ProductTypeShipping
disabled={disabled}
data={data}
weightUnit={productType?.weight?.unit || defaultWeightUnit}
onChange={change}
/>
</div>
</Grid>
<SaveButtonBar
onCancel={onBack}
onDelete={onDelete}
onSave={submit}
disabled={disabled || !hasChanged}
state={saveButtonBarState}
/>
</Container>
)}
<Form initial={formInitialData} onSubmit={handleSubmit} confirmLeave>
{({ change, data, hasChanged, submit }) => {
const changeMetadata = makeMetadataChangeHandler(change);
return (
<Container>
<AppHeader onBack={onBack}>
{intl.formatMessage(sectionNames.productTypes)}
</AppHeader>
<PageHeader title={pageTitle} />
<Grid>
<div>
<ProductTypeDetails
data={data}
disabled={disabled}
errors={errors}
onChange={change}
/>
<CardSpacer />
<ProductTypeTaxes
disabled={disabled}
data={data}
taxTypes={taxTypes}
taxTypeDisplayName={taxTypeDisplayName}
onChange={event =>
handleTaxTypeChange(
event,
taxTypes,
change,
setTaxTypeDisplayName
)
}
/>
<CardSpacer />
<ProductTypeAttributes
attributes={maybe(() => productType.productAttributes)}
disabled={disabled}
type={AttributeTypeEnum.PRODUCT}
onAttributeAssign={onAttributeAdd}
onAttributeClick={onAttributeClick}
onAttributeReorder={(event: ReorderEvent) =>
onAttributeReorder(event, AttributeTypeEnum.PRODUCT)
}
onAttributeUnassign={onAttributeUnassign}
{...productAttributeList}
/>
<CardSpacer />
<ControlledSwitch
checked={data.hasVariants}
disabled={disabled}
label={intl.formatMessage({
defaultMessage: "Product type uses Variant Attributes",
description: "switch button"
})}
name="hasVariants"
onChange={event => onHasVariantsToggle(event.target.value)}
/>
{data.hasVariants && (
<>
<CardSpacer />
<ProductTypeAttributes
attributes={maybe(() => productType.variantAttributes)}
disabled={disabled}
type={AttributeTypeEnum.VARIANT}
onAttributeAssign={onAttributeAdd}
onAttributeClick={onAttributeClick}
onAttributeReorder={(event: ReorderEvent) =>
onAttributeReorder(event, AttributeTypeEnum.VARIANT)
}
onAttributeUnassign={onAttributeUnassign}
{...variantAttributeList}
/>
</>
)}
<CardSpacer />
<Metadata data={data} onChange={changeMetadata} />
</div>
<div>
<ProductTypeShipping
disabled={disabled}
data={data}
weightUnit={productType?.weight?.unit || defaultWeightUnit}
onChange={change}
/>
</div>
</Grid>
<SaveButtonBar
onCancel={onBack}
onDelete={onDelete}
onSave={submit}
disabled={disabled || !hasChanged}
state={saveButtonBarState}
/>
</Container>
);
}}
</Form>
);
};

View file

@ -12,7 +12,6 @@ import {
ProductTypeAttributeReorderMutation,
TypedAssignAttributeMutation,
TypedProductTypeDeleteMutation,
TypedProductTypeUpdateMutation,
TypedUnassignAttributeMutation
} from "../mutations";
import {
@ -27,10 +26,6 @@ import {
ProductTypeDelete,
ProductTypeDeleteVariables
} from "../types/ProductTypeDelete";
import {
ProductTypeUpdate,
ProductTypeUpdateVariables
} from "../types/ProductTypeUpdate";
import {
UnassignAttribute,
UnassignAttributeVariables
@ -75,17 +70,12 @@ interface ProductTypeOperationsProps {
ProductTypeAttributeReorder,
ProductTypeAttributeReorderVariables
>;
updateProductType: PartialMutationProviderOutput<
ProductTypeUpdate,
ProductTypeUpdateVariables
>;
}) => React.ReactNode;
productType: ProductTypeDetailsFragment;
onAssignAttribute: (data: AssignAttribute) => void;
onUnassignAttribute: (data: UnassignAttribute) => void;
onProductTypeAttributeReorder: (data: ProductTypeAttributeReorder) => void;
onProductTypeDelete: (data: ProductTypeDelete) => void;
onProductTypeUpdate: (data: ProductTypeUpdate) => void;
}
const ProductTypeOperations: React.FC<ProductTypeOperationsProps> = ({
@ -94,85 +84,72 @@ const ProductTypeOperations: React.FC<ProductTypeOperationsProps> = ({
onAssignAttribute,
onUnassignAttribute,
onProductTypeAttributeReorder,
onProductTypeDelete,
onProductTypeUpdate
onProductTypeDelete
}) => (
<TypedProductTypeDeleteMutation onCompleted={onProductTypeDelete}>
{(...deleteProductType) => (
<TypedProductTypeUpdateMutation onCompleted={onProductTypeUpdate}>
{(...updateProductType) => (
<TypedAssignAttributeMutation onCompleted={onAssignAttribute}>
{(...assignAttribute) => (
<TypedUnassignAttributeMutation onCompleted={onUnassignAttribute}>
{(...unassignAttribute) => (
<ProductTypeAttributeReorderMutation
onCompleted={onProductTypeAttributeReorder}
>
{(
reorderAttributeMutation,
reorderAttributeMutationResult
) => {
const reorderAttributeMutationFn: MutationFunction<
ProductTypeAttributeReorder,
ProductTypeAttributeReorderVariables
> = opts => {
const optimisticResponse: ProductTypeAttributeReorder = {
productTypeReorderAttributes: {
__typename: "ProductTypeReorderAttributes" as "ProductTypeReorderAttributes",
errors: [],
productType: {
...productType,
productAttributes:
opts.variables.type ===
AttributeTypeEnum.PRODUCT
? moveAttribute(
productType.productAttributes,
opts.variables.move
)
: productType.productAttributes,
variantAttributes:
opts.variables.type ===
AttributeTypeEnum.VARIANT
? moveAttribute(
productType.variantAttributes,
opts.variables.move
)
: productType.variantAttributes
}
}
};
return reorderAttributeMutation({
...opts,
optimisticResponse
});
};
<TypedAssignAttributeMutation onCompleted={onAssignAttribute}>
{(...assignAttribute) => (
<TypedUnassignAttributeMutation onCompleted={onUnassignAttribute}>
{(...unassignAttribute) => (
<ProductTypeAttributeReorderMutation
onCompleted={onProductTypeAttributeReorder}
>
{(reorderAttributeMutation, reorderAttributeMutationResult) => {
const reorderAttributeMutationFn: MutationFunction<
ProductTypeAttributeReorder,
ProductTypeAttributeReorderVariables
> = opts => {
const optimisticResponse: ProductTypeAttributeReorder = {
productTypeReorderAttributes: {
__typename: "ProductTypeReorderAttributes" as "ProductTypeReorderAttributes",
errors: [],
productType: {
...productType,
productAttributes:
opts.variables.type === AttributeTypeEnum.PRODUCT
? moveAttribute(
productType.productAttributes,
opts.variables.move
)
: productType.productAttributes,
variantAttributes:
opts.variables.type === AttributeTypeEnum.VARIANT
? moveAttribute(
productType.variantAttributes,
opts.variables.move
)
: productType.variantAttributes
}
}
};
return reorderAttributeMutation({
...opts,
optimisticResponse
});
};
return children({
assignAttribute: getMutationProviderData(
...assignAttribute
),
deleteProductType: getMutationProviderData(
...deleteProductType
),
reorderAttribute: getMutationProviderData(
reorderAttributeMutationFn,
reorderAttributeMutationResult
),
unassignAttribute: getMutationProviderData(
...unassignAttribute
),
updateProductType: getMutationProviderData(
...updateProductType
)
});
}}
</ProductTypeAttributeReorderMutation>
)}
</TypedUnassignAttributeMutation>
return children({
assignAttribute: getMutationProviderData(
...assignAttribute
),
deleteProductType: getMutationProviderData(
...deleteProductType
),
reorderAttribute: getMutationProviderData(
reorderAttributeMutationFn,
reorderAttributeMutationResult
),
unassignAttribute: getMutationProviderData(
...unassignAttribute
)
});
}}
</ProductTypeAttributeReorderMutation>
)}
</TypedAssignAttributeMutation>
</TypedUnassignAttributeMutation>
)}
</TypedProductTypeUpdateMutation>
</TypedAssignAttributeMutation>
)}
</TypedProductTypeDeleteMutation>
);

View file

@ -550,7 +550,15 @@ export const productType: ProductTypeDetails_productType = {
hasVariants: false,
id: "UHJvZHVjdFR5cGU6NQ==",
isShippingRequired: false,
metadata: [
{
__typename: "MetadataItem",
key: "integration.id",
value: "100023123"
}
],
name: "E-books",
privateMetadata: [],
productAttributes: [
{
__typename: "Attribute" as "Attribute",

View file

@ -1,4 +1,5 @@
import { productTypeDetailsFragment } from "@saleor/fragments/productTypes";
import makeMutation from "@saleor/hooks/makeMutation";
import gql from "graphql-tag";
import { TypedMutation } from "../mutations";
@ -78,7 +79,7 @@ export const productTypeUpdateMutation = gql`
}
}
`;
export const TypedProductTypeUpdateMutation = TypedMutation<
export const useProductTypeUpdateMutation = makeMutation<
ProductTypeUpdate,
ProductTypeUpdateVariables
>(productTypeUpdateMutation);

View file

@ -20,6 +20,18 @@ export interface AssignAttribute_attributeAssign_productType_taxType {
taxCode: string | null;
}
export interface AssignAttribute_attributeAssign_productType_metadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface AssignAttribute_attributeAssign_productType_privateMetadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface AssignAttribute_attributeAssign_productType_productAttributes {
__typename: "Attribute";
id: string;
@ -53,6 +65,8 @@ export interface AssignAttribute_attributeAssign_productType {
hasVariants: boolean;
isShippingRequired: boolean;
taxType: AssignAttribute_attributeAssign_productType_taxType | null;
metadata: (AssignAttribute_attributeAssign_productType_metadata | null)[];
privateMetadata: (AssignAttribute_attributeAssign_productType_privateMetadata | null)[];
productAttributes: (AssignAttribute_attributeAssign_productType_productAttributes | null)[] | null;
variantAttributes: (AssignAttribute_attributeAssign_productType_variantAttributes | null)[] | null;
weight: AssignAttribute_attributeAssign_productType_weight | null;

View file

@ -20,6 +20,18 @@ export interface ProductTypeAttributeReorder_productTypeReorderAttributes_produc
taxCode: string | null;
}
export interface ProductTypeAttributeReorder_productTypeReorderAttributes_productType_metadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface ProductTypeAttributeReorder_productTypeReorderAttributes_productType_privateMetadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface ProductTypeAttributeReorder_productTypeReorderAttributes_productType_productAttributes {
__typename: "Attribute";
id: string;
@ -53,6 +65,8 @@ export interface ProductTypeAttributeReorder_productTypeReorderAttributes_produc
hasVariants: boolean;
isShippingRequired: boolean;
taxType: ProductTypeAttributeReorder_productTypeReorderAttributes_productType_taxType | null;
metadata: (ProductTypeAttributeReorder_productTypeReorderAttributes_productType_metadata | null)[];
privateMetadata: (ProductTypeAttributeReorder_productTypeReorderAttributes_productType_privateMetadata | null)[];
productAttributes: (ProductTypeAttributeReorder_productTypeReorderAttributes_productType_productAttributes | null)[] | null;
variantAttributes: (ProductTypeAttributeReorder_productTypeReorderAttributes_productType_variantAttributes | null)[] | null;
weight: ProductTypeAttributeReorder_productTypeReorderAttributes_productType_weight | null;

View file

@ -20,6 +20,18 @@ export interface ProductTypeCreate_productTypeCreate_productType_taxType {
taxCode: string | null;
}
export interface ProductTypeCreate_productTypeCreate_productType_metadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface ProductTypeCreate_productTypeCreate_productType_privateMetadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface ProductTypeCreate_productTypeCreate_productType_productAttributes {
__typename: "Attribute";
id: string;
@ -53,6 +65,8 @@ export interface ProductTypeCreate_productTypeCreate_productType {
hasVariants: boolean;
isShippingRequired: boolean;
taxType: ProductTypeCreate_productTypeCreate_productType_taxType | null;
metadata: (ProductTypeCreate_productTypeCreate_productType_metadata | null)[];
privateMetadata: (ProductTypeCreate_productTypeCreate_productType_privateMetadata | null)[];
productAttributes: (ProductTypeCreate_productTypeCreate_productType_productAttributes | null)[] | null;
variantAttributes: (ProductTypeCreate_productTypeCreate_productType_variantAttributes | null)[] | null;
weight: ProductTypeCreate_productTypeCreate_productType_weight | null;

View file

@ -14,6 +14,18 @@ export interface ProductTypeDetails_productType_taxType {
taxCode: string | null;
}
export interface ProductTypeDetails_productType_metadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface ProductTypeDetails_productType_privateMetadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface ProductTypeDetails_productType_productAttributes {
__typename: "Attribute";
id: string;
@ -47,6 +59,8 @@ export interface ProductTypeDetails_productType {
hasVariants: boolean;
isShippingRequired: boolean;
taxType: ProductTypeDetails_productType_taxType | null;
metadata: (ProductTypeDetails_productType_metadata | null)[];
privateMetadata: (ProductTypeDetails_productType_privateMetadata | null)[];
productAttributes: (ProductTypeDetails_productType_productAttributes | null)[] | null;
variantAttributes: (ProductTypeDetails_productType_variantAttributes | null)[] | null;
weight: ProductTypeDetails_productType_weight | null;

View file

@ -20,6 +20,18 @@ export interface ProductTypeUpdate_productTypeUpdate_productType_taxType {
taxCode: string | null;
}
export interface ProductTypeUpdate_productTypeUpdate_productType_metadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface ProductTypeUpdate_productTypeUpdate_productType_privateMetadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface ProductTypeUpdate_productTypeUpdate_productType_productAttributes {
__typename: "Attribute";
id: string;
@ -53,6 +65,8 @@ export interface ProductTypeUpdate_productTypeUpdate_productType {
hasVariants: boolean;
isShippingRequired: boolean;
taxType: ProductTypeUpdate_productTypeUpdate_productType_taxType | null;
metadata: (ProductTypeUpdate_productTypeUpdate_productType_metadata | null)[];
privateMetadata: (ProductTypeUpdate_productTypeUpdate_productType_privateMetadata | null)[];
productAttributes: (ProductTypeUpdate_productTypeUpdate_productType_productAttributes | null)[] | null;
variantAttributes: (ProductTypeUpdate_productTypeUpdate_productType_variantAttributes | null)[] | null;
weight: ProductTypeUpdate_productTypeUpdate_productType_weight | null;

View file

@ -20,6 +20,18 @@ export interface UnassignAttribute_attributeUnassign_productType_taxType {
taxCode: string | null;
}
export interface UnassignAttribute_attributeUnassign_productType_metadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface UnassignAttribute_attributeUnassign_productType_privateMetadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface UnassignAttribute_attributeUnassign_productType_productAttributes {
__typename: "Attribute";
id: string;
@ -53,6 +65,8 @@ export interface UnassignAttribute_attributeUnassign_productType {
hasVariants: boolean;
isShippingRequired: boolean;
taxType: UnassignAttribute_attributeUnassign_productType_taxType | null;
metadata: (UnassignAttribute_attributeUnassign_productType_metadata | null)[];
privateMetadata: (UnassignAttribute_attributeUnassign_productType_privateMetadata | null)[];
productAttributes: (UnassignAttribute_attributeUnassign_productType_productAttributes | null)[] | null;
variantAttributes: (UnassignAttribute_attributeUnassign_productType_variantAttributes | null)[] | null;
weight: UnassignAttribute_attributeUnassign_productType_weight | null;

View file

@ -1,44 +0,0 @@
import React from "react";
import { UserError } from "../../../types";
interface ProductTypeUpdateErrorsState {
addAttributeErrors: UserError[];
editAttributeErrors: UserError[];
formErrors: UserError[];
}
interface ProductTypeUpdateErrorsProps {
children: (props: {
errors: ProductTypeUpdateErrorsState;
set: {
addAttributeErrors: (errors: UserError[]) => void;
editAttributeErrors: (errors: UserError[]) => void;
formErrors: (errors: UserError[]) => void;
};
}) => React.ReactNode;
}
export class ProductTypeUpdateErrors extends React.Component<
ProductTypeUpdateErrorsProps,
ProductTypeUpdateErrorsState
> {
state: ProductTypeUpdateErrorsState = {
addAttributeErrors: [],
editAttributeErrors: [],
formErrors: []
};
render() {
return this.props.children({
errors: this.state,
set: {
addAttributeErrors: (addAttributeErrors: UserError[]) =>
this.setState({ addAttributeErrors }),
editAttributeErrors: (editAttributeErrors: UserError[]) =>
this.setState({ editAttributeErrors }),
formErrors: (formErrors: UserError[]) => this.setState({ formErrors })
}
});
}
}
export default ProductTypeUpdateErrors;

View file

@ -9,8 +9,14 @@ import useNotifier from "@saleor/hooks/useNotifier";
import { commonMessages } from "@saleor/intl";
import { maybe } from "@saleor/misc";
import AssignAttributeDialog from "@saleor/productTypes/components/AssignAttributeDialog";
import { useProductTypeUpdateMutation } from "@saleor/productTypes/mutations";
import { ReorderEvent } from "@saleor/types";
import { AttributeTypeEnum } from "@saleor/types/globalTypes";
import createMetadataUpdateHandler from "@saleor/utils/handlers/metadataUpdateHandler";
import {
useMetadataUpdate,
usePrivateMetadataUpdate
} from "@saleor/utils/metadata/updateMetadata";
import React from "react";
import { FormattedMessage, useIntl } from "react-intl";
@ -25,14 +31,12 @@ import useAvailableAttributeSearch from "../../hooks/useAvailableAttributeSearch
import { TypedProductTypeDetailsQuery } from "../../queries";
import { AssignAttribute } from "../../types/AssignAttribute";
import { ProductTypeDelete } from "../../types/ProductTypeDelete";
import { ProductTypeUpdate as ProductTypeUpdateMutation } from "../../types/ProductTypeUpdate";
import { UnassignAttribute } from "../../types/UnassignAttribute";
import {
productTypeListUrl,
productTypeUrl,
ProductTypeUrlQueryParams
} from "../../urls";
import { ProductTypeUpdateErrors } from "./errors";
interface ProductTypeUpdateProps {
id: string;
@ -54,361 +58,367 @@ export const ProductTypeUpdate: React.FC<ProductTypeUpdateProps> = ({
id
}
});
const [errors, setErrors] = React.useState({
addAttributeErrors: [],
editAttributeErrors: [],
formErrors: []
});
const [
updateProductType,
updateProductTypeOpts
] = useProductTypeUpdateMutation({
onCompleted: updateData => {
if (
!updateData.productTypeUpdate.errors ||
updateData.productTypeUpdate.errors.length === 0
) {
notify({
status: "success",
text: intl.formatMessage(commonMessages.savedChanges)
});
} else if (
updateData.productTypeUpdate.errors !== null &&
updateData.productTypeUpdate.errors.length > 0
) {
setErrors(prevErrors => ({
...prevErrors,
formErrors: updateData.productTypeUpdate.errors
}));
}
}
});
const [updateMetadata] = useMetadataUpdate({});
const [updatePrivateMetadata] = usePrivateMetadataUpdate({});
const handleBack = () => navigate(productTypeListUrl());
const handleProductTypeUpdate = async (formData: ProductTypeForm) => {
const result = await updateProductType({
variables: {
id,
input: {
hasVariants: formData.hasVariants,
isShippingRequired: formData.isShippingRequired,
name: formData.name,
productAttributes: formData.productAttributes.map(
choice => choice.value
),
taxCode: formData.taxType,
variantAttributes: formData.variantAttributes.map(
choice => choice.value
),
weight: formData.weight
}
}
});
return result.data.productTypeUpdate.errors;
};
return (
<ProductTypeUpdateErrors>
{({ errors, set: setErrors }) => (
<TypedProductTypeDetailsQuery displayLoader variables={{ id }}>
{({ data, loading: dataLoading }) => {
const productType = data?.productType;
<TypedProductTypeDetailsQuery displayLoader variables={{ id }}>
{({ data, loading: dataLoading }) => {
const productType = data?.productType;
if (productType === null) {
return <NotFoundPage onBack={handleBack} />;
}
if (productType === null) {
return <NotFoundPage onBack={handleBack} />;
}
const closeModal = () => navigate(productTypeUrl(id), true);
const closeModal = () => navigate(productTypeUrl(id), true);
const handleAttributeAssignSuccess = (data: AssignAttribute) => {
if (data.attributeAssign.errors.length === 0) {
notify({
status: "success",
text: intl.formatMessage(commonMessages.savedChanges)
const handleAttributeAssignSuccess = (data: AssignAttribute) => {
if (data.attributeAssign.errors.length === 0) {
notify({
status: "success",
text: intl.formatMessage(commonMessages.savedChanges)
});
closeModal();
} else if (
data.attributeAssign.errors !== null &&
data.attributeAssign.errors.length > 0
) {
setErrors(prevErrors => ({
...prevErrors,
addAttributeErrors: data.attributeAssign.errors
}));
}
};
const handleAttributeUnassignSuccess = (data: UnassignAttribute) => {
if (data.attributeUnassign.errors.length === 0) {
notify({
status: "success",
text: intl.formatMessage(commonMessages.savedChanges)
});
closeModal();
productAttributeListActions.reset();
variantAttributeListActions.reset();
}
};
const handleProductTypeDeleteSuccess = (
deleteData: ProductTypeDelete
) => {
if (deleteData.productTypeDelete.errors.length === 0) {
notify({
status: "success",
text: intl.formatMessage({
defaultMessage: "Product type deleted"
})
});
navigate(productTypeListUrl(), true);
}
};
const handleSubmit = createMetadataUpdateHandler(
data?.productType,
handleProductTypeUpdate,
variables => updateMetadata({ variables }),
variables => updatePrivateMetadata({ variables })
);
return (
<ProductTypeOperations
productType={maybe(() => data.productType)}
onAssignAttribute={handleAttributeAssignSuccess}
onUnassignAttribute={handleAttributeUnassignSuccess}
onProductTypeDelete={handleProductTypeDeleteSuccess}
onProductTypeAttributeReorder={() => undefined}
>
{({
assignAttribute,
deleteProductType,
unassignAttribute,
reorderAttribute
}) => {
const handleProductTypeDelete = () =>
deleteProductType.mutate({ id });
const handleProductTypeVariantsToggle = (hasVariants: boolean) =>
updateProductType({
variables: {
id,
input: {
hasVariants
}
}
});
closeModal();
} else if (
data.attributeAssign.errors !== null &&
data.attributeAssign.errors.length > 0
) {
setErrors.addAttributeErrors(data.attributeAssign.errors);
}
};
const handleAttributeUnassignSuccess = (
data: UnassignAttribute
) => {
if (data.attributeUnassign.errors.length === 0) {
notify({
status: "success",
text: intl.formatMessage(commonMessages.savedChanges)
const handleAssignAttribute = () =>
assignAttribute.mutate({
id,
operations: params.ids.map(id => ({
id,
type: AttributeTypeEnum[params.type]
}))
});
closeModal();
productAttributeListActions.reset();
variantAttributeListActions.reset();
}
};
const handleProductTypeDeleteSuccess = (
deleteData: ProductTypeDelete
) => {
if (deleteData.productTypeDelete.errors.length === 0) {
notify({
status: "success",
text: intl.formatMessage({
defaultMessage: "Product type deleted"
})
const handleAttributeUnassign = () =>
unassignAttribute.mutate({
id,
ids: [params.id]
});
navigate(productTypeListUrl(), true);
}
};
const handleProductTypeUpdateSuccess = (
updateData: ProductTypeUpdateMutation
) => {
if (
!updateData.productTypeUpdate.errors ||
updateData.productTypeUpdate.errors.length === 0
) {
notify({
status: "success",
text: intl.formatMessage(commonMessages.savedChanges)
const handleBulkAttributeUnassign = () =>
unassignAttribute.mutate({
id,
ids: params.ids
});
} else if (
updateData.productTypeUpdate.errors !== null &&
updateData.productTypeUpdate.errors.length > 0
) {
setErrors.formErrors(updateData.productTypeUpdate.errors);
}
};
return (
<ProductTypeOperations
productType={maybe(() => data.productType)}
onAssignAttribute={handleAttributeAssignSuccess}
onUnassignAttribute={handleAttributeUnassignSuccess}
onProductTypeDelete={handleProductTypeDeleteSuccess}
onProductTypeUpdate={handleProductTypeUpdateSuccess}
onProductTypeAttributeReorder={() => undefined}
>
{({
assignAttribute,
deleteProductType,
unassignAttribute,
updateProductType,
reorderAttribute
}) => {
const handleProductTypeDelete = () =>
deleteProductType.mutate({ id });
const handleProductTypeUpdate = (
formData: ProductTypeForm
) => {
updateProductType.mutate({
id,
input: {
hasVariants: formData.hasVariants,
isShippingRequired: formData.isShippingRequired,
name: formData.name,
productAttributes: formData.productAttributes.map(
choice => choice.value
),
taxCode: formData.taxType,
variantAttributes: formData.variantAttributes.map(
choice => choice.value
),
weight: formData.weight
}
});
};
const handleProductTypeVariantsToggle = (
hasVariants: boolean
) =>
updateProductType.mutate({
id,
input: {
hasVariants
}
});
const handleAssignAttribute = () =>
assignAttribute.mutate({
id,
operations: params.ids.map(id => ({
id,
type: AttributeTypeEnum[params.type]
}))
});
const loading = updateProductTypeOpts.loading || dataLoading;
const handleAttributeUnassign = () =>
unassignAttribute.mutate({
id,
ids: [params.id]
});
const handleAttributeReorder = (
event: ReorderEvent,
type: AttributeTypeEnum
) => {
const attributes =
type === AttributeTypeEnum.PRODUCT
? data.productType.productAttributes
: data.productType.variantAttributes;
const handleBulkAttributeUnassign = () =>
unassignAttribute.mutate({
id,
ids: params.ids
});
reorderAttribute.mutate({
move: {
id: attributes[event.oldIndex].id,
sortOrder: event.newIndex - event.oldIndex
},
productTypeId: id,
type
});
};
const loading = updateProductType.opts.loading || dataLoading;
const handleAttributeReorder = (
event: ReorderEvent,
type: AttributeTypeEnum
) => {
const attributes =
type === AttributeTypeEnum.PRODUCT
? data.productType.productAttributes
: data.productType.variantAttributes;
reorderAttribute.mutate({
move: {
id: attributes[event.oldIndex].id,
sortOrder: event.newIndex - event.oldIndex
},
productTypeId: id,
type
});
};
return (
<>
<WindowTitle title={maybe(() => data.productType.name)} />
<ProductTypeDetailsPage
defaultWeightUnit={maybe(
() => data.shop.defaultWeightUnit
)}
disabled={loading}
errors={errors.formErrors}
pageTitle={maybe(() => data.productType.name)}
productType={maybe(() => data.productType)}
saveButtonBarState={updateProductType.opts.status}
taxTypes={maybe(() => data.taxTypes, [])}
onAttributeAdd={type =>
navigate(
productTypeUrl(id, {
action: "assign-attribute",
type
})
)
}
onAttributeClick={attributeId =>
navigate(attributeUrl(attributeId))
}
onAttributeReorder={handleAttributeReorder}
onAttributeUnassign={attributeId =>
navigate(
productTypeUrl(id, {
action: "unassign-attribute",
id: attributeId
})
)
}
onBack={handleBack}
onDelete={() =>
navigate(
productTypeUrl(id, {
action: "remove"
})
)
}
onHasVariantsToggle={handleProductTypeVariantsToggle}
onSubmit={handleProductTypeUpdate}
productAttributeList={{
isChecked: productAttributeListActions.isSelected,
selected:
productAttributeListActions.listElements.length,
toggle: productAttributeListActions.toggle,
toggleAll: productAttributeListActions.toggleAll,
toolbar: (
<Button
color="primary"
onClick={() =>
navigate(
productTypeUrl(id, {
action: "unassign-attributes",
ids:
productAttributeListActions.listElements
})
)
}
>
<FormattedMessage
defaultMessage="Unassign"
description="unassign attribute from product type, button"
/>
</Button>
)
}}
variantAttributeList={{
isChecked: variantAttributeListActions.isSelected,
selected:
variantAttributeListActions.listElements.length,
toggle: variantAttributeListActions.toggle,
toggleAll: variantAttributeListActions.toggleAll,
toolbar: (
<Button
color="primary"
onClick={() =>
navigate(
productTypeUrl(id, {
action: "unassign-attributes",
ids:
variantAttributeListActions.listElements
})
)
}
>
<FormattedMessage
defaultMessage="Unassign"
description="unassign attribute from product type, button"
/>
</Button>
)
}}
/>
{!dataLoading &&
Object.keys(AttributeTypeEnum).map(key => (
<AssignAttributeDialog
attributes={maybe(() =>
result.data.productType.availableAttributes.edges.map(
edge => edge.node
)
)}
confirmButtonState={assignAttribute.opts.status}
errors={maybe(
() =>
assignAttribute.opts.data.attributeAssign.errors.map(
err => err.message
),
[]
)}
loading={result.loading}
onClose={closeModal}
onSubmit={handleAssignAttribute}
onFetch={search}
onFetchMore={loadMore}
onOpen={result.refetch}
hasMore={maybe(
() =>
result.data.productType.availableAttributes
.pageInfo.hasNextPage,
false
)}
open={
params.action === "assign-attribute" &&
params.type === AttributeTypeEnum[key]
}
selected={maybe(() => params.ids, [])}
onToggle={attributeId => {
const ids = maybe(() => params.ids, []);
navigate(
productTypeUrl(id, {
...params,
ids: ids.includes(attributeId)
? params.ids.filter(
selectedId => selectedId !== attributeId
)
: [...ids, attributeId]
})
);
}}
key={key}
return (
<>
<WindowTitle title={maybe(() => data.productType.name)} />
<ProductTypeDetailsPage
defaultWeightUnit={maybe(() => data.shop.defaultWeightUnit)}
disabled={loading}
errors={errors.formErrors}
pageTitle={maybe(() => data.productType.name)}
productType={maybe(() => data.productType)}
saveButtonBarState={updateProductTypeOpts.status}
taxTypes={maybe(() => data.taxTypes, [])}
onAttributeAdd={type =>
navigate(
productTypeUrl(id, {
action: "assign-attribute",
type
})
)
}
onAttributeClick={attributeId =>
navigate(attributeUrl(attributeId))
}
onAttributeReorder={handleAttributeReorder}
onAttributeUnassign={attributeId =>
navigate(
productTypeUrl(id, {
action: "unassign-attribute",
id: attributeId
})
)
}
onBack={handleBack}
onDelete={() =>
navigate(
productTypeUrl(id, {
action: "remove"
})
)
}
onHasVariantsToggle={handleProductTypeVariantsToggle}
onSubmit={handleSubmit}
productAttributeList={{
isChecked: productAttributeListActions.isSelected,
selected: productAttributeListActions.listElements.length,
toggle: productAttributeListActions.toggle,
toggleAll: productAttributeListActions.toggleAll,
toolbar: (
<Button
color="primary"
onClick={() =>
navigate(
productTypeUrl(id, {
action: "unassign-attributes",
ids: productAttributeListActions.listElements
})
)
}
>
<FormattedMessage
defaultMessage="Unassign"
description="unassign attribute from product type, button"
/>
))}
<ProductTypeDeleteDialog
confirmButtonState={deleteProductType.opts.status}
name={maybe(() => data.productType.name, "...")}
open={params.action === "remove"}
onClose={() => navigate(productTypeUrl(id))}
onConfirm={handleProductTypeDelete}
/>
<ProductTypeBulkAttributeUnassignDialog
attributeQuantity={maybe(() => params.ids.length)}
confirmButtonState={unassignAttribute.opts.status}
onClose={closeModal}
onConfirm={handleBulkAttributeUnassign}
open={params.action === "unassign-attributes"}
productTypeName={maybe(
() => data.productType.name,
"..."
</Button>
)
}}
variantAttributeList={{
isChecked: variantAttributeListActions.isSelected,
selected: variantAttributeListActions.listElements.length,
toggle: variantAttributeListActions.toggle,
toggleAll: variantAttributeListActions.toggleAll,
toolbar: (
<Button
color="primary"
onClick={() =>
navigate(
productTypeUrl(id, {
action: "unassign-attributes",
ids: variantAttributeListActions.listElements
})
)
}
>
<FormattedMessage
defaultMessage="Unassign"
description="unassign attribute from product type, button"
/>
</Button>
)
}}
/>
{!dataLoading &&
Object.keys(AttributeTypeEnum).map(key => (
<AssignAttributeDialog
attributes={maybe(() =>
result.data.productType.availableAttributes.edges.map(
edge => edge.node
)
)}
/>
<ProductTypeAttributeUnassignDialog
attributeName={maybe(
confirmButtonState={assignAttribute.opts.status}
errors={maybe(
() =>
[
...data.productType.productAttributes,
...data.productType.variantAttributes
].find(attribute => attribute.id === params.id)
.name,
"..."
assignAttribute.opts.data.attributeAssign.errors.map(
err => err.message
),
[]
)}
confirmButtonState={unassignAttribute.opts.status}
loading={result.loading}
onClose={closeModal}
onConfirm={handleAttributeUnassign}
open={params.action === "unassign-attribute"}
productTypeName={maybe(
() => data.productType.name,
"..."
onSubmit={handleAssignAttribute}
onFetch={search}
onFetchMore={loadMore}
onOpen={result.refetch}
hasMore={maybe(
() =>
result.data.productType.availableAttributes.pageInfo
.hasNextPage,
false
)}
open={
params.action === "assign-attribute" &&
params.type === AttributeTypeEnum[key]
}
selected={maybe(() => params.ids, [])}
onToggle={attributeId => {
const ids = maybe(() => params.ids, []);
navigate(
productTypeUrl(id, {
...params,
ids: ids.includes(attributeId)
? params.ids.filter(
selectedId => selectedId !== attributeId
)
: [...ids, attributeId]
})
);
}}
key={key}
/>
</>
);
}}
</ProductTypeOperations>
);
}}
</TypedProductTypeDetailsQuery>
)}
</ProductTypeUpdateErrors>
))}
<ProductTypeDeleteDialog
confirmButtonState={deleteProductType.opts.status}
name={maybe(() => data.productType.name, "...")}
open={params.action === "remove"}
onClose={() => navigate(productTypeUrl(id))}
onConfirm={handleProductTypeDelete}
/>
<ProductTypeBulkAttributeUnassignDialog
attributeQuantity={maybe(() => params.ids.length)}
confirmButtonState={unassignAttribute.opts.status}
onClose={closeModal}
onConfirm={handleBulkAttributeUnassign}
open={params.action === "unassign-attributes"}
productTypeName={maybe(() => data.productType.name, "...")}
/>
<ProductTypeAttributeUnassignDialog
attributeName={maybe(
() =>
[
...data.productType.productAttributes,
...data.productType.variantAttributes
].find(attribute => attribute.id === params.id).name,
"..."
)}
confirmButtonState={unassignAttribute.opts.status}
onClose={closeModal}
onConfirm={handleAttributeUnassign}
open={params.action === "unassign-attribute"}
productTypeName={maybe(() => data.productType.name, "...")}
/>
</>
);
}}
</ProductTypeOperations>
);
}}
</TypedProductTypeDetailsQuery>
);
};
export default ProductTypeUpdate;

View file

@ -4,6 +4,7 @@ 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";
import SaveButtonBar from "@saleor/components/SaveButtonBar";
import SeoForm from "@saleor/components/SeoForm";
@ -20,6 +21,7 @@ import { SearchCollections_search_edges_node } from "@saleor/searches/types/Sear
import { FetchMoreProps, ListActions } 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";
@ -155,6 +157,12 @@ export const ProductUpdatePage: React.FC<ProductUpdatePageProps> = ({
getChoices(maybe(() => product.collections, []))
);
const {
isMetadataModified,
isPrivateMetadataModified,
makeChangeHandler: makeMetadataChangeHandler
} = useMetadataChangeTrigger();
const initialData = getProductUpdatePageFormData(product, variants);
const initialDescription = maybe<RawDraftContentState>(() =>
JSON.parse(product.descriptionJson)
@ -167,11 +175,18 @@ export const ProductUpdatePage: React.FC<ProductUpdatePageProps> = ({
const hasVariants = maybe(() => product.productType.hasVariants, false);
const handleSubmit = (data: ProductUpdatePageFormData) => {
const metadata = isMetadataModified ? data.metadata : undefined;
const privateMetadata = isPrivateMetadataModified
? data.privateMetadata
: undefined;
if (product.productType.hasVariants) {
onSubmit({
...data,
addStocks: [],
attributes,
metadata,
privateMetadata,
removeStocks: [],
updateStocks: []
});
@ -188,6 +203,8 @@ export const ProductUpdatePage: React.FC<ProductUpdatePageProps> = ({
stockDiff.added.some(addedStock => addedStock === stock.id)
),
attributes,
metadata,
privateMetadata,
removeStocks: stockDiff.removed,
updateStocks: stocks.filter(
stock => !stockDiff.added.some(addedStock => addedStock === stock.id)
@ -224,6 +241,7 @@ export const ProductUpdatePage: React.FC<ProductUpdatePageProps> = ({
attributes,
triggerChange
);
const changeMetadata = makeMetadataChangeHandler(change);
return (
<>
@ -347,6 +365,8 @@ export const ProductUpdatePage: React.FC<ProductUpdatePageProps> = ({
"Add search engine title and description to make this product easier to find"
})}
/>
<CardSpacer />
<Metadata data={data} onChange={changeMetadata} />
</div>
<div>
<ProductOrganization

View file

@ -4,6 +4,8 @@ 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";
import PageHeader from "@saleor/components/PageHeader";
import SaveButtonBar from "@saleor/components/SaveButtonBar";
import { ProductVariant } from "@saleor/fragments/types/ProductVariant";
@ -17,6 +19,8 @@ import {
getAttributeInputFromVariant,
getStockInputFromVariant
} from "@saleor/products/utils/data";
import { mapMetadataItemToInput } from "@saleor/utils/maps";
import useMetadataChangeTrigger from "@saleor/utils/metadata/useMetadataChangeTrigger";
import { diff } from "fast-array-diff";
import React from "react";
@ -31,7 +35,7 @@ import ProductVariantImageSelectDialog from "../ProductVariantImageSelectDialog"
import ProductVariantNavigation from "../ProductVariantNavigation";
import ProductVariantPrice from "../ProductVariantPrice";
export interface ProductVariantPageFormData {
export interface ProductVariantPageFormData extends MetadataFormData {
costPrice: string;
price: string;
sku: string;
@ -100,6 +104,12 @@ const ProductVariantPage: React.FC<ProductVariantPageProps> = ({
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) =>
@ -114,7 +124,9 @@ const ProductVariantPage: React.FC<ProductVariantPageProps> = ({
const initialForm: ProductVariantPageFormData = {
costPrice: maybe(() => variant.costPrice.amount.toString(), ""),
metadata: variant?.metadata?.map(mapMetadataItemToInput),
price: maybe(() => variant.price.amount.toString(), ""),
privateMetadata: variant?.privateMetadata?.map(mapMetadataItemToInput),
sku: maybe(() => variant.sku, ""),
trackInventory: variant?.trackInventory,
weight: variant?.weight?.value.toString() || ""
@ -124,6 +136,10 @@ const ProductVariantPage: React.FC<ProductVariantPageProps> = ({
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,
@ -131,6 +147,8 @@ const ProductVariantPage: React.FC<ProductVariantPageProps> = ({
stockDiff.added.some(addedStock => addedStock === stock.id)
),
attributes,
metadata,
privateMetadata,
removeStocks: stockDiff.removed,
updateStocks: stocks.filter(
stock => !stockDiff.added.some(addedStock => addedStock === stock.id)
@ -152,6 +170,8 @@ const ProductVariantPage: React.FC<ProductVariantPageProps> = ({
triggerChange();
};
const changeMetadata = makeMetadataChangeHandler(change);
return (
<>
<Grid variant="inverted">
@ -235,6 +255,8 @@ const ProductVariantPage: React.FC<ProductVariantPageProps> = ({
removeStock(id);
}}
/>
<CardSpacer />
<Metadata data={data} onChange={changeMetadata} />
</div>
</Grid>
<SaveButtonBar

View file

@ -153,6 +153,13 @@ export const product: (
isFeatured: false,
isPublished: true,
margin: { __typename: "Margin", start: 2, stop: 7 },
metadata: [
{
__typename: "MetadataItem",
key: "integration.id",
value: "100023123"
}
],
name: "Ergonomic Plastic Bacon",
pricing: {
__typename: "ProductPricingInfo",
@ -186,6 +193,7 @@ export const product: (
}
}
},
privateMetadata: [],
productType: {
__typename: "ProductType",
hasVariants: true,
@ -1516,12 +1524,20 @@ export const variant = (placeholderImage: string): ProductVariant => ({
url: placeholderImage
}
],
metadata: [
{
__typename: "MetadataItem",
key: "integration.id",
value: "100023123"
}
],
name: "Extended Hard",
price: {
__typename: "Money",
amount: 100,
currency: "USD"
},
privateMetadata: [],
product: {
__typename: "Product" as "Product",
id: "prod1",

View file

@ -99,6 +99,18 @@ export interface ProductCreate_productCreate_product_pricing {
priceRangeUndiscounted: ProductCreate_productCreate_product_pricing_priceRangeUndiscounted | null;
}
export interface ProductCreate_productCreate_product_metadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface ProductCreate_productCreate_product_privateMetadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface ProductCreate_productCreate_product_category {
__typename: "Category";
id: string;
@ -186,6 +198,8 @@ export interface ProductCreate_productCreate_product {
attributes: ProductCreate_productCreate_product_attributes[];
productType: ProductCreate_productCreate_product_productType;
pricing: ProductCreate_productCreate_product_pricing | null;
metadata: (ProductCreate_productCreate_product_metadata | null)[];
privateMetadata: (ProductCreate_productCreate_product_privateMetadata | null)[];
name: string;
descriptionJson: any;
seoTitle: string | null;

View file

@ -93,6 +93,18 @@ export interface ProductDetails_product_pricing {
priceRangeUndiscounted: ProductDetails_product_pricing_priceRangeUndiscounted | null;
}
export interface ProductDetails_product_metadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface ProductDetails_product_privateMetadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface ProductDetails_product_category {
__typename: "Category";
id: string;
@ -180,6 +192,8 @@ export interface ProductDetails_product {
attributes: ProductDetails_product_attributes[];
productType: ProductDetails_product_productType;
pricing: ProductDetails_product_pricing | null;
metadata: (ProductDetails_product_metadata | null)[];
privateMetadata: (ProductDetails_product_privateMetadata | null)[];
name: string;
descriptionJson: any;
seoTitle: string | null;

View file

@ -99,6 +99,18 @@ export interface ProductImageCreate_productImageCreate_product_pricing {
priceRangeUndiscounted: ProductImageCreate_productImageCreate_product_pricing_priceRangeUndiscounted | null;
}
export interface ProductImageCreate_productImageCreate_product_metadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface ProductImageCreate_productImageCreate_product_privateMetadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface ProductImageCreate_productImageCreate_product_category {
__typename: "Category";
id: string;
@ -186,6 +198,8 @@ export interface ProductImageCreate_productImageCreate_product {
attributes: ProductImageCreate_productImageCreate_product_attributes[];
productType: ProductImageCreate_productImageCreate_product_productType;
pricing: ProductImageCreate_productImageCreate_product_pricing | null;
metadata: (ProductImageCreate_productImageCreate_product_metadata | null)[];
privateMetadata: (ProductImageCreate_productImageCreate_product_privateMetadata | null)[];
name: string;
descriptionJson: any;
seoTitle: string | null;

View file

@ -99,6 +99,18 @@ export interface ProductImageUpdate_productImageUpdate_product_pricing {
priceRangeUndiscounted: ProductImageUpdate_productImageUpdate_product_pricing_priceRangeUndiscounted | null;
}
export interface ProductImageUpdate_productImageUpdate_product_metadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface ProductImageUpdate_productImageUpdate_product_privateMetadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface ProductImageUpdate_productImageUpdate_product_category {
__typename: "Category";
id: string;
@ -186,6 +198,8 @@ export interface ProductImageUpdate_productImageUpdate_product {
attributes: ProductImageUpdate_productImageUpdate_product_attributes[];
productType: ProductImageUpdate_productImageUpdate_product_productType;
pricing: ProductImageUpdate_productImageUpdate_product_pricing | null;
metadata: (ProductImageUpdate_productImageUpdate_product_metadata | null)[];
privateMetadata: (ProductImageUpdate_productImageUpdate_product_privateMetadata | null)[];
name: string;
descriptionJson: any;
seoTitle: string | null;

View file

@ -99,6 +99,18 @@ export interface ProductUpdate_productUpdate_product_pricing {
priceRangeUndiscounted: ProductUpdate_productUpdate_product_pricing_priceRangeUndiscounted | null;
}
export interface ProductUpdate_productUpdate_product_metadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface ProductUpdate_productUpdate_product_privateMetadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface ProductUpdate_productUpdate_product_category {
__typename: "Category";
id: string;
@ -186,6 +198,8 @@ export interface ProductUpdate_productUpdate_product {
attributes: ProductUpdate_productUpdate_product_attributes[];
productType: ProductUpdate_productUpdate_product_productType;
pricing: ProductUpdate_productUpdate_product_pricing | null;
metadata: (ProductUpdate_productUpdate_product_metadata | null)[];
privateMetadata: (ProductUpdate_productUpdate_product_privateMetadata | null)[];
name: string;
descriptionJson: any;
seoTitle: string | null;

View file

@ -8,6 +8,18 @@ import { WeightUnitsEnum } from "./../../types/globalTypes";
// GraphQL query operation: ProductVariantDetails
// ====================================================
export interface ProductVariantDetails_productVariant_metadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface ProductVariantDetails_productVariant_privateMetadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface ProductVariantDetails_productVariant_attributes_attribute_values {
__typename: "AttributeValue";
id: string;
@ -114,6 +126,8 @@ export interface ProductVariantDetails_productVariant_weight {
export interface ProductVariantDetails_productVariant {
__typename: "ProductVariant";
id: string;
metadata: (ProductVariantDetails_productVariant_metadata | null)[];
privateMetadata: (ProductVariantDetails_productVariant_privateMetadata | null)[];
attributes: ProductVariantDetails_productVariant_attributes[];
costPrice: ProductVariantDetails_productVariant_costPrice | null;
images: (ProductVariantDetails_productVariant_images | null)[] | null;

View file

@ -99,6 +99,18 @@ export interface SimpleProductUpdate_productUpdate_product_pricing {
priceRangeUndiscounted: SimpleProductUpdate_productUpdate_product_pricing_priceRangeUndiscounted | null;
}
export interface SimpleProductUpdate_productUpdate_product_metadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface SimpleProductUpdate_productUpdate_product_privateMetadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface SimpleProductUpdate_productUpdate_product_category {
__typename: "Category";
id: string;
@ -186,6 +198,8 @@ export interface SimpleProductUpdate_productUpdate_product {
attributes: SimpleProductUpdate_productUpdate_product_attributes[];
productType: SimpleProductUpdate_productUpdate_product_productType;
pricing: SimpleProductUpdate_productUpdate_product_pricing | null;
metadata: (SimpleProductUpdate_productUpdate_product_metadata | null)[];
privateMetadata: (SimpleProductUpdate_productUpdate_product_privateMetadata | null)[];
name: string;
descriptionJson: any;
seoTitle: string | null;
@ -215,6 +229,18 @@ export interface SimpleProductUpdate_productVariantUpdate_errors {
field: string | null;
}
export interface SimpleProductUpdate_productVariantUpdate_productVariant_metadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface SimpleProductUpdate_productVariantUpdate_productVariant_privateMetadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface SimpleProductUpdate_productVariantUpdate_productVariant_attributes_attribute_values {
__typename: "AttributeValue";
id: string;
@ -321,6 +347,8 @@ export interface SimpleProductUpdate_productVariantUpdate_productVariant_weight
export interface SimpleProductUpdate_productVariantUpdate_productVariant {
__typename: "ProductVariant";
id: string;
metadata: (SimpleProductUpdate_productVariantUpdate_productVariant_metadata | null)[];
privateMetadata: (SimpleProductUpdate_productVariantUpdate_productVariant_privateMetadata | null)[];
attributes: SimpleProductUpdate_productVariantUpdate_productVariant_attributes[];
costPrice: SimpleProductUpdate_productVariantUpdate_productVariant_costPrice | null;
images: (SimpleProductUpdate_productVariantUpdate_productVariant_images | null)[] | null;
@ -346,6 +374,18 @@ export interface SimpleProductUpdate_productVariantStocksCreate_errors {
index: number | null;
}
export interface SimpleProductUpdate_productVariantStocksCreate_productVariant_metadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface SimpleProductUpdate_productVariantStocksCreate_productVariant_privateMetadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface SimpleProductUpdate_productVariantStocksCreate_productVariant_attributes_attribute_values {
__typename: "AttributeValue";
id: string;
@ -452,6 +492,8 @@ export interface SimpleProductUpdate_productVariantStocksCreate_productVariant_w
export interface SimpleProductUpdate_productVariantStocksCreate_productVariant {
__typename: "ProductVariant";
id: string;
metadata: (SimpleProductUpdate_productVariantStocksCreate_productVariant_metadata | null)[];
privateMetadata: (SimpleProductUpdate_productVariantStocksCreate_productVariant_privateMetadata | null)[];
attributes: SimpleProductUpdate_productVariantStocksCreate_productVariant_attributes[];
costPrice: SimpleProductUpdate_productVariantStocksCreate_productVariant_costPrice | null;
images: (SimpleProductUpdate_productVariantStocksCreate_productVariant_images | null)[] | null;
@ -476,6 +518,18 @@ export interface SimpleProductUpdate_productVariantStocksDelete_errors {
field: string | null;
}
export interface SimpleProductUpdate_productVariantStocksDelete_productVariant_metadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface SimpleProductUpdate_productVariantStocksDelete_productVariant_privateMetadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface SimpleProductUpdate_productVariantStocksDelete_productVariant_attributes_attribute_values {
__typename: "AttributeValue";
id: string;
@ -582,6 +636,8 @@ export interface SimpleProductUpdate_productVariantStocksDelete_productVariant_w
export interface SimpleProductUpdate_productVariantStocksDelete_productVariant {
__typename: "ProductVariant";
id: string;
metadata: (SimpleProductUpdate_productVariantStocksDelete_productVariant_metadata | null)[];
privateMetadata: (SimpleProductUpdate_productVariantStocksDelete_productVariant_privateMetadata | null)[];
attributes: SimpleProductUpdate_productVariantStocksDelete_productVariant_attributes[];
costPrice: SimpleProductUpdate_productVariantStocksDelete_productVariant_costPrice | null;
images: (SimpleProductUpdate_productVariantStocksDelete_productVariant_images | null)[] | null;
@ -607,6 +663,18 @@ export interface SimpleProductUpdate_productVariantStocksUpdate_errors {
index: number | null;
}
export interface SimpleProductUpdate_productVariantStocksUpdate_productVariant_metadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface SimpleProductUpdate_productVariantStocksUpdate_productVariant_privateMetadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface SimpleProductUpdate_productVariantStocksUpdate_productVariant_attributes_attribute_values {
__typename: "AttributeValue";
id: string;
@ -713,6 +781,8 @@ export interface SimpleProductUpdate_productVariantStocksUpdate_productVariant_w
export interface SimpleProductUpdate_productVariantStocksUpdate_productVariant {
__typename: "ProductVariant";
id: string;
metadata: (SimpleProductUpdate_productVariantStocksUpdate_productVariant_metadata | null)[];
privateMetadata: (SimpleProductUpdate_productVariantStocksUpdate_productVariant_privateMetadata | null)[];
attributes: SimpleProductUpdate_productVariantStocksUpdate_productVariant_attributes[];
costPrice: SimpleProductUpdate_productVariantStocksUpdate_productVariant_costPrice | null;
images: (SimpleProductUpdate_productVariantStocksUpdate_productVariant_images | null)[] | null;

View file

@ -14,6 +14,18 @@ export interface VariantCreate_productVariantCreate_errors {
field: string | null;
}
export interface VariantCreate_productVariantCreate_productVariant_metadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface VariantCreate_productVariantCreate_productVariant_privateMetadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface VariantCreate_productVariantCreate_productVariant_attributes_attribute_values {
__typename: "AttributeValue";
id: string;
@ -120,6 +132,8 @@ export interface VariantCreate_productVariantCreate_productVariant_weight {
export interface VariantCreate_productVariantCreate_productVariant {
__typename: "ProductVariant";
id: string;
metadata: (VariantCreate_productVariantCreate_productVariant_metadata | null)[];
privateMetadata: (VariantCreate_productVariantCreate_productVariant_privateMetadata | null)[];
attributes: VariantCreate_productVariantCreate_productVariant_attributes[];
costPrice: VariantCreate_productVariantCreate_productVariant_costPrice | null;
images: (VariantCreate_productVariantCreate_productVariant_images | null)[] | null;

View file

@ -14,6 +14,18 @@ export interface VariantImageAssign_variantImageAssign_errors {
field: string | null;
}
export interface VariantImageAssign_variantImageAssign_productVariant_metadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface VariantImageAssign_variantImageAssign_productVariant_privateMetadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface VariantImageAssign_variantImageAssign_productVariant_attributes_attribute_values {
__typename: "AttributeValue";
id: string;
@ -120,6 +132,8 @@ export interface VariantImageAssign_variantImageAssign_productVariant_weight {
export interface VariantImageAssign_variantImageAssign_productVariant {
__typename: "ProductVariant";
id: string;
metadata: (VariantImageAssign_variantImageAssign_productVariant_metadata | null)[];
privateMetadata: (VariantImageAssign_variantImageAssign_productVariant_privateMetadata | null)[];
attributes: VariantImageAssign_variantImageAssign_productVariant_attributes[];
costPrice: VariantImageAssign_variantImageAssign_productVariant_costPrice | null;
images: (VariantImageAssign_variantImageAssign_productVariant_images | null)[] | null;

View file

@ -14,6 +14,18 @@ export interface VariantImageUnassign_variantImageUnassign_errors {
field: string | null;
}
export interface VariantImageUnassign_variantImageUnassign_productVariant_metadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface VariantImageUnassign_variantImageUnassign_productVariant_privateMetadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface VariantImageUnassign_variantImageUnassign_productVariant_attributes_attribute_values {
__typename: "AttributeValue";
id: string;
@ -120,6 +132,8 @@ export interface VariantImageUnassign_variantImageUnassign_productVariant_weight
export interface VariantImageUnassign_variantImageUnassign_productVariant {
__typename: "ProductVariant";
id: string;
metadata: (VariantImageUnassign_variantImageUnassign_productVariant_metadata | null)[];
privateMetadata: (VariantImageUnassign_variantImageUnassign_productVariant_privateMetadata | null)[];
attributes: VariantImageUnassign_variantImageUnassign_productVariant_attributes[];
costPrice: VariantImageUnassign_variantImageUnassign_productVariant_costPrice | null;
images: (VariantImageUnassign_variantImageUnassign_productVariant_images | null)[] | null;

View file

@ -14,6 +14,18 @@ export interface VariantUpdate_productVariantUpdate_errors {
field: string | null;
}
export interface VariantUpdate_productVariantUpdate_productVariant_metadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface VariantUpdate_productVariantUpdate_productVariant_privateMetadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface VariantUpdate_productVariantUpdate_productVariant_attributes_attribute_values {
__typename: "AttributeValue";
id: string;
@ -120,6 +132,8 @@ export interface VariantUpdate_productVariantUpdate_productVariant_weight {
export interface VariantUpdate_productVariantUpdate_productVariant {
__typename: "ProductVariant";
id: string;
metadata: (VariantUpdate_productVariantUpdate_productVariant_metadata | null)[];
privateMetadata: (VariantUpdate_productVariantUpdate_productVariant_privateMetadata | null)[];
attributes: VariantUpdate_productVariantUpdate_productVariant_attributes[];
costPrice: VariantUpdate_productVariantUpdate_productVariant_costPrice | null;
images: (VariantUpdate_productVariantUpdate_productVariant_images | null)[] | null;
@ -145,6 +159,18 @@ export interface VariantUpdate_productVariantStocksUpdate_errors {
index: number | null;
}
export interface VariantUpdate_productVariantStocksUpdate_productVariant_metadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface VariantUpdate_productVariantStocksUpdate_productVariant_privateMetadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface VariantUpdate_productVariantStocksUpdate_productVariant_attributes_attribute_values {
__typename: "AttributeValue";
id: string;
@ -251,6 +277,8 @@ export interface VariantUpdate_productVariantStocksUpdate_productVariant_weight
export interface VariantUpdate_productVariantStocksUpdate_productVariant {
__typename: "ProductVariant";
id: string;
metadata: (VariantUpdate_productVariantStocksUpdate_productVariant_metadata | null)[];
privateMetadata: (VariantUpdate_productVariantStocksUpdate_productVariant_privateMetadata | null)[];
attributes: VariantUpdate_productVariantStocksUpdate_productVariant_attributes[];
costPrice: VariantUpdate_productVariantStocksUpdate_productVariant_costPrice | null;
images: (VariantUpdate_productVariantStocksUpdate_productVariant_images | null)[] | null;

View file

@ -1,3 +1,4 @@
import { MetadataFormData } from "@saleor/components/Metadata/types";
import { MultiAutocompleteChoiceType } from "@saleor/components/MultiAutocompleteSelectField";
import { SingleAutocompleteChoiceType } from "@saleor/components/SingleAutocompleteSelectField";
import { ProductVariant } from "@saleor/fragments/types/ProductVariant";
@ -10,6 +11,7 @@ import {
} from "@saleor/products/types/ProductDetails";
import { SearchProductTypes_search_edges_node_productAttributes } from "@saleor/searches/types/SearchProductTypes";
import { StockInput } from "@saleor/types/globalTypes";
import { mapMetadataItemToInput } from "@saleor/utils/maps";
import { RawDraftContentState } from "draft-js";
import { ProductAttributeInput } from "../components/ProductAttributes";
@ -168,7 +170,7 @@ export function getChoices(nodes: Node[]): SingleAutocompleteChoiceType[] {
);
}
export interface ProductUpdatePageFormData {
export interface ProductUpdatePageFormData extends MetadataFormData {
basePrice: number;
category: string | null;
collections: string[];
@ -198,7 +200,9 @@ export function getProductUpdatePageFormData(
),
description: maybe(() => JSON.parse(product.descriptionJson)),
isPublished: maybe(() => product.isPublished, false),
metadata: product?.metadata?.map(mapMetadataItemToInput),
name: maybe(() => product.name, ""),
privateMetadata: product?.privateMetadata?.map(mapMetadataItemToInput),
publicationDate: maybe(() => product.publicationDate, ""),
seoDescription: maybe(() => product.seoDescription, ""),
seoTitle: maybe(() => product.seoTitle, ""),

View file

@ -23,6 +23,11 @@ import {
import useCategorySearch from "@saleor/searches/useCategorySearch";
import useCollectionSearch from "@saleor/searches/useCollectionSearch";
import createDialogActionHandlers from "@saleor/utils/handlers/dialogActionHandlers";
import createMetadataUpdateHandler from "@saleor/utils/handlers/metadataUpdateHandler";
import {
useMetadataUpdate,
usePrivateMetadataUpdate
} from "@saleor/utils/metadata/updateMetadata";
import { useWarehouseList } from "@saleor/warehouses/queries";
import React from "react";
import { FormattedMessage, useIntl } from "react-intl";
@ -81,6 +86,8 @@ export const ProductUpdate: React.FC<ProductUpdateProps> = ({ id, params }) => {
}
});
const shop = useShop();
const [updateMetadata] = useMetadataUpdate({});
const [updatePrivateMetadata] = usePrivateMetadataUpdate({});
const { data, loading, refetch } = useProductDetails({
displayLoader: true,
@ -181,10 +188,15 @@ export const ProductUpdate: React.FC<ProductUpdateProps> = ({ id, params }) => {
deleteProductImage({ variables: { id } });
const handleImageEdit = (imageId: string) => () =>
navigate(productImageUrl(id, imageId));
const handleSubmit = createUpdateHandler(
const handleSubmit = createMetadataUpdateHandler(
product,
variables => updateProduct({ variables }),
variables => updateSimpleProduct({ variables })
createUpdateHandler(
product,
variables => updateProduct({ variables }),
variables => updateSimpleProduct({ variables })
),
variables => updateMetadata({ variables }),
variables => updatePrivateMetadata({ variables })
);
const handleImageUpload = createImageUploadHandler(id, variables =>
createProductImage({ variables })

View file

@ -1,20 +1,34 @@
import { BulkStockErrorFragment } from "@saleor/fragments/types/BulkStockErrorFragment";
import { ProductErrorFragment } from "@saleor/fragments/types/ProductErrorFragment";
import { StockErrorFragment } from "@saleor/fragments/types/StockErrorFragment";
import { decimal, weight } from "@saleor/misc";
import { ProductUpdatePageSubmitData } from "@saleor/products/components/ProductUpdatePage";
import { ProductDetails_product } from "@saleor/products/types/ProductDetails";
import { ProductImageCreateVariables } from "@saleor/products/types/ProductImageCreate";
import { ProductImageReorderVariables } from "@saleor/products/types/ProductImageReorder";
import { ProductUpdateVariables } from "@saleor/products/types/ProductUpdate";
import { SimpleProductUpdateVariables } from "@saleor/products/types/SimpleProductUpdate";
import {
ProductUpdate,
ProductUpdateVariables
} from "@saleor/products/types/ProductUpdate";
import {
SimpleProductUpdate,
SimpleProductUpdateVariables
} from "@saleor/products/types/SimpleProductUpdate";
import { mapFormsetStockToStockInput } from "@saleor/products/utils/data";
import { ReorderEvent } from "@saleor/types";
import { MutationFetchResult } from "react-apollo";
import { arrayMove } from "react-sortable-hoc";
export function createUpdateHandler(
product: ProductDetails_product,
updateProduct: (variables: ProductUpdateVariables) => void,
updateSimpleProduct: (variables: SimpleProductUpdateVariables) => void
updateProduct: (
variables: ProductUpdateVariables
) => Promise<MutationFetchResult<ProductUpdate>>,
updateSimpleProduct: (
variables: SimpleProductUpdateVariables
) => Promise<MutationFetchResult<SimpleProductUpdate>>
) {
return (data: ProductUpdatePageSubmitData) => {
return async (data: ProductUpdatePageSubmitData) => {
const productVariables: ProductUpdateVariables = {
attributes: data.attributes.map(attribute => ({
id: attribute.id,
@ -36,10 +50,15 @@ export function createUpdateHandler(
}
};
let errors: Array<
ProductErrorFragment | StockErrorFragment | BulkStockErrorFragment
>;
if (product.productType.hasVariants) {
updateProduct(productVariables);
const result = await updateProduct(productVariables);
errors = result.data.productUpdate.errors;
} else {
updateSimpleProduct({
const result = await updateSimpleProduct({
...productVariables,
addStocks: data.addStocks.map(mapFormsetStockToStockInput),
deleteStocks: data.removeStocks,
@ -51,7 +70,16 @@ export function createUpdateHandler(
updateStocks: data.updateStocks.map(mapFormsetStockToStockInput),
weight: weight(data.weight)
});
errors = [
...result.data.productUpdate.errors,
...result.data.productVariantStocksCreate.errors,
...result.data.productVariantStocksDelete.errors,
...result.data.productVariantStocksUpdate.errors,
...result.data.productVariantUpdate.errors
];
}
return errors;
};
}

View file

@ -6,6 +6,11 @@ import useNotifier from "@saleor/hooks/useNotifier";
import useShop from "@saleor/hooks/useShop";
import { commonMessages } from "@saleor/intl";
import createDialogActionHandlers from "@saleor/utils/handlers/dialogActionHandlers";
import createMetadataUpdateHandler from "@saleor/utils/handlers/metadataUpdateHandler";
import {
useMetadataUpdate,
usePrivateMetadataUpdate
} from "@saleor/utils/metadata/updateMetadata";
import { useWarehouseList } from "@saleor/warehouses/queries";
import React, { useEffect, useState } from "react";
import { useIntl } from "react-intl";
@ -67,6 +72,8 @@ export const ProductVariant: React.FC<ProductUpdateProps> = ({
id: variantId
}
});
const [updateMetadata] = useMetadataUpdate({});
const [updatePrivateMetadata] = usePrivateMetadataUpdate({});
const [openModal] = createDialogActionHandlers<
ProductVariantEditUrlDialog,
@ -140,6 +147,39 @@ export const ProductVariant: React.FC<ProductUpdateProps> = ({
}
};
const handleUpdate = async (data: ProductVariantPageSubmitData) => {
const result = await updateVariant({
variables: {
addStocks: data.addStocks.map(mapFormsetStockToStockInput),
attributes: data.attributes.map(attribute => ({
id: attribute.id,
values: [attribute.value]
})),
costPrice: decimal(data.costPrice),
id: variantId,
price: decimal(data.price),
removeStocks: data.removeStocks,
sku: data.sku,
stocks: data.updateStocks.map(mapFormsetStockToStockInput),
trackInventory: data.trackInventory,
weight: weight(data.weight)
}
});
return [
...result.data?.productVariantStocksCreate.errors,
...result.data?.productVariantStocksDelete.errors,
...result.data?.productVariantStocksUpdate.errors,
...result.data?.productVariantUpdate.errors
];
};
const handleSubmit = createMetadataUpdateHandler(
data?.productVariant,
handleUpdate,
variables => updateMetadata({ variables }),
variables => updatePrivateMetadata({ variables })
);
return (
<>
<WindowTitle title={data?.productVariant?.name} />
@ -158,25 +198,7 @@ export const ProductVariant: React.FC<ProductUpdateProps> = ({
onBack={handleBack}
onDelete={() => openModal("remove")}
onImageSelect={handleImageSelect}
onSubmit={(data: ProductVariantPageSubmitData) =>
updateVariant({
variables: {
addStocks: data.addStocks.map(mapFormsetStockToStockInput),
attributes: data.attributes.map(attribute => ({
id: attribute.id,
values: [attribute.value]
})),
costPrice: decimal(data.costPrice),
id: variantId,
price: decimal(data.price),
removeStocks: data.removeStocks,
sku: data.sku,
stocks: data.updateStocks.map(mapFormsetStockToStockInput),
trackInventory: data.trackInventory,
weight: weight(data.weight)
}
})
}
onSubmit={handleSubmit}
onVariantClick={variantId => {
navigate(productVariantEditUrl(productId, variantId));
}}

File diff suppressed because it is too large Load diff

View file

@ -506,6 +506,12 @@ export enum MenuSortField {
NAME = "NAME",
}
export enum MetadataErrorCode {
GRAPHQL_ERROR = "GRAPHQL_ERROR",
INVALID = "INVALID",
NOT_FOUND = "NOT_FOUND",
}
export enum OrderAction {
CAPTURE = "CAPTURE",
MARK_AS_PAID = "MARK_AS_PAID",
@ -1186,6 +1192,11 @@ export interface MenuSortingInput {
field: MenuSortField;
}
export interface MetadataInput {
key: string;
value: string;
}
export interface NameTranslationInput {
name?: string | null;
}

View file

@ -0,0 +1,88 @@
import { MetadataFormData } from "@saleor/components/Metadata/types";
import { MetadataInput } from "@saleor/types/globalTypes";
import { diff } from "fast-array-diff";
import { MutationFetchResult } from "react-apollo";
import {
UpdateMetadata,
UpdateMetadataVariables
} from "../metadata/types/UpdateMetadata";
import {
UpdatePrivateMetadata,
UpdatePrivateMetadataVariables
} from "../metadata/types/UpdatePrivateMetadata";
interface ObjectWithMetadata {
id: string;
metadata: MetadataInput[];
privateMetadata: MetadataInput[];
}
function createMetadataUpdateHandler<TData extends MetadataFormData, TError>(
initial: ObjectWithMetadata,
update: (data: TData) => Promise<TError[]>,
updateMetadata: (
variables: UpdateMetadataVariables
) => Promise<MutationFetchResult<UpdateMetadata>>,
updatePrivateMetadata: (
variables: UpdatePrivateMetadataVariables
) => Promise<MutationFetchResult<UpdatePrivateMetadata>>
) {
return async (data: TData) => {
const errors = await update(data);
if (errors.length > 0) {
return errors;
}
if (errors.length === 0) {
if (data.metadata) {
const metaDiff = diff(
initial.metadata,
data.metadata,
(a, b) => a.key === b.key
);
const updateMetaResult = await updateMetadata({
id: initial.id,
input: data.metadata,
keysToDelete: metaDiff.removed.map(meta => meta.key)
});
const updateMetaErrors = [
...(updateMetaResult.data.deleteMetadata.errors || []),
...(updateMetaResult.data.updateMetadata.errors || [])
];
if (updateMetaErrors.length > 0) {
return updateMetaErrors;
}
}
if (data.privateMetadata) {
const privateMetaDiff = diff(
initial.privateMetadata,
data.privateMetadata,
(a, b) => a.key === b.key
);
const updatePrivateMetaResult = await updatePrivateMetadata({
id: initial.id,
input: data.privateMetadata,
keysToDelete: privateMetaDiff.removed.map(meta => meta.key)
});
const updatePrivateMetaErrors = [
...(updatePrivateMetaResult.data.deletePrivateMetadata.errors || []),
...(updatePrivateMetaResult.data.updatePrivateMetadata.errors || [])
];
if (updatePrivateMetaErrors.length > 0) {
return updatePrivateMetaErrors;
}
}
}
return [];
};
}
export default createMetadataUpdateHandler;

View file

@ -1,7 +1,9 @@
import { MultiAutocompleteChoiceType } from "@saleor/components/MultiAutocompleteSelectField";
import { ShopInfo_shop_countries } from "@saleor/components/Shop/types/ShopInfo";
import { SingleAutocompleteChoiceType } from "@saleor/components/SingleAutocompleteSelectField";
import { MetadataItem } from "@saleor/fragments/types/MetadataItem";
import { Node } from "@saleor/types";
import { MetadataInput } from "@saleor/types/globalTypes";
export function mapCountriesToChoices(
countries: ShopInfo_shop_countries[]
@ -20,3 +22,10 @@ export function mapNodeToChoice(
value: node.id
}));
}
export function mapMetadataItemToInput(item: MetadataItem): MetadataInput {
return {
key: item.key,
value: item.value
};
}

View file

@ -0,0 +1,62 @@
/* tslint:disable */
/* eslint-disable */
// This file was automatically generated and should not be edited.
import { MetadataInput, MetadataErrorCode } from "./../../../types/globalTypes";
// ====================================================
// GraphQL mutation operation: UpdateMetadata
// ====================================================
export interface UpdateMetadata_updateMetadata_errors {
__typename: "MetadataError";
code: MetadataErrorCode;
field: string | null;
}
export interface UpdateMetadata_updateMetadata {
__typename: "UpdateMetadata";
errors: UpdateMetadata_updateMetadata_errors[];
}
export interface UpdateMetadata_deleteMetadata_errors {
__typename: "MetadataError";
code: MetadataErrorCode;
field: string | null;
}
export interface UpdateMetadata_deleteMetadata_item_metadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface UpdateMetadata_deleteMetadata_item_privateMetadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface UpdateMetadata_deleteMetadata_item {
__typename: "ServiceAccount" | "App" | "Product" | "ProductType" | "Attribute" | "Category" | "ProductVariant" | "DigitalContent" | "Collection" | "User" | "Checkout" | "Order" | "Fulfillment" | "Invoice";
metadata: (UpdateMetadata_deleteMetadata_item_metadata | null)[];
privateMetadata: (UpdateMetadata_deleteMetadata_item_privateMetadata | null)[];
id: string;
}
export interface UpdateMetadata_deleteMetadata {
__typename: "DeleteMetadata";
errors: UpdateMetadata_deleteMetadata_errors[];
item: UpdateMetadata_deleteMetadata_item | null;
}
export interface UpdateMetadata {
updateMetadata: UpdateMetadata_updateMetadata | null;
deleteMetadata: UpdateMetadata_deleteMetadata | null;
}
export interface UpdateMetadataVariables {
id: string;
input: MetadataInput[];
keysToDelete: string[];
}

View file

@ -0,0 +1,62 @@
/* tslint:disable */
/* eslint-disable */
// This file was automatically generated and should not be edited.
import { MetadataInput, MetadataErrorCode } from "./../../../types/globalTypes";
// ====================================================
// GraphQL mutation operation: UpdatePrivateMetadata
// ====================================================
export interface UpdatePrivateMetadata_updatePrivateMetadata_errors {
__typename: "MetadataError";
code: MetadataErrorCode;
field: string | null;
}
export interface UpdatePrivateMetadata_updatePrivateMetadata {
__typename: "UpdatePrivateMetadata";
errors: UpdatePrivateMetadata_updatePrivateMetadata_errors[];
}
export interface UpdatePrivateMetadata_deletePrivateMetadata_errors {
__typename: "MetadataError";
code: MetadataErrorCode;
field: string | null;
}
export interface UpdatePrivateMetadata_deletePrivateMetadata_item_metadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface UpdatePrivateMetadata_deletePrivateMetadata_item_privateMetadata {
__typename: "MetadataItem";
key: string;
value: string;
}
export interface UpdatePrivateMetadata_deletePrivateMetadata_item {
__typename: "ServiceAccount" | "App" | "Product" | "ProductType" | "Attribute" | "Category" | "ProductVariant" | "DigitalContent" | "Collection" | "User" | "Checkout" | "Order" | "Fulfillment" | "Invoice";
metadata: (UpdatePrivateMetadata_deletePrivateMetadata_item_metadata | null)[];
privateMetadata: (UpdatePrivateMetadata_deletePrivateMetadata_item_privateMetadata | null)[];
id: string;
}
export interface UpdatePrivateMetadata_deletePrivateMetadata {
__typename: "DeletePrivateMetadata";
errors: UpdatePrivateMetadata_deletePrivateMetadata_errors[];
item: UpdatePrivateMetadata_deletePrivateMetadata_item | null;
}
export interface UpdatePrivateMetadata {
updatePrivateMetadata: UpdatePrivateMetadata_updatePrivateMetadata | null;
deletePrivateMetadata: UpdatePrivateMetadata_deletePrivateMetadata | null;
}
export interface UpdatePrivateMetadataVariables {
id: string;
input: MetadataInput[];
keysToDelete: string[];
}

View file

@ -0,0 +1,75 @@
import { metadataErrorFragment } from "@saleor/fragments/errors";
import makeMutation from "@saleor/hooks/makeMutation";
import gql from "graphql-tag";
import { metadataFragment } from "../../fragments/metadata";
import {
UpdateMetadata,
UpdateMetadataVariables
} from "./types/UpdateMetadata";
import {
UpdatePrivateMetadata,
UpdatePrivateMetadataVariables
} from "./types/UpdatePrivateMetadata";
const updateMetadata = gql`
${metadataFragment}
${metadataErrorFragment}
mutation UpdateMetadata(
$id: ID!
$input: [MetadataInput!]!
$keysToDelete: [String!]!
) {
updateMetadata(id: $id, input: $input) {
errors: metadataErrors {
...MetadataErrorFragment
}
}
deleteMetadata(id: $id, keys: $keysToDelete) {
errors: metadataErrors {
...MetadataErrorFragment
}
item {
...MetadataFragment
... on Node {
id
}
}
}
}
`;
export const useMetadataUpdate = makeMutation<
UpdateMetadata,
UpdateMetadataVariables
>(updateMetadata);
const updatePrivateMetadata = gql`
${metadataFragment}
${metadataErrorFragment}
mutation UpdatePrivateMetadata(
$id: ID!
$input: [MetadataInput!]!
$keysToDelete: [String!]!
) {
updatePrivateMetadata(id: $id, input: $input) {
errors: metadataErrors {
...MetadataErrorFragment
}
}
deletePrivateMetadata(id: $id, keys: $keysToDelete) {
errors: metadataErrors {
...MetadataErrorFragment
}
item {
...MetadataFragment
... on Node {
id
}
}
}
}
`;
export const usePrivateMetadataUpdate = makeMutation<
UpdatePrivateMetadata,
UpdatePrivateMetadataVariables
>(updatePrivateMetadata);

View file

@ -0,0 +1,28 @@
import { FormChange } from "@saleor/hooks/useForm";
import { useState } from "react";
function useMetadataChangeTrigger() {
const [isMetadataModified, setMetadataModified] = useState(false);
const [isPrivateMetadataModified, setPrivateMetadataModified] = useState(
false
);
const makeChangeHandler: (
onChange: FormChange
) => FormChange = onChange => event => {
if (event.target.name === "metadata") {
setMetadataModified(true);
} else {
setPrivateMetadataModified(true);
}
onChange(event);
};
return {
isMetadataModified,
isPrivateMetadataModified,
makeChangeHandler
};
}
export default useMetadataChangeTrigger;

25
testUtils/wrapper.tsx Normal file
View file

@ -0,0 +1,25 @@
import { Provider as DateProvider } from "@saleor/components/Date/DateContext";
import { Locale, RawLocaleProvider } from "@saleor/components/Locale";
import ThemeProvider from "@saleor/components/Theme";
import { TimezoneProvider } from "@saleor/components/Timezone";
import React from "react";
import { IntlProvider } from "react-intl";
const Wrapper: React.FC = ({ children }) => (
<IntlProvider defaultLocale={Locale.EN} locale={Locale.EN}>
<RawLocaleProvider
value={{
locale: Locale.EN,
setLocale: () => undefined
}}
>
<DateProvider value={+new Date("2018-08-07T14:30:44+00:00")}>
<TimezoneProvider value="America/New_York">
<ThemeProvider isDefaultDark={false}>{children}</ThemeProvider>
</TimezoneProvider>
</DateProvider>
</RawLocaleProvider>
</IntlProvider>
);
export default Wrapper;