diff --git a/src/attributes/components/AttributeBulkDeleteDialog/AttributeBulkDeleteDialog.tsx b/src/attributes/components/AttributeBulkDeleteDialog/AttributeBulkDeleteDialog.tsx new file mode 100644 index 000000000..bd40e2c51 --- /dev/null +++ b/src/attributes/components/AttributeBulkDeleteDialog/AttributeBulkDeleteDialog.tsx @@ -0,0 +1,40 @@ +import DialogContentText from "@material-ui/core/DialogContentText"; +import React from "react"; + +import ActionDialog from "@saleor/components/ActionDialog"; +import { ConfirmButtonTransitionState } from "@saleor/components/ConfirmButton"; +import i18n from "../../../i18n"; + +export interface AttributeBulkDeleteDialogProps { + confirmButtonState: ConfirmButtonTransitionState; + quantity: string; + open: boolean; + onConfirm: () => void; + onClose: () => void; +} + +const AttributeBulkDeleteDialog: React.StatelessComponent< + AttributeBulkDeleteDialogProps +> = ({ confirmButtonState, quantity, onClose, onConfirm, open }) => ( + + {{ quantity }} attributes?", + { + quantity + } + ) + }} + /> + +); +AttributeBulkDeleteDialog.displayName = "AttributeBulkDeleteDialog"; +export default AttributeBulkDeleteDialog; diff --git a/src/attributes/components/AttributeBulkDeleteDialog/index.ts b/src/attributes/components/AttributeBulkDeleteDialog/index.ts new file mode 100644 index 000000000..12ecc2424 --- /dev/null +++ b/src/attributes/components/AttributeBulkDeleteDialog/index.ts @@ -0,0 +1,2 @@ +export { default } from './AttributeBulkDeleteDialog'; +export * from './AttributeBulkDeleteDialog'; \ No newline at end of file diff --git a/src/attributes/components/AttributeDeleteDialog/AttributeDeleteDialog.tsx b/src/attributes/components/AttributeDeleteDialog/AttributeDeleteDialog.tsx new file mode 100644 index 000000000..b6e577d88 --- /dev/null +++ b/src/attributes/components/AttributeDeleteDialog/AttributeDeleteDialog.tsx @@ -0,0 +1,45 @@ +import DialogContentText from "@material-ui/core/DialogContentText"; +import React from "react"; + +import ActionDialog from "@saleor/components/ActionDialog"; +import { ConfirmButtonTransitionState } from "@saleor/components/ConfirmButton"; +import i18n from "@saleor/i18n"; + +export interface AttributeDeleteDialogProps { + confirmButtonState: ConfirmButtonTransitionState; + open: boolean; + onConfirm: () => void; + onClose: () => void; + name: string; +} + +const AttributeDeleteDialog: React.FC = ({ + name, + confirmButtonState, + onClose, + onConfirm, + open +}) => ( + + {{ name }}?", + { + name + } + ) + }} + /> + +); + +AttributeDeleteDialog.displayName = "AttributeDeleteDialog"; +export default AttributeDeleteDialog; diff --git a/src/attributes/components/AttributeDeleteDialog/index.ts b/src/attributes/components/AttributeDeleteDialog/index.ts new file mode 100644 index 000000000..d3f62c3df --- /dev/null +++ b/src/attributes/components/AttributeDeleteDialog/index.ts @@ -0,0 +1,2 @@ +export { default } from './AttributeDeleteDialog'; +export * from './AttributeDeleteDialog'; \ No newline at end of file diff --git a/src/attributes/components/AttributeDetails/AttributeDetails.tsx b/src/attributes/components/AttributeDetails/AttributeDetails.tsx new file mode 100644 index 000000000..038f0fa65 --- /dev/null +++ b/src/attributes/components/AttributeDetails/AttributeDetails.tsx @@ -0,0 +1,98 @@ +import Card from "@material-ui/core/Card"; +import CardContent from "@material-ui/core/CardContent"; +import TextField from "@material-ui/core/TextField"; +import React from "react"; +import slugify from "slugify"; + +import CardTitle from "@saleor/components/CardTitle"; +import ControlledSwitch from "@saleor/components/ControlledSwitch"; +import FormSpacer from "@saleor/components/FormSpacer"; +import SingleSelectField from "@saleor/components/SingleSelectField"; +import i18n from "@saleor/i18n"; +import { FormErrors } from "@saleor/types"; +import { AttributeInputTypeEnum } from "@saleor/types/globalTypes"; +import { AttributePageFormData } from "../AttributePage"; + +export interface AttributeDetailsProps { + canChangeType: boolean; + data: AttributePageFormData; + disabled: boolean; + errors: FormErrors<"name" | "slug" | "inputType">; + onChange: (event: React.ChangeEvent) => void; +} + +const inputTypeChoices = [ + { + label: i18n.t("Dropdown"), + value: AttributeInputTypeEnum.DROPDOWN + }, + { + label: i18n.t("Multiple Select"), + value: AttributeInputTypeEnum.MULTISELECT + } +]; + +const AttributeDetails: React.FC = ({ + canChangeType, + data, + disabled, + errors, + onChange +}) => ( + + + + + + + + + + + + +); +AttributeDetails.displayName = "AttributeDetails"; +export default AttributeDetails; diff --git a/src/attributes/components/AttributeDetails/index.ts b/src/attributes/components/AttributeDetails/index.ts new file mode 100644 index 000000000..93e888b1e --- /dev/null +++ b/src/attributes/components/AttributeDetails/index.ts @@ -0,0 +1,2 @@ +export { default } from './AttributeDetails'; +export * from './AttributeDetails'; \ No newline at end of file diff --git a/src/attributes/components/AttributeList/AttributeList.tsx b/src/attributes/components/AttributeList/AttributeList.tsx new file mode 100644 index 000000000..95dfc7cc4 --- /dev/null +++ b/src/attributes/components/AttributeList/AttributeList.tsx @@ -0,0 +1,183 @@ +import { Theme } from "@material-ui/core/styles"; +import Table from "@material-ui/core/Table"; +import TableBody from "@material-ui/core/TableBody"; +import TableCell from "@material-ui/core/TableCell"; +import TableFooter from "@material-ui/core/TableFooter"; +import TableRow from "@material-ui/core/TableRow"; +import makeStyles from "@material-ui/styles/makeStyles"; +import React from "react"; + +import Checkbox from "@saleor/components/Checkbox"; +import Skeleton from "@saleor/components/Skeleton"; +import TableHead from "@saleor/components/TableHead"; +import TablePagination from "@saleor/components/TablePagination"; +import i18n from "@saleor/i18n"; +import { renderCollection } from "@saleor/misc"; +import { ListActions, ListProps } from "@saleor/types"; +import { translateBoolean } from "@saleor/utils/i18n"; +import { AttributeList_attributes_edges_node } from "../../types/AttributeList"; + +export interface AttributeListProps extends ListProps, ListActions { + attributes: AttributeList_attributes_edges_node[]; +} + +const useStyles = makeStyles((theme: Theme) => ({ + [theme.breakpoints.up("lg")]: { + colFaceted: { + width: 150 + }, + colName: { + width: "auto" + }, + colSearchable: { + width: 150 + }, + colSlug: { + width: 200 + }, + colVisible: { + width: 150 + } + }, + colFaceted: { + textAlign: "center" + }, + colName: {}, + colSearchable: { + textAlign: "center" + }, + colSlug: {}, + colVisible: { + textAlign: "center" + }, + link: { + cursor: "pointer" + } +})); + +const numberOfColumns = 6; + +const AttributeList: React.StatelessComponent = ({ + attributes, + disabled, + isChecked, + onNextPage, + onPreviousPage, + onRowClick, + pageInfo, + selected, + toggle, + toggleAll, + toolbar +}) => { + const classes = useStyles({}); + + return ( + + + + {i18n.t("Attribute Code", { context: "attribute slug" })} + + + {i18n.t("Default Label", { context: "attribute name" })} + + + {i18n.t("Visible", { context: "attribute visibility" })} + + + {i18n.t("Searchable", { + context: "attribute can be searched in dashboard" + })} + + + {i18n.t("Use in faceted search", { + context: "attribute can be searched in storefront" + })} + + + + + + + + + {renderCollection( + attributes, + attribute => { + const isSelected = attribute ? isChecked(attribute.id) : false; + + return ( + + + toggle(attribute.id)} + /> + + + {attribute ? attribute.slug : } + + + {attribute ? attribute.name : } + + + {attribute ? ( + translateBoolean(attribute.visibleInStorefront) + ) : ( + + )} + + + {attribute ? ( + translateBoolean(attribute.filterableInDashboard) + ) : ( + + )} + + + {attribute ? ( + translateBoolean(attribute.filterableInStorefront) + ) : ( + + )} + + + ); + }, + () => ( + + + {i18n.t("No attributes found")} + + + ) + )} + +
+ ); +}; +AttributeList.displayName = "AttributeList"; +export default AttributeList; diff --git a/src/attributes/components/AttributeList/index.ts b/src/attributes/components/AttributeList/index.ts new file mode 100644 index 000000000..4a094ce5f --- /dev/null +++ b/src/attributes/components/AttributeList/index.ts @@ -0,0 +1,2 @@ +export { default } from './AttributeList'; +export * from './AttributeList'; \ No newline at end of file diff --git a/src/attributes/components/AttributeListPage/AttributeListPage.tsx b/src/attributes/components/AttributeListPage/AttributeListPage.tsx new file mode 100644 index 000000000..4daa73528 --- /dev/null +++ b/src/attributes/components/AttributeListPage/AttributeListPage.tsx @@ -0,0 +1,33 @@ +import Button from "@material-ui/core/Button"; +import Card from "@material-ui/core/Card"; +import AddIcon from "@material-ui/icons/Add"; +import React from "react"; + +import Container from "../../../components/Container"; +import PageHeader from "../../../components/PageHeader"; +import i18n from "../../../i18n"; +import { ListActions, PageListProps } from "../../../types"; +import { AttributeList_attributes_edges_node } from "../../types/AttributeList"; +import AttributeList from "../AttributeList/AttributeList"; + +export interface AttributeListPageProps extends PageListProps, ListActions { + attributes: AttributeList_attributes_edges_node[]; +} + +const AttributeListPage: React.FC = ({ + onAdd, + ...listProps +}) => ( + + + + + + + + +); +AttributeListPage.displayName = "AttributeListPage"; +export default AttributeListPage; diff --git a/src/attributes/components/AttributeListPage/index.ts b/src/attributes/components/AttributeListPage/index.ts new file mode 100644 index 000000000..e044052b6 --- /dev/null +++ b/src/attributes/components/AttributeListPage/index.ts @@ -0,0 +1,2 @@ +export { default } from './AttributeListPage'; +export * from './AttributeListPage'; \ No newline at end of file diff --git a/src/attributes/components/AttributePage/AttributePage.tsx b/src/attributes/components/AttributePage/AttributePage.tsx new file mode 100644 index 000000000..6cc875c90 --- /dev/null +++ b/src/attributes/components/AttributePage/AttributePage.tsx @@ -0,0 +1,160 @@ +import React from "react"; +import slugify from "slugify"; + +import AppHeader from "@saleor/components/AppHeader"; +import CardSpacer from "@saleor/components/CardSpacer"; +import { ConfirmButtonTransitionState } from "@saleor/components/ConfirmButton"; +import Container from "@saleor/components/Container"; +import Form from "@saleor/components/Form"; +import Grid from "@saleor/components/Grid"; +import PageHeader from "@saleor/components/PageHeader"; +import SaveButtonBar from "@saleor/components/SaveButtonBar"; +import i18n from "@saleor/i18n"; +import { maybe } from "@saleor/misc"; +import { ReorderAction, UserError } from "@saleor/types"; +import { AttributeInputTypeEnum } from "@saleor/types/globalTypes"; +import { + AttributeDetailsFragment, + AttributeDetailsFragment_values +} from "../../types/AttributeDetailsFragment"; +import AttributeDetails from "../AttributeDetails"; +import AttributeProperties from "../AttributeProperties"; +import AttributeValues from "../AttributeValues"; + +export interface AttributePageProps { + attribute: AttributeDetailsFragment | null; + disabled: boolean; + errors: UserError[]; + saveButtonBarState: ConfirmButtonTransitionState; + values: AttributeDetailsFragment_values[]; + onBack: () => void; + onDelete: () => void; + onSubmit: (data: AttributePageFormData) => void; + onValueAdd: () => void; + onValueDelete: (id: string) => void; + onValueReorder: ReorderAction; + onValueUpdate: (id: string) => void; +} + +export interface AttributePageFormData { + filterableInDashboard: boolean; + inputType: AttributeInputTypeEnum; + filterableInStorefront: boolean; + name: string; + slug: string; + storefrontSearchPosition: string; + valueRequired: boolean; + visibleInStorefront: boolean; +} + +const AttributePage: React.FC = ({ + attribute, + disabled, + errors, + saveButtonBarState, + values, + onBack, + onDelete, + onSubmit, + onValueAdd, + onValueDelete, + onValueReorder, + onValueUpdate +}) => { + const initialForm: AttributePageFormData = + attribute === null + ? { + filterableInDashboard: true, + filterableInStorefront: true, + inputType: AttributeInputTypeEnum.DROPDOWN, + name: "", + slug: "", + storefrontSearchPosition: "", + valueRequired: true, + visibleInStorefront: true + } + : { + filterableInDashboard: maybe( + () => attribute.filterableInDashboard, + true + ), + filterableInStorefront: maybe( + () => attribute.filterableInStorefront, + true + ), + inputType: maybe( + () => attribute.inputType, + AttributeInputTypeEnum.DROPDOWN + ), + name: maybe(() => attribute.name, ""), + slug: maybe(() => attribute.slug, ""), + storefrontSearchPosition: maybe( + () => attribute.storefrontSearchPosition.toString(), + "" + ), + valueRequired: maybe(() => attribute.valueRequired, true), + visibleInStorefront: maybe(() => attribute.visibleInStorefront, true) + }; + + const handleSubmit = (data: AttributePageFormData) => + onSubmit({ + ...data, + slug: data.slug || slugify(data.name).toLowerCase() + }); + + return ( +
+ {({ change, errors: formErrors, data, submit }) => ( + + {i18n.t("Attributes")} + attribute.name) + } + /> + +
+ + + +
+
+ +
+
+ +
+ )} +
+ ); +}; +AttributePage.displayName = "AttributePage"; +export default AttributePage; diff --git a/src/attributes/components/AttributePage/index.ts b/src/attributes/components/AttributePage/index.ts new file mode 100644 index 000000000..08880e9d8 --- /dev/null +++ b/src/attributes/components/AttributePage/index.ts @@ -0,0 +1,2 @@ +export { default } from './AttributePage'; +export * from './AttributePage'; \ No newline at end of file diff --git a/src/attributes/components/AttributeProperties/AttributeProperties.tsx b/src/attributes/components/AttributeProperties/AttributeProperties.tsx new file mode 100644 index 000000000..078d16882 --- /dev/null +++ b/src/attributes/components/AttributeProperties/AttributeProperties.tsx @@ -0,0 +1,112 @@ +import Card from "@material-ui/core/Card"; +import CardContent from "@material-ui/core/CardContent"; +import TextField from "@material-ui/core/TextField"; +import Typography from "@material-ui/core/Typography"; +import React from "react"; + +import CardSpacer from "@saleor/components/CardSpacer"; +import CardTitle from "@saleor/components/CardTitle"; +import ControlledSwitch from "@saleor/components/ControlledSwitch"; +import FormSpacer from "@saleor/components/FormSpacer"; +import Hr from "@saleor/components/Hr"; +import i18n from "@saleor/i18n"; +import { FormErrors } from "@saleor/types"; +import { AttributePageFormData } from "../AttributePage"; + +export interface AttributePropertiesProps { + data: AttributePageFormData; + disabled: boolean; + errors: FormErrors<"storefrontSearchPosition">; + onChange: (event: React.ChangeEvent) => void; +} + +const AttributeProperties: React.FC = ({ + data, + errors, + disabled, + onChange +}) => ( + + + + {/* + {i18n.t("General Properties")} + +
+ + + {i18n.t("Variant Attribute")} + + {i18n.t( + "If enabled, you'll be able to use this attribute to create product variants" + )} + + + } + onChange={onChange} + /> */} + + + {i18n.t("Storefront Properties")} + +
+ + {data.filterableInStorefront && ( + + )} + + + + + + {i18n.t("Dashboard Properties")} + +
+ + + {i18n.t( + "If enabled, you’ll be able to use this attribute to filter products in product list." + )} + + } + onChange={onChange} + /> +
+
+); +AttributeProperties.displayName = "AttributeProperties"; +export default AttributeProperties; diff --git a/src/attributes/components/AttributeProperties/index.ts b/src/attributes/components/AttributeProperties/index.ts new file mode 100644 index 000000000..4f02c2478 --- /dev/null +++ b/src/attributes/components/AttributeProperties/index.ts @@ -0,0 +1,2 @@ +export { default } from './AttributeProperties'; +export * from './AttributeProperties'; \ No newline at end of file diff --git a/src/attributes/components/AttributeValueDeleteDialog/AttributeValueDeleteDialog.tsx b/src/attributes/components/AttributeValueDeleteDialog/AttributeValueDeleteDialog.tsx new file mode 100644 index 000000000..3edd078ee --- /dev/null +++ b/src/attributes/components/AttributeValueDeleteDialog/AttributeValueDeleteDialog.tsx @@ -0,0 +1,52 @@ +import DialogContentText from "@material-ui/core/DialogContentText"; +import React from "react"; + +import ActionDialog from "@saleor/components/ActionDialog"; +import { ConfirmButtonTransitionState } from "@saleor/components/ConfirmButton"; +import i18n from "@saleor/i18n"; + +export interface AttributeValueDeleteDialogProps { + attributeName: string; + confirmButtonState: ConfirmButtonTransitionState; + open: boolean; + name: string; + useName?: boolean; + onConfirm: () => void; + onClose: () => void; +} + +const AttributeValueDeleteDialog: React.FC = ({ + attributeName, + name, + confirmButtonState, + useName, + onClose, + onConfirm, + open +}) => ( + + + {useName + ? i18n.t( + 'Are you sure you want to remove "{{ name }}" value? If you remove it you won’t be able to assign it to any of the products with "{{ attributeName }}" attribute.', + { + attributeName, + name + } + ) + : i18n.t('Are you sure you want to remove "{{ name }}" value?', { + name + })} + + +); + +AttributeValueDeleteDialog.displayName = "AttributeValueDeleteDialog"; +export default AttributeValueDeleteDialog; diff --git a/src/attributes/components/AttributeValueDeleteDialog/index.ts b/src/attributes/components/AttributeValueDeleteDialog/index.ts new file mode 100644 index 000000000..b58e72f50 --- /dev/null +++ b/src/attributes/components/AttributeValueDeleteDialog/index.ts @@ -0,0 +1,2 @@ +export { default } from './AttributeValueDeleteDialog'; +export * from './AttributeValueDeleteDialog'; \ No newline at end of file diff --git a/src/attributes/components/AttributeValueEditDialog/AttributeValueEditDialog.tsx b/src/attributes/components/AttributeValueEditDialog/AttributeValueEditDialog.tsx new file mode 100644 index 000000000..7bb83ca00 --- /dev/null +++ b/src/attributes/components/AttributeValueEditDialog/AttributeValueEditDialog.tsx @@ -0,0 +1,97 @@ +import Button from "@material-ui/core/Button"; +import Dialog from "@material-ui/core/Dialog"; +import DialogActions from "@material-ui/core/DialogActions"; +import DialogContent from "@material-ui/core/DialogContent"; +import DialogTitle from "@material-ui/core/DialogTitle"; +import TextField from "@material-ui/core/TextField"; +import React from "react"; + +import ConfirmButton, { + ConfirmButtonTransitionState +} from "@saleor/components/ConfirmButton"; +import Form from "@saleor/components/Form"; +import useModalDialogErrors from "@saleor/hooks/useModalDialogErrors"; +import i18n from "@saleor/i18n"; +import { maybe } from "@saleor/misc"; +import { UserError } from "@saleor/types"; +import { AttributeDetails_attribute_values } from "../../types/AttributeDetails"; + +export interface AttributeValueEditDialogFormData { + name: string; +} +export interface AttributeValueEditDialogProps { + attributeValue: AttributeDetails_attribute_values | null; + confirmButtonState: ConfirmButtonTransitionState; + disabled: boolean; + errors: UserError[]; + open: boolean; + onSubmit: (data: AttributeValueEditDialogFormData) => void; + onClose: () => void; +} + +const AttributeValueEditDialog: React.StatelessComponent< + AttributeValueEditDialogProps +> = ({ + attributeValue, + confirmButtonState, + disabled, + errors: apiErrors, + onClose, + onSubmit, + open +}) => { + const initialForm: AttributeValueEditDialogFormData = { + name: maybe(() => attributeValue.name, "") + }; + const errors = useModalDialogErrors(apiErrors, open); + + return ( + + + {attributeValue === null + ? i18n.t("Add Value", { + context: "add attribute value" + }) + : i18n.t("Edit Value", { + context: "edit attribute value" + })} + +
+ {({ change, data, errors: formErrors, submit }) => ( + <> + + + + + + + {i18n.t("Save")} + + + + )} +
+
+ ); +}; +AttributeValueEditDialog.displayName = "AttributeValueEditDialog"; +export default AttributeValueEditDialog; diff --git a/src/attributes/components/AttributeValueEditDialog/index.ts b/src/attributes/components/AttributeValueEditDialog/index.ts new file mode 100644 index 000000000..1dfc3d061 --- /dev/null +++ b/src/attributes/components/AttributeValueEditDialog/index.ts @@ -0,0 +1,2 @@ +export { default } from './AttributeValueEditDialog'; +export * from './AttributeValueEditDialog'; \ No newline at end of file diff --git a/src/attributes/components/AttributeValues/AttributeValues.tsx b/src/attributes/components/AttributeValues/AttributeValues.tsx new file mode 100644 index 000000000..4d6f3c444 --- /dev/null +++ b/src/attributes/components/AttributeValues/AttributeValues.tsx @@ -0,0 +1,129 @@ +import Button from "@material-ui/core/Button"; +import Card from "@material-ui/core/Card"; +import IconButton from "@material-ui/core/IconButton"; +import { Theme } from "@material-ui/core/styles"; +import Table from "@material-ui/core/Table"; +import TableCell from "@material-ui/core/TableCell"; +import TableHead from "@material-ui/core/TableHead"; +import TableRow from "@material-ui/core/TableRow"; +import DeleteIcon from "@material-ui/icons/Delete"; +import makeStyles from "@material-ui/styles/makeStyles"; +import React from "react"; + +import CardTitle from "@saleor/components/CardTitle"; +import Skeleton from "@saleor/components/Skeleton"; +import { + SortableTableBody, + SortableTableRow +} from "@saleor/components/SortableTable"; +import i18n from "@saleor/i18n"; +import { maybe, renderCollection, stopPropagation } from "@saleor/misc"; +import { ReorderAction } from "@saleor/types"; +import { AttributeDetailsFragment_values } from "../../types/AttributeDetailsFragment"; + +export interface AttributeValuesProps { + disabled: boolean; + values: AttributeDetailsFragment_values[]; + onValueAdd: () => void; + onValueDelete: (id: string) => void; + onValueReorder: ReorderAction; + onValueUpdate: (id: string) => void; +} + +const useStyles = makeStyles((theme: Theme) => ({ + columnAdmin: { + width: "50%" + }, + columnDrag: { + width: 48 + theme.spacing.unit * 1.5 + }, + columnStore: { + width: "50%" + }, + dragIcon: { + cursor: "grab" + }, + iconCell: { + "&:last-child": { + paddingRight: theme.spacing.unit + }, + width: 48 + theme.spacing.unit * 1.5 + }, + link: { + cursor: "pointer" + } +})); + +const AttributeValues: React.FC = ({ + disabled, + onValueAdd, + onValueDelete, + onValueReorder, + onValueUpdate, + values +}) => { + const classes = useStyles({}); + + return ( + + + {i18n.t("Add value", { context: "button" })} + + } + /> + + + + + + {i18n.t("Admin")} + + + {i18n.t("Default Store View")} + + + + + + {renderCollection( + values, + (value, valueIndex) => ( + onValueUpdate(value.id) : undefined} + key={maybe(() => value.id)} + index={valueIndex || 0} + > + + {maybe(() => value.slug) ? value.slug : } + + + {maybe(() => value.name) ? value.name : } + + + onValueDelete(value.id))} + > + + + + + ), + () => ( + + {i18n.t("No values found")} + + ) + )} + +
+
+ ); +}; +AttributeValues.displayName = "AttributeValues"; +export default AttributeValues; diff --git a/src/attributes/components/AttributeValues/index.ts b/src/attributes/components/AttributeValues/index.ts new file mode 100644 index 000000000..dae7f6baf --- /dev/null +++ b/src/attributes/components/AttributeValues/index.ts @@ -0,0 +1,2 @@ +export { default } from './AttributeValues'; +export * from './AttributeValues'; \ No newline at end of file diff --git a/src/attributes/fixtures.ts b/src/attributes/fixtures.ts new file mode 100644 index 000000000..b55cfa134 --- /dev/null +++ b/src/attributes/fixtures.ts @@ -0,0 +1,512 @@ +import { + AttributeInputTypeEnum, + AttributeValueType +} from "@saleor/types/globalTypes"; +import { AttributeList_attributes_edges_node } from "./types/AttributeList"; + +export const attribute = { + __typename: "Attribute" as "Attribute", + filterableInDashboard: false, + filterableInStorefront: true, + id: "UHJvZHVjdEF0dHJpYnV0ZTo5", + inputType: AttributeInputTypeEnum.DROPDOWN, + name: "Author", + slug: "author", + storefrontSearchPosition: 2, + valueRequired: true, + values: [ + { + __typename: "AttributeValue" as "AttributeValue", + id: "UHJvZHVjdEF0dHJpYnV0ZVZhbHVlOjI0", + name: "John Doe", + slug: "john-doe", + sortOrder: 0, + type: AttributeValueType.STRING, + value: "" + }, + { + __typename: "AttributeValue" as "AttributeValue", + id: "UHJvZHVjdEF0dHJpYnV0ZVZhbHVlOjI1", + name: "Milionare Pirate", + slug: "milionare-pirate", + sortOrder: 1, + type: AttributeValueType.STRING, + value: "" + } + ], + visibleInStorefront: true +}; + +export const attributes: AttributeList_attributes_edges_node[] = [ + { + node: { + __typename: "Attribute" as "Attribute", + filterableInDashboard: true, + filterableInStorefront: false, + id: "UHJvZHVjdEF0dHJpYnV0ZTo5", + name: "Author", + slug: "author", + values: [ + { + __typename: "AttributeValue" as "AttributeValue", + id: "UHJvZHVjdEF0dHJpYnV0ZVZhbHVlOjI0", + name: "John Doe", + slug: "john-doe", + sortOrder: 0, + type: AttributeValueType.STRING, + value: "" + }, + { + __typename: "AttributeValue" as "AttributeValue", + id: "UHJvZHVjdEF0dHJpYnV0ZVZhbHVlOjI1", + name: "Milionare Pirate", + slug: "milionare-pirate", + sortOrder: 1, + type: AttributeValueType.STRING, + value: "" + } + ], + visibleInStorefront: true + } + }, + { + node: { + __typename: "Attribute" as "Attribute", + filterableInDashboard: true, + filterableInStorefront: false, + id: "UHJvZHVjdEF0dHJpYnV0ZTo2", + name: "Box Size", + slug: "box-size", + values: [ + { + __typename: "AttributeValue" as "AttributeValue", + id: "UHJvZHVjdEF0dHJpYnV0ZVZhbHVlOjE1", + name: "100g", + slug: "100g", + sortOrder: 0, + type: AttributeValueType.STRING, + value: "" + }, + { + __typename: "AttributeValue" as "AttributeValue", + id: "UHJvZHVjdEF0dHJpYnV0ZVZhbHVlOjE2", + name: "250g", + slug: "250g", + sortOrder: 1, + type: AttributeValueType.STRING, + value: "" + }, + { + __typename: "AttributeValue" as "AttributeValue", + id: "UHJvZHVjdEF0dHJpYnV0ZVZhbHVlOjE3", + name: "500g", + slug: "500g", + sortOrder: 2, + type: AttributeValueType.STRING, + value: "" + }, + { + __typename: "AttributeValue" as "AttributeValue", + id: "UHJvZHVjdEF0dHJpYnV0ZVZhbHVlOjE4", + name: "1kg", + slug: "1kg", + sortOrder: 3, + type: AttributeValueType.STRING, + value: "" + } + ], + visibleInStorefront: false + } + }, + { + node: { + __typename: "Attribute" as "Attribute", + filterableInDashboard: false, + filterableInStorefront: true, + id: "UHJvZHVjdEF0dHJpYnV0ZToz", + name: "Brand", + slug: "brand", + values: [ + { + __typename: "AttributeValue" as "AttributeValue", + id: "UHJvZHVjdEF0dHJpYnV0ZVZhbHVlOjY=", + name: "Saleor", + slug: "saleor", + sortOrder: 0, + type: AttributeValueType.STRING, + value: "" + } + ], + visibleInStorefront: false + } + }, + { + node: { + __typename: "Attribute" as "Attribute", + filterableInDashboard: true, + filterableInStorefront: true, + id: "UHJvZHVjdEF0dHJpYnV0ZTo4", + name: "Candy Box Size", + slug: "candy-box-size", + values: [ + { + __typename: "AttributeValue" as "AttributeValue", + id: "UHJvZHVjdEF0dHJpYnV0ZVZhbHVlOjIx", + name: "100g", + slug: "100g", + sortOrder: 0, + type: AttributeValueType.STRING, + value: "" + }, + { + __typename: "AttributeValue" as "AttributeValue", + id: "UHJvZHVjdEF0dHJpYnV0ZVZhbHVlOjIy", + name: "250g", + slug: "250g", + sortOrder: 1, + type: AttributeValueType.STRING, + value: "" + }, + { + __typename: "AttributeValue" as "AttributeValue", + id: "UHJvZHVjdEF0dHJpYnV0ZVZhbHVlOjIz", + name: "500g", + slug: "500g", + sortOrder: 2, + type: AttributeValueType.STRING, + value: "" + } + ], + visibleInStorefront: false + } + }, + { + node: { + __typename: "Attribute" as "Attribute", + filterableInDashboard: true, + filterableInStorefront: true, + id: "UHJvZHVjdEF0dHJpYnV0ZTo1", + name: "Coffee Genre", + slug: "coffee-genre", + values: [ + { + __typename: "AttributeValue" as "AttributeValue", + id: "UHJvZHVjdEF0dHJpYnV0ZVZhbHVlOjEz", + name: "Arabica", + slug: "arabica", + sortOrder: 0, + type: AttributeValueType.STRING, + value: "" + }, + { + __typename: "AttributeValue" as "AttributeValue", + id: "UHJvZHVjdEF0dHJpYnV0ZVZhbHVlOjE0", + name: "Robusta", + slug: "robusta", + sortOrder: 1, + type: AttributeValueType.STRING, + value: "" + } + ], + visibleInStorefront: true + } + }, + { + node: { + __typename: "Attribute" as "Attribute", + filterableInDashboard: false, + filterableInStorefront: true, + id: "UHJvZHVjdEF0dHJpYnV0ZToy", + name: "Collar", + slug: "collar", + values: [ + { + __typename: "AttributeValue" as "AttributeValue", + id: "UHJvZHVjdEF0dHJpYnV0ZVZhbHVlOjM=", + name: "Round", + slug: "round", + sortOrder: 0, + type: AttributeValueType.STRING, + value: "" + }, + { + __typename: "AttributeValue" as "AttributeValue", + id: "UHJvZHVjdEF0dHJpYnV0ZVZhbHVlOjQ=", + name: "V-Neck", + slug: "v-neck", + sortOrder: 1, + type: AttributeValueType.STRING, + value: "" + }, + { + __typename: "AttributeValue" as "AttributeValue", + id: "UHJvZHVjdEF0dHJpYnV0ZVZhbHVlOjU=", + name: "Polo", + slug: "polo", + sortOrder: 2, + type: AttributeValueType.STRING, + value: "" + } + ], + visibleInStorefront: true + } + }, + { + node: { + __typename: "Attribute" as "Attribute", + filterableInDashboard: false, + filterableInStorefront: false, + id: "UHJvZHVjdEF0dHJpYnV0ZTox", + name: "Color", + slug: "color", + values: [ + { + __typename: "AttributeValue" as "AttributeValue", + id: "UHJvZHVjdEF0dHJpYnV0ZVZhbHVlOjE=", + name: "Blue", + slug: "blue", + sortOrder: 0, + type: AttributeValueType.STRING, + value: "" + }, + { + __typename: "AttributeValue" as "AttributeValue", + id: "UHJvZHVjdEF0dHJpYnV0ZVZhbHVlOjI=", + name: "White", + slug: "white", + sortOrder: 1, + type: AttributeValueType.STRING, + value: "" + } + ], + visibleInStorefront: true + } + }, + { + node: { + __typename: "Attribute" as "Attribute", + filterableInDashboard: true, + filterableInStorefront: false, + id: "UHJvZHVjdEF0dHJpYnV0ZToxMg==", + name: "Cover", + slug: "cover", + values: [ + { + __typename: "AttributeValue" as "AttributeValue", + id: "UHJvZHVjdEF0dHJpYnV0ZVZhbHVlOjMw", + name: "Soft", + slug: "soft", + sortOrder: 0, + type: AttributeValueType.STRING, + value: "" + }, + { + __typename: "AttributeValue" as "AttributeValue", + id: "UHJvZHVjdEF0dHJpYnV0ZVZhbHVlOjMx", + name: "Hard", + slug: "hard", + sortOrder: 1, + type: AttributeValueType.STRING, + value: "" + }, + { + __typename: "AttributeValue" as "AttributeValue", + id: "UHJvZHVjdEF0dHJpYnV0ZVZhbHVlOjMy", + name: "Middle soft", + slug: "middle-soft", + sortOrder: 2, + type: AttributeValueType.STRING, + value: "" + }, + { + __typename: "AttributeValue" as "AttributeValue", + id: "UHJvZHVjdEF0dHJpYnV0ZVZhbHVlOjMz", + name: "Middle hard", + slug: "middle-hard", + sortOrder: 3, + type: AttributeValueType.STRING, + value: "" + }, + { + __typename: "AttributeValue" as "AttributeValue", + id: "UHJvZHVjdEF0dHJpYnV0ZVZhbHVlOjM0", + name: "Middle", + slug: "middle", + sortOrder: 4, + type: AttributeValueType.STRING, + value: "" + }, + { + __typename: "AttributeValue" as "AttributeValue", + id: "UHJvZHVjdEF0dHJpYnV0ZVZhbHVlOjM1", + name: "Very hard", + slug: "very-hard", + sortOrder: 5, + type: AttributeValueType.STRING, + value: "" + } + ], + visibleInStorefront: false + } + }, + { + node: { + __typename: "Attribute" as "Attribute", + filterableInDashboard: true, + filterableInStorefront: true, + id: "UHJvZHVjdEF0dHJpYnV0ZTo3", + name: "Flavor", + slug: "flavor", + values: [ + { + __typename: "AttributeValue" as "AttributeValue", + id: "UHJvZHVjdEF0dHJpYnV0ZVZhbHVlOjE5", + name: "Sour", + slug: "sour", + sortOrder: 0, + type: AttributeValueType.STRING, + value: "" + }, + { + __typename: "AttributeValue" as "AttributeValue", + id: "UHJvZHVjdEF0dHJpYnV0ZVZhbHVlOjIw", + name: "Sweet", + slug: "sweet", + sortOrder: 1, + type: AttributeValueType.STRING, + value: "" + } + ], + visibleInStorefront: true + } + }, + { + node: { + __typename: "Attribute" as "Attribute", + filterableInDashboard: false, + filterableInStorefront: true, + id: "UHJvZHVjdEF0dHJpYnV0ZToxMQ==", + name: "Language", + slug: "language", + values: [ + { + __typename: "AttributeValue" as "AttributeValue", + id: "UHJvZHVjdEF0dHJpYnV0ZVZhbHVlOjI4", + name: "English", + slug: "english", + sortOrder: 0, + type: AttributeValueType.STRING, + value: "" + }, + { + __typename: "AttributeValue" as "AttributeValue", + id: "UHJvZHVjdEF0dHJpYnV0ZVZhbHVlOjI5", + name: "Pirate", + slug: "pirate", + sortOrder: 1, + type: AttributeValueType.STRING, + value: "" + } + ], + visibleInStorefront: true + } + }, + { + node: { + __typename: "Attribute" as "Attribute", + filterableInDashboard: true, + filterableInStorefront: true, + id: "UHJvZHVjdEF0dHJpYnV0ZToxMA==", + name: "Publisher", + slug: "publisher", + values: [ + { + __typename: "AttributeValue" as "AttributeValue", + id: "UHJvZHVjdEF0dHJpYnV0ZVZhbHVlOjI2", + name: "Mirumee Press", + slug: "mirumee-press", + sortOrder: 0, + type: AttributeValueType.STRING, + value: "" + }, + { + __typename: "AttributeValue" as "AttributeValue", + id: "UHJvZHVjdEF0dHJpYnV0ZVZhbHVlOjI3", + name: "Saleor Publishing", + slug: "saleor-publishing", + sortOrder: 1, + type: AttributeValueType.STRING, + value: "" + } + ], + visibleInStorefront: true + } + }, + { + node: { + __typename: "Attribute" as "Attribute", + filterableInDashboard: true, + filterableInStorefront: true, + id: "UHJvZHVjdEF0dHJpYnV0ZTo0", + name: "Size", + slug: "size", + values: [ + { + __typename: "AttributeValue" as "AttributeValue", + id: "UHJvZHVjdEF0dHJpYnV0ZVZhbHVlOjc=", + name: "XS", + slug: "xs", + sortOrder: 0, + type: AttributeValueType.STRING, + value: "" + }, + { + __typename: "AttributeValue" as "AttributeValue", + id: "UHJvZHVjdEF0dHJpYnV0ZVZhbHVlOjg=", + name: "S", + slug: "s", + sortOrder: 1, + type: AttributeValueType.STRING, + value: "" + }, + { + __typename: "AttributeValue" as "AttributeValue", + id: "UHJvZHVjdEF0dHJpYnV0ZVZhbHVlOjk=", + name: "M", + slug: "m", + sortOrder: 2, + type: AttributeValueType.STRING, + value: "" + }, + { + __typename: "AttributeValue" as "AttributeValue", + id: "UHJvZHVjdEF0dHJpYnV0ZVZhbHVlOjEw", + name: "L", + slug: "l", + sortOrder: 3, + type: AttributeValueType.STRING, + value: "" + }, + { + __typename: "AttributeValue" as "AttributeValue", + id: "UHJvZHVjdEF0dHJpYnV0ZVZhbHVlOjEx", + name: "XL", + slug: "xl", + sortOrder: 4, + type: AttributeValueType.STRING, + value: "" + }, + { + __typename: "AttributeValue" as "AttributeValue", + id: "UHJvZHVjdEF0dHJpYnV0ZVZhbHVlOjEy", + name: "XXL", + slug: "xxl", + sortOrder: 5, + type: AttributeValueType.STRING, + value: "" + } + ], + visibleInStorefront: true + } + } +].map(edge => edge.node); diff --git a/src/attributes/index.tsx b/src/attributes/index.tsx new file mode 100644 index 000000000..b98ce72ab --- /dev/null +++ b/src/attributes/index.tsx @@ -0,0 +1,55 @@ +import { parse as parseQs } from "qs"; +import React from "react"; +import { Route, RouteComponentProps, Switch } from "react-router-dom"; + +import { WindowTitle } from "../components/WindowTitle"; +import i18n from "../i18n"; +import { + attributeAddPath, + AttributeAddUrlQueryParams, + attributeListPath, + AttributeListUrlQueryParams, + attributePath, + AttributeUrlQueryParams +} from "./urls"; +import AttributeCreateComponent from "./views/AttributeCreate"; +import AttributeDetailsComponent from "./views/AttributeDetails"; +import AttributeListComponent from "./views/AttributeList"; + +const AttributeList: React.FC> = ({ location }) => { + const qs = parseQs(location.search.substr(1)); + const params: AttributeListUrlQueryParams = qs; + return ; +}; + +const AttributeCreate: React.FC> = ({ location }) => { + const qs = parseQs(location.search.substr(1)); + const params: AttributeAddUrlQueryParams = qs; + return ; +}; + +const AttributeDetails: React.FC> = ({ + location, + match +}) => { + const qs = parseQs(location.search.substr(1)); + const params: AttributeUrlQueryParams = qs; + return ( + + ); +}; + +export const AttributeSection: React.FC = () => ( + <> + + + + + + + +); +export default AttributeSection; diff --git a/src/attributes/mutations.ts b/src/attributes/mutations.ts new file mode 100644 index 000000000..b70363a7d --- /dev/null +++ b/src/attributes/mutations.ts @@ -0,0 +1,182 @@ +import gql from "graphql-tag"; + +import { TypedMutation } from "@saleor/mutations"; +import { attributeDetailsFragment } from "./queries"; +import { + AttributeBulkDelete, + AttributeBulkDeleteVariables +} from "./types/AttributeBulkDelete"; +import { + AttributeCreate, + AttributeCreateVariables +} from "./types/AttributeCreate"; +import { + AttributeDelete, + AttributeDeleteVariables +} from "./types/AttributeDelete"; +import { + AttributeUpdate, + AttributeUpdateVariables +} from "./types/AttributeUpdate"; +import { + AttributeValueCreate, + AttributeValueCreateVariables +} from "./types/AttributeValueCreate"; +import { + AttributeValueDelete, + AttributeValueDeleteVariables +} from "./types/AttributeValueDelete"; +import { + AttributeValueReorder, + AttributeValueReorderVariables +} from "./types/AttributeValueReorder"; +import { + AttributeValueUpdate, + AttributeValueUpdateVariables +} from "./types/AttributeValueUpdate"; + +const attributeBulkDelete = gql` + mutation AttributeBulkDelete($ids: [ID!]!) { + attributeBulkDelete(ids: $ids) { + errors { + field + message + } + } + } +`; +export const AttributeBulkDeleteMutation = TypedMutation< + AttributeBulkDelete, + AttributeBulkDeleteVariables +>(attributeBulkDelete); + +const attributeDelete = gql` + mutation AttributeDelete($id: ID!) { + attributeDelete(id: $id) { + errors { + field + message + } + } + } +`; +export const AttributeDeleteMutation = TypedMutation< + AttributeDelete, + AttributeDeleteVariables +>(attributeDelete); + +export const attributeUpdateMutation = gql` + ${attributeDetailsFragment} + mutation AttributeUpdate($id: ID!, $input: AttributeUpdateInput!) { + attributeUpdate(id: $id, input: $input) { + errors { + field + message + } + attribute { + ...AttributeDetailsFragment + } + } + } +`; +export const AttributeUpdateMutation = TypedMutation< + AttributeUpdate, + AttributeUpdateVariables +>(attributeUpdateMutation); + +const attributeValueDelete = gql` + ${attributeDetailsFragment} + mutation AttributeValueDelete($id: ID!) { + attributeValueDelete(id: $id) { + errors { + field + message + } + attribute { + ...AttributeDetailsFragment + } + } + } +`; +export const AttributeValueDeleteMutation = TypedMutation< + AttributeValueDelete, + AttributeValueDeleteVariables +>(attributeValueDelete); + +export const attributeValueUpdateMutation = gql` + ${attributeDetailsFragment} + mutation AttributeValueUpdate($id: ID!, $input: AttributeValueCreateInput!) { + attributeValueUpdate(id: $id, input: $input) { + errors { + field + message + } + attribute { + ...AttributeDetailsFragment + } + } + } +`; +export const AttributeValueUpdateMutation = TypedMutation< + AttributeValueUpdate, + AttributeValueUpdateVariables +>(attributeValueUpdateMutation); + +export const attributeValueCreateMutation = gql` + ${attributeDetailsFragment} + mutation AttributeValueCreate($id: ID!, $input: AttributeValueCreateInput!) { + attributeValueCreate(attribute: $id, input: $input) { + errors { + field + message + } + attribute { + ...AttributeDetailsFragment + } + } + } +`; +export const AttributeValueCreateMutation = TypedMutation< + AttributeValueCreate, + AttributeValueCreateVariables +>(attributeValueCreateMutation); + +export const attributeCreateMutation = gql` + ${attributeDetailsFragment} + mutation AttributeCreate($input: AttributeCreateInput!) { + attributeCreate(input: $input) { + errors { + field + message + } + attribute { + ...AttributeDetailsFragment + } + } + } +`; +export const AttributeCreateMutation = TypedMutation< + AttributeCreate, + AttributeCreateVariables +>(attributeCreateMutation); + +const attributeValueReorderMutation = gql` + mutation AttributeValueReorder($id: ID!, $move: ReorderInput!) { + attributeReorderValues(attributeId: $id, moves: [$move]) { + errors { + field + message + } + attribute { + id + values { + id + } + } + } + } +`; +export const AttributeValueReorderMutation = TypedMutation< + AttributeValueReorder, + AttributeValueReorderVariables +>(attributeValueReorderMutation); diff --git a/src/attributes/queries.ts b/src/attributes/queries.ts new file mode 100644 index 000000000..e18d7c8c0 --- /dev/null +++ b/src/attributes/queries.ts @@ -0,0 +1,86 @@ +import gql from "graphql-tag"; + +import { pageInfoFragment, TypedQuery } from "../queries"; +import { + AttributeDetails, + AttributeDetailsVariables +} from "./types/AttributeDetails"; +import { AttributeList, AttributeListVariables } from "./types/AttributeList"; + +export const attributeFragment = gql` + fragment AttributeFragment on Attribute { + id + name + slug + visibleInStorefront + filterableInDashboard + filterableInStorefront + } +`; + +export const attributeDetailsFragment = gql` + ${attributeFragment} + fragment AttributeDetailsFragment on Attribute { + ...AttributeFragment + inputType + storefrontSearchPosition + valueRequired + values { + id + name + slug + type + value + } + } +`; + +const attributeDetails = gql` + ${attributeDetailsFragment} + query AttributeDetails($id: ID!) { + attribute(id: $id) { + ...AttributeDetailsFragment + } + } +`; +export const AttributeDetailsQuery = TypedQuery< + AttributeDetails, + AttributeDetailsVariables +>(attributeDetails); + +const attributeList = gql` + ${attributeFragment} + ${pageInfoFragment} + query AttributeList( + $query: String + $inCategory: ID + $inCollection: ID + $before: String + $after: String + $first: Int + $last: Int + ) { + attributes( + query: $query + inCategory: $inCategory + inCollection: $inCollection + before: $before + after: $after + first: $first + last: $last + ) { + edges { + node { + ...AttributeFragment + } + } + pageInfo { + ...PageInfoFragment + } + } + } +`; +export const AttributeListQuery = TypedQuery< + AttributeList, + AttributeListVariables +>(attributeList); diff --git a/src/attributes/types/AttributeBulkDelete.ts b/src/attributes/types/AttributeBulkDelete.ts new file mode 100644 index 000000000..06ff83fd7 --- /dev/null +++ b/src/attributes/types/AttributeBulkDelete.ts @@ -0,0 +1,26 @@ +/* tslint:disable */ +/* eslint-disable */ +// This file was automatically generated and should not be edited. + +// ==================================================== +// GraphQL mutation operation: AttributeBulkDelete +// ==================================================== + +export interface AttributeBulkDelete_attributeBulkDelete_errors { + __typename: "Error"; + field: string | null; + message: string | null; +} + +export interface AttributeBulkDelete_attributeBulkDelete { + __typename: "AttributeBulkDelete"; + errors: AttributeBulkDelete_attributeBulkDelete_errors[] | null; +} + +export interface AttributeBulkDelete { + attributeBulkDelete: AttributeBulkDelete_attributeBulkDelete | null; +} + +export interface AttributeBulkDeleteVariables { + ids: string[]; +} diff --git a/src/attributes/types/AttributeCreate.ts b/src/attributes/types/AttributeCreate.ts new file mode 100644 index 000000000..9d8eab0a0 --- /dev/null +++ b/src/attributes/types/AttributeCreate.ts @@ -0,0 +1,52 @@ +/* tslint:disable */ +/* eslint-disable */ +// This file was automatically generated and should not be edited. + +import { AttributeCreateInput, AttributeInputTypeEnum, AttributeValueType } from "./../../types/globalTypes"; + +// ==================================================== +// GraphQL mutation operation: AttributeCreate +// ==================================================== + +export interface AttributeCreate_attributeCreate_errors { + __typename: "Error"; + field: string | null; + message: string | null; +} + +export interface AttributeCreate_attributeCreate_attribute_values { + __typename: "AttributeValue"; + id: string; + name: string | null; + slug: string | null; + type: AttributeValueType | null; + value: string | null; +} + +export interface AttributeCreate_attributeCreate_attribute { + __typename: "Attribute"; + id: string; + name: string | null; + slug: string | null; + visibleInStorefront: boolean; + filterableInDashboard: boolean; + filterableInStorefront: boolean; + inputType: AttributeInputTypeEnum | null; + storefrontSearchPosition: number; + valueRequired: boolean; + values: (AttributeCreate_attributeCreate_attribute_values | null)[] | null; +} + +export interface AttributeCreate_attributeCreate { + __typename: "AttributeCreate"; + errors: AttributeCreate_attributeCreate_errors[] | null; + attribute: AttributeCreate_attributeCreate_attribute | null; +} + +export interface AttributeCreate { + attributeCreate: AttributeCreate_attributeCreate | null; +} + +export interface AttributeCreateVariables { + input: AttributeCreateInput; +} diff --git a/src/attributes/types/AttributeDelete.ts b/src/attributes/types/AttributeDelete.ts new file mode 100644 index 000000000..2cd44da90 --- /dev/null +++ b/src/attributes/types/AttributeDelete.ts @@ -0,0 +1,26 @@ +/* tslint:disable */ +/* eslint-disable */ +// This file was automatically generated and should not be edited. + +// ==================================================== +// GraphQL mutation operation: AttributeDelete +// ==================================================== + +export interface AttributeDelete_attributeDelete_errors { + __typename: "Error"; + field: string | null; + message: string | null; +} + +export interface AttributeDelete_attributeDelete { + __typename: "AttributeDelete"; + errors: AttributeDelete_attributeDelete_errors[] | null; +} + +export interface AttributeDelete { + attributeDelete: AttributeDelete_attributeDelete | null; +} + +export interface AttributeDeleteVariables { + id: string; +} diff --git a/src/attributes/types/AttributeDetails.ts b/src/attributes/types/AttributeDetails.ts new file mode 100644 index 000000000..b1826d42e --- /dev/null +++ b/src/attributes/types/AttributeDetails.ts @@ -0,0 +1,40 @@ +/* tslint:disable */ +/* eslint-disable */ +// This file was automatically generated and should not be edited. + +import { AttributeInputTypeEnum, AttributeValueType } from "./../../types/globalTypes"; + +// ==================================================== +// GraphQL query operation: AttributeDetails +// ==================================================== + +export interface AttributeDetails_attribute_values { + __typename: "AttributeValue"; + id: string; + name: string | null; + slug: string | null; + type: AttributeValueType | null; + value: string | null; +} + +export interface AttributeDetails_attribute { + __typename: "Attribute"; + id: string; + name: string | null; + slug: string | null; + visibleInStorefront: boolean; + filterableInDashboard: boolean; + filterableInStorefront: boolean; + inputType: AttributeInputTypeEnum | null; + storefrontSearchPosition: number; + valueRequired: boolean; + values: (AttributeDetails_attribute_values | null)[] | null; +} + +export interface AttributeDetails { + attribute: AttributeDetails_attribute | null; +} + +export interface AttributeDetailsVariables { + id: string; +} diff --git a/src/attributes/types/AttributeDetailsFragment.ts b/src/attributes/types/AttributeDetailsFragment.ts new file mode 100644 index 000000000..f4c9375f2 --- /dev/null +++ b/src/attributes/types/AttributeDetailsFragment.ts @@ -0,0 +1,32 @@ +/* tslint:disable */ +/* eslint-disable */ +// This file was automatically generated and should not be edited. + +import { AttributeInputTypeEnum, AttributeValueType } from "./../../types/globalTypes"; + +// ==================================================== +// GraphQL fragment: AttributeDetailsFragment +// ==================================================== + +export interface AttributeDetailsFragment_values { + __typename: "AttributeValue"; + id: string; + name: string | null; + slug: string | null; + type: AttributeValueType | null; + value: string | null; +} + +export interface AttributeDetailsFragment { + __typename: "Attribute"; + id: string; + name: string | null; + slug: string | null; + visibleInStorefront: boolean; + filterableInDashboard: boolean; + filterableInStorefront: boolean; + inputType: AttributeInputTypeEnum | null; + storefrontSearchPosition: number; + valueRequired: boolean; + values: (AttributeDetailsFragment_values | null)[] | null; +} diff --git a/src/attributes/types/AttributeFragment.ts b/src/attributes/types/AttributeFragment.ts new file mode 100644 index 000000000..333736761 --- /dev/null +++ b/src/attributes/types/AttributeFragment.ts @@ -0,0 +1,17 @@ +/* tslint:disable */ +/* eslint-disable */ +// This file was automatically generated and should not be edited. + +// ==================================================== +// GraphQL fragment: AttributeFragment +// ==================================================== + +export interface AttributeFragment { + __typename: "Attribute"; + id: string; + name: string | null; + slug: string | null; + visibleInStorefront: boolean; + filterableInDashboard: boolean; + filterableInStorefront: boolean; +} diff --git a/src/attributes/types/AttributeList.ts b/src/attributes/types/AttributeList.ts new file mode 100644 index 000000000..938c0098d --- /dev/null +++ b/src/attributes/types/AttributeList.ts @@ -0,0 +1,50 @@ +/* tslint:disable */ +/* eslint-disable */ +// This file was automatically generated and should not be edited. + +// ==================================================== +// GraphQL query operation: AttributeList +// ==================================================== + +export interface AttributeList_attributes_edges_node { + __typename: "Attribute"; + id: string; + name: string | null; + slug: string | null; + visibleInStorefront: boolean; + filterableInDashboard: boolean; + filterableInStorefront: boolean; +} + +export interface AttributeList_attributes_edges { + __typename: "AttributeCountableEdge"; + node: AttributeList_attributes_edges_node; +} + +export interface AttributeList_attributes_pageInfo { + __typename: "PageInfo"; + endCursor: string | null; + hasNextPage: boolean; + hasPreviousPage: boolean; + startCursor: string | null; +} + +export interface AttributeList_attributes { + __typename: "AttributeCountableConnection"; + edges: AttributeList_attributes_edges[]; + pageInfo: AttributeList_attributes_pageInfo; +} + +export interface AttributeList { + attributes: AttributeList_attributes | null; +} + +export interface AttributeListVariables { + query?: string | null; + inCategory?: string | null; + inCollection?: string | null; + before?: string | null; + after?: string | null; + first?: number | null; + last?: number | null; +} diff --git a/src/attributes/types/AttributeUpdate.ts b/src/attributes/types/AttributeUpdate.ts new file mode 100644 index 000000000..7ec2e5e7c --- /dev/null +++ b/src/attributes/types/AttributeUpdate.ts @@ -0,0 +1,53 @@ +/* tslint:disable */ +/* eslint-disable */ +// This file was automatically generated and should not be edited. + +import { AttributeUpdateInput, AttributeInputTypeEnum, AttributeValueType } from "./../../types/globalTypes"; + +// ==================================================== +// GraphQL mutation operation: AttributeUpdate +// ==================================================== + +export interface AttributeUpdate_attributeUpdate_errors { + __typename: "Error"; + field: string | null; + message: string | null; +} + +export interface AttributeUpdate_attributeUpdate_attribute_values { + __typename: "AttributeValue"; + id: string; + name: string | null; + slug: string | null; + type: AttributeValueType | null; + value: string | null; +} + +export interface AttributeUpdate_attributeUpdate_attribute { + __typename: "Attribute"; + id: string; + name: string | null; + slug: string | null; + visibleInStorefront: boolean; + filterableInDashboard: boolean; + filterableInStorefront: boolean; + inputType: AttributeInputTypeEnum | null; + storefrontSearchPosition: number; + valueRequired: boolean; + values: (AttributeUpdate_attributeUpdate_attribute_values | null)[] | null; +} + +export interface AttributeUpdate_attributeUpdate { + __typename: "AttributeUpdate"; + errors: AttributeUpdate_attributeUpdate_errors[] | null; + attribute: AttributeUpdate_attributeUpdate_attribute | null; +} + +export interface AttributeUpdate { + attributeUpdate: AttributeUpdate_attributeUpdate | null; +} + +export interface AttributeUpdateVariables { + id: string; + input: AttributeUpdateInput; +} diff --git a/src/attributes/types/AttributeValueCreate.ts b/src/attributes/types/AttributeValueCreate.ts new file mode 100644 index 000000000..c5450d981 --- /dev/null +++ b/src/attributes/types/AttributeValueCreate.ts @@ -0,0 +1,53 @@ +/* tslint:disable */ +/* eslint-disable */ +// This file was automatically generated and should not be edited. + +import { AttributeValueCreateInput, AttributeInputTypeEnum, AttributeValueType } from "./../../types/globalTypes"; + +// ==================================================== +// GraphQL mutation operation: AttributeValueCreate +// ==================================================== + +export interface AttributeValueCreate_attributeValueCreate_errors { + __typename: "Error"; + field: string | null; + message: string | null; +} + +export interface AttributeValueCreate_attributeValueCreate_attribute_values { + __typename: "AttributeValue"; + id: string; + name: string | null; + slug: string | null; + type: AttributeValueType | null; + value: string | null; +} + +export interface AttributeValueCreate_attributeValueCreate_attribute { + __typename: "Attribute"; + id: string; + name: string | null; + slug: string | null; + visibleInStorefront: boolean; + filterableInDashboard: boolean; + filterableInStorefront: boolean; + inputType: AttributeInputTypeEnum | null; + storefrontSearchPosition: number; + valueRequired: boolean; + values: (AttributeValueCreate_attributeValueCreate_attribute_values | null)[] | null; +} + +export interface AttributeValueCreate_attributeValueCreate { + __typename: "AttributeValueCreate"; + errors: AttributeValueCreate_attributeValueCreate_errors[] | null; + attribute: AttributeValueCreate_attributeValueCreate_attribute | null; +} + +export interface AttributeValueCreate { + attributeValueCreate: AttributeValueCreate_attributeValueCreate | null; +} + +export interface AttributeValueCreateVariables { + id: string; + input: AttributeValueCreateInput; +} diff --git a/src/attributes/types/AttributeValueDelete.ts b/src/attributes/types/AttributeValueDelete.ts new file mode 100644 index 000000000..10c5924e0 --- /dev/null +++ b/src/attributes/types/AttributeValueDelete.ts @@ -0,0 +1,52 @@ +/* tslint:disable */ +/* eslint-disable */ +// This file was automatically generated and should not be edited. + +import { AttributeInputTypeEnum, AttributeValueType } from "./../../types/globalTypes"; + +// ==================================================== +// GraphQL mutation operation: AttributeValueDelete +// ==================================================== + +export interface AttributeValueDelete_attributeValueDelete_errors { + __typename: "Error"; + field: string | null; + message: string | null; +} + +export interface AttributeValueDelete_attributeValueDelete_attribute_values { + __typename: "AttributeValue"; + id: string; + name: string | null; + slug: string | null; + type: AttributeValueType | null; + value: string | null; +} + +export interface AttributeValueDelete_attributeValueDelete_attribute { + __typename: "Attribute"; + id: string; + name: string | null; + slug: string | null; + visibleInStorefront: boolean; + filterableInDashboard: boolean; + filterableInStorefront: boolean; + inputType: AttributeInputTypeEnum | null; + storefrontSearchPosition: number; + valueRequired: boolean; + values: (AttributeValueDelete_attributeValueDelete_attribute_values | null)[] | null; +} + +export interface AttributeValueDelete_attributeValueDelete { + __typename: "AttributeValueDelete"; + errors: AttributeValueDelete_attributeValueDelete_errors[] | null; + attribute: AttributeValueDelete_attributeValueDelete_attribute | null; +} + +export interface AttributeValueDelete { + attributeValueDelete: AttributeValueDelete_attributeValueDelete | null; +} + +export interface AttributeValueDeleteVariables { + id: string; +} diff --git a/src/attributes/types/AttributeValueReorder.ts b/src/attributes/types/AttributeValueReorder.ts new file mode 100644 index 000000000..492b94e7d --- /dev/null +++ b/src/attributes/types/AttributeValueReorder.ts @@ -0,0 +1,41 @@ +/* tslint:disable */ +/* eslint-disable */ +// This file was automatically generated and should not be edited. + +import { ReorderInput } from "./../../types/globalTypes"; + +// ==================================================== +// GraphQL mutation operation: AttributeValueReorder +// ==================================================== + +export interface AttributeValueReorder_attributeReorderValues_errors { + __typename: "Error"; + field: string | null; + message: string | null; +} + +export interface AttributeValueReorder_attributeReorderValues_attribute_values { + __typename: "AttributeValue"; + id: string; +} + +export interface AttributeValueReorder_attributeReorderValues_attribute { + __typename: "Attribute"; + id: string; + values: (AttributeValueReorder_attributeReorderValues_attribute_values | null)[] | null; +} + +export interface AttributeValueReorder_attributeReorderValues { + __typename: "AttributeReorderValues"; + errors: AttributeValueReorder_attributeReorderValues_errors[] | null; + attribute: AttributeValueReorder_attributeReorderValues_attribute | null; +} + +export interface AttributeValueReorder { + attributeReorderValues: AttributeValueReorder_attributeReorderValues | null; +} + +export interface AttributeValueReorderVariables { + id: string; + move: ReorderInput; +} diff --git a/src/attributes/types/AttributeValueUpdate.ts b/src/attributes/types/AttributeValueUpdate.ts new file mode 100644 index 000000000..d1ccbee1f --- /dev/null +++ b/src/attributes/types/AttributeValueUpdate.ts @@ -0,0 +1,53 @@ +/* tslint:disable */ +/* eslint-disable */ +// This file was automatically generated and should not be edited. + +import { AttributeValueCreateInput, AttributeInputTypeEnum, AttributeValueType } from "./../../types/globalTypes"; + +// ==================================================== +// GraphQL mutation operation: AttributeValueUpdate +// ==================================================== + +export interface AttributeValueUpdate_attributeValueUpdate_errors { + __typename: "Error"; + field: string | null; + message: string | null; +} + +export interface AttributeValueUpdate_attributeValueUpdate_attribute_values { + __typename: "AttributeValue"; + id: string; + name: string | null; + slug: string | null; + type: AttributeValueType | null; + value: string | null; +} + +export interface AttributeValueUpdate_attributeValueUpdate_attribute { + __typename: "Attribute"; + id: string; + name: string | null; + slug: string | null; + visibleInStorefront: boolean; + filterableInDashboard: boolean; + filterableInStorefront: boolean; + inputType: AttributeInputTypeEnum | null; + storefrontSearchPosition: number; + valueRequired: boolean; + values: (AttributeValueUpdate_attributeValueUpdate_attribute_values | null)[] | null; +} + +export interface AttributeValueUpdate_attributeValueUpdate { + __typename: "AttributeValueUpdate"; + errors: AttributeValueUpdate_attributeValueUpdate_errors[] | null; + attribute: AttributeValueUpdate_attributeValueUpdate_attribute | null; +} + +export interface AttributeValueUpdate { + attributeValueUpdate: AttributeValueUpdate_attributeValueUpdate | null; +} + +export interface AttributeValueUpdateVariables { + id: string; + input: AttributeValueCreateInput; +} diff --git a/src/attributes/urls.ts b/src/attributes/urls.ts new file mode 100644 index 000000000..ccd6c28be --- /dev/null +++ b/src/attributes/urls.ts @@ -0,0 +1,38 @@ +import { stringify as stringifyQs } from "qs"; +import urlJoin from "url-join"; + +import { BulkAction, Dialog, Pagination, SingleAction } from "../types"; + +export const attributeSection = "/attributes/"; + +export type AttributeListUrlDialog = "remove"; +export type AttributeListUrlQueryParams = BulkAction & + Dialog & + Pagination; +export const attributeListPath = attributeSection; +export const attributeListUrl = (params?: AttributeListUrlQueryParams) => + attributeListPath + "?" + stringifyQs(params); + +export type AttributeAddUrlDialog = + | "add-value" + | "edit-value" + | "remove-value" + | "remove-values"; +export type AttributeAddUrlQueryParams = Dialog & + SingleAction; +export const attributeAddPath = urlJoin(attributeSection, "add"); +export const attributeAddUrl = (params?: AttributeAddUrlQueryParams) => + attributeAddPath + "?" + stringifyQs(params); + +export type AttributeUrlDialog = + | "add-value" + | "edit-value" + | "remove" + | "remove-value" + | "remove-values"; +export type AttributeUrlQueryParams = BulkAction & + Dialog & + SingleAction; +export const attributePath = (id: string) => urlJoin(attributeSection, id); +export const attributeUrl = (id: string, params?: AttributeUrlQueryParams) => + attributePath(encodeURIComponent(id)) + "?" + stringifyQs(params); diff --git a/src/attributes/views/AttributeCreate/AttributeCreate.tsx b/src/attributes/views/AttributeCreate/AttributeCreate.tsx new file mode 100644 index 000000000..eac1dad27 --- /dev/null +++ b/src/attributes/views/AttributeCreate/AttributeCreate.tsx @@ -0,0 +1,206 @@ +import React from "react"; +import slugify from "slugify"; + +import useNavigator from "@saleor/hooks/useNavigator"; +import useNotifier from "@saleor/hooks/useNotifier"; +import i18n from "@saleor/i18n"; +import { getMutationState, maybe } from "@saleor/misc"; +import { ReorderEvent, UserError } from "@saleor/types"; +import { + add, + isSelected, + move, + remove, + updateAtIndex +} from "@saleor/utils/lists"; +import AttributePage from "../../components/AttributePage"; +import AttributeValueDeleteDialog from "../../components/AttributeValueDeleteDialog"; +import AttributeValueEditDialog, { + AttributeValueEditDialogFormData +} from "../../components/AttributeValueEditDialog"; +import { AttributeCreateMutation } from "../../mutations"; +import { AttributeCreate } from "../../types/AttributeCreate"; +import { + attributeAddUrl, + AttributeAddUrlDialog, + AttributeAddUrlQueryParams, + attributeListUrl, + attributeUrl +} from "../../urls"; + +interface AttributeDetailsProps { + params: AttributeAddUrlQueryParams; +} + +function areValuesEqual( + a: AttributeValueEditDialogFormData, + b: AttributeValueEditDialogFormData +) { + return a.name === b.name; +} + +const AttributeDetails: React.FC = ({ params }) => { + const navigate = useNavigator(); + const notify = useNotifier(); + + const [values, setValues] = React.useState< + AttributeValueEditDialogFormData[] + >([]); + const [valueErrors, setValueErrors] = React.useState([]); + + const id = params.id ? parseInt(params.id, 0) : undefined; + + const closeModal = () => + navigate( + attributeAddUrl({ + ...params, + action: undefined, + id: undefined + }), + true + ); + + const openModal = (action: AttributeAddUrlDialog, valueId?: string) => + navigate( + attributeAddUrl({ + ...params, + action, + id: valueId + }) + ); + + const handleValueDelete = () => { + setValues(remove(values[params.id], values, areValuesEqual)); + closeModal(); + }; + const handleCreate = (data: AttributeCreate) => { + if (data.attributeCreate.errors.length === 0) { + notify({ text: i18n.t("Successfully created attribute") }); + navigate(attributeUrl(data.attributeCreate.attribute.id)); + } + }; + const handleValueUpdate = (input: AttributeValueEditDialogFormData) => { + if (isSelected(input, values, areValuesEqual)) { + setValueErrors([ + { + field: "name", + message: i18n.t("A value named {{ name }} already exists", { + context: "value edit error", + name: input.name + }) + } + ]); + } else { + setValues(updateAtIndex(input, values, id)); + closeModal(); + } + }; + const handleValueCreate = (input: AttributeValueEditDialogFormData) => { + if (isSelected(input, values, areValuesEqual)) { + setValueErrors([ + { + field: "name", + message: i18n.t("A value named {{ name }} already exists", { + context: "value edit error", + name: input.name + }) + } + ]); + } else { + setValues(add(input, values)); + closeModal(); + } + }; + const handleValueReorder = ({ newIndex, oldIndex }: ReorderEvent) => + setValues(move(values[oldIndex], values, areValuesEqual, newIndex)); + + return ( + + {(attributeCreate, attributeCreateOpts) => { + const createTransitionState = getMutationState( + attributeCreateOpts.called, + attributeCreateOpts.loading, + maybe(() => attributeCreateOpts.data.attributeCreate.errors) + ); + + return ( + <> + 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={createTransitionState} + values={values.map((value, valueIndex) => ({ + __typename: "AttributeValue" as "AttributeValue", + id: valueIndex.toString(), + slug: slugify(value.name).toLowerCase(), + sortOrder: valueIndex, + type: null, + value: null, + ...value + }))} + /> + + {values.length > 0 && ( + <> + values[id].name, "...")} + confirmButtonState="default" + onClose={closeModal} + onConfirm={handleValueDelete} + /> + values[params.id])} + confirmButtonState="default" + disabled={false} + errors={valueErrors} + open={params.action === "edit-value"} + onClose={closeModal} + onSubmit={handleValueUpdate} + /> + + )} + + ); + }} + + ); +}; +AttributeDetails.displayName = "AttributeDetails"; + +export default AttributeDetails; diff --git a/src/attributes/views/AttributeCreate/index.ts b/src/attributes/views/AttributeCreate/index.ts new file mode 100644 index 000000000..d6f344ce6 --- /dev/null +++ b/src/attributes/views/AttributeCreate/index.ts @@ -0,0 +1,2 @@ +export * from "./AttributeCreate"; +export { default } from "./AttributeCreate"; diff --git a/src/attributes/views/AttributeDetails/AttributeDetails.tsx b/src/attributes/views/AttributeDetails/AttributeDetails.tsx new file mode 100644 index 000000000..c5a04e2ff --- /dev/null +++ b/src/attributes/views/AttributeDetails/AttributeDetails.tsx @@ -0,0 +1,360 @@ +import React from "react"; + +import useNavigator from "@saleor/hooks/useNavigator"; +import useNotifier from "@saleor/hooks/useNotifier"; +import i18n from "@saleor/i18n"; +import { getMutationState, maybe } from "@saleor/misc"; +import { ReorderEvent } from "@saleor/types"; +import { move } from "@saleor/utils/lists"; +import AttributeDeleteDialog from "../../components/AttributeDeleteDialog"; +import AttributePage from "../../components/AttributePage"; +import AttributeValueDeleteDialog from "../../components/AttributeValueDeleteDialog"; +import AttributeValueEditDialog from "../../components/AttributeValueEditDialog"; +import { + AttributeDeleteMutation, + AttributeUpdateMutation, + AttributeValueCreateMutation, + AttributeValueDeleteMutation, + AttributeValueReorderMutation, + AttributeValueUpdateMutation +} 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 { + attributeListUrl, + attributeUrl, + AttributeUrlDialog, + AttributeUrlQueryParams +} from "../../urls"; + +interface AttributeDetailsProps { + id: string; + params: AttributeUrlQueryParams; +} + +const AttributeDetails: React.FC = ({ id, params }) => { + const navigate = useNavigator(); + const notify = useNotifier(); + + const closeModal = () => + navigate( + attributeUrl(id, { + ...params, + action: undefined, + id: undefined, + ids: undefined + }), + true + ); + + const openModal = (action: AttributeUrlDialog, valueId?: string) => + navigate( + attributeUrl(id, { + ...params, + action, + id: valueId + }) + ); + + const handleDelete = (data: AttributeDelete) => { + if (data.attributeDelete.errors.length === 0) { + notify({ text: i18n.t("Attribute removed") }); + navigate(attributeListUrl()); + } + }; + const handleValueDelete = (data: AttributeValueDelete) => { + if (data.attributeValueDelete.errors.length === 0) { + notify({ text: i18n.t("Value removed") }); + closeModal(); + } + }; + const handleUpdate = (data: AttributeUpdate) => { + if (data.attributeUpdate.errors.length === 0) { + notify({ text: i18n.t("Saved changes") }); + } + }; + const handleValueUpdate = (data: AttributeValueUpdate) => { + if (data.attributeValueUpdate.errors.length === 0) { + notify({ text: i18n.t("Saved changes") }); + closeModal(); + } + }; + const handleValueCreate = (data: AttributeValueCreate) => { + if (data.attributeValueCreate.errors.length === 0) { + notify({ text: i18n.t("Added new value") }); + closeModal(); + } + }; + const handleValueReorderMutation = (data: AttributeValueReorder) => { + if (data.attributeReorderValues.errors.length !== 0) { + notify({ + text: i18n.t("Error: {{ errorMessage }}", { + errorMessage: data.attributeReorderValues.errors[0].message + }) + }); + } + }; + + return ( + + {({ data, loading }) => ( + + {(attributeDelete, attributeDeleteOpts) => ( + + {(attributeValueDelete, attributeValueDeleteOpts) => ( + + {(attributeUpdate, attributeUpdateOpts) => ( + + {(attributeValueUpdate, attributeValueUpdateOpts) => ( + + {(attributeValueCreate, attributeValueCreateOpts) => ( + + {attributeValueReorder => { + const deleteTransitionState = getMutationState( + attributeDeleteOpts.called, + attributeDeleteOpts.loading, + maybe( + () => + attributeDeleteOpts.data.attributeDelete + .errors + ) + ); + const deleteValueTransitionState = getMutationState( + attributeValueDeleteOpts.called, + attributeValueDeleteOpts.loading, + maybe( + () => + attributeValueDeleteOpts.data + .attributeValueDelete.errors + ) + ); + const updateTransitionState = getMutationState( + attributeUpdateOpts.called, + attributeUpdateOpts.loading, + maybe( + () => + attributeUpdateOpts.data.attributeUpdate + .errors + ) + ); + const updateValueTransitionState = getMutationState( + attributeValueUpdateOpts.called, + attributeValueUpdateOpts.loading, + maybe( + () => + attributeValueUpdateOpts.data + .attributeValueUpdate.errors + ) + ); + const createValueTransitionState = getMutationState( + attributeValueCreateOpts.called, + attributeValueCreateOpts.loading, + maybe( + () => + attributeValueCreateOpts.data + .attributeValueCreate.errors + ) + ); + + 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 ( + <> + data.attribute)} + disabled={loading} + errors={maybe( + () => + 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={updateTransitionState} + values={maybe( + () => data.attribute.values + )} + /> + data.attribute.name, + "..." + )} + confirmButtonState={deleteTransitionState} + onClose={closeModal} + onConfirm={() => + attributeDelete({ + variables: { + id + } + }) + } + /> + data.attribute.name, + "..." + )} + open={params.action === "remove-value"} + name={maybe( + () => + data.attribute.values.find( + value => params.id === value.id + ).name, + "..." + )} + useName={true} + confirmButtonState={ + deleteValueTransitionState + } + onClose={closeModal} + onConfirm={() => + attributeValueDelete({ + variables: { + id: params.id + } + }) + } + /> + + attributeValueCreateOpts.data + .attributeValueCreate.errors, + [] + )} + open={params.action === "add-value"} + onClose={closeModal} + onSubmit={input => + attributeValueCreate({ + variables: { + id, + input + } + }) + } + /> + + data.attribute.values.find( + value => params.id === value.id + ) + )} + confirmButtonState={ + updateValueTransitionState + } + disabled={loading} + errors={maybe( + () => + 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"; + +export default AttributeDetails; diff --git a/src/attributes/views/AttributeDetails/index.ts b/src/attributes/views/AttributeDetails/index.ts new file mode 100644 index 000000000..f5f9c85b8 --- /dev/null +++ b/src/attributes/views/AttributeDetails/index.ts @@ -0,0 +1,2 @@ +export * from "./AttributeDetails"; +export { default } from "./AttributeDetails"; diff --git a/src/attributes/views/AttributeList/AttributeList.tsx b/src/attributes/views/AttributeList/AttributeList.tsx new file mode 100644 index 000000000..6e311cfab --- /dev/null +++ b/src/attributes/views/AttributeList/AttributeList.tsx @@ -0,0 +1,137 @@ +import IconButton from "@material-ui/core/IconButton"; +import DeleteIcon from "@material-ui/icons/Delete"; +import React from "react"; + +import useNavigator from "@saleor/hooks/useNavigator"; +import useNotifier from "@saleor/hooks/useNotifier"; +import usePaginator, { + createPaginationState +} from "@saleor/hooks/usePaginator"; +import { PAGINATE_BY } from "../../../config"; +import useBulkActions from "../../../hooks/useBulkActions"; +import i18n from "../../../i18n"; +import { getMutationState, maybe } from "../../../misc"; +import AttributeBulkDeleteDialog from "../../components/AttributeBulkDeleteDialog"; +import AttributeListPage from "../../components/AttributeListPage"; +import { AttributeBulkDeleteMutation } from "../../mutations"; +import { AttributeListQuery } from "../../queries"; +import { AttributeBulkDelete } from "../../types/AttributeBulkDelete"; +import { + attributeAddUrl, + attributeListUrl, + AttributeListUrlDialog, + AttributeListUrlQueryParams, + attributeUrl +} from "../../urls"; + +interface AttributeListProps { + params: AttributeListUrlQueryParams; +} + +const AttributeList: React.FC = ({ params }) => { + const navigate = useNavigator(); + const paginate = usePaginator(); + const notify = useNotifier(); + const { isSelected, listElements, reset, toggle, toggleAll } = useBulkActions( + params.ids + ); + + const closeModal = () => + navigate( + attributeListUrl({ + ...params, + action: undefined, + ids: undefined + }), + true + ); + + const openModal = (action: AttributeListUrlDialog, ids?: string[]) => + navigate( + attributeListUrl({ + ...params, + action, + ids + }) + ); + + const paginationState = createPaginationState(PAGINATE_BY, params); + const queryVariables = React.useMemo(() => paginationState, [params]); + + return ( + + {({ data, loading, refetch }) => { + const { loadNextPage, loadPreviousPage, pageInfo } = paginate( + maybe(() => data.attributes.pageInfo), + paginationState, + params + ); + + const handleBulkDelete = (data: AttributeBulkDelete) => { + if (data.attributeBulkDelete.errors.length === 0) { + closeModal(); + notify({ + text: i18n.t("Attributes removed") + }); + reset(); + refetch(); + } + }; + + return ( + + {(attributeBulkDelete, attributeBulkDeleteOpts) => { + const bulkDeleteMutationState = getMutationState( + attributeBulkDeleteOpts.called, + attributeBulkDeleteOpts.loading, + maybe( + () => attributeBulkDeleteOpts.data.attributeBulkDelete.errors + ) + ); + + return ( + <> + + data.attributes.edges.map(edge => edge.node) + )} + disabled={loading || attributeBulkDeleteOpts.loading} + isChecked={isSelected} + onAdd={() => navigate(attributeAddUrl())} + onNextPage={loadNextPage} + onPreviousPage={loadPreviousPage} + onRowClick={id => () => navigate(attributeUrl(id))} + pageInfo={pageInfo} + selected={listElements.length} + toggle={toggle} + toggleAll={toggleAll} + toolbar={ + openModal("remove", listElements)} + > + + + } + /> + + attributeBulkDelete({ variables: { ids: params.ids } }) + } + onClose={closeModal} + quantity={maybe(() => params.ids.length.toString(), "...")} + /> + + ); + }} + + ); + }} + + ); +}; +AttributeList.displayName = "AttributeList"; + +export default AttributeList; diff --git a/src/attributes/views/AttributeList/index.ts b/src/attributes/views/AttributeList/index.ts new file mode 100644 index 000000000..216f3b4bc --- /dev/null +++ b/src/attributes/views/AttributeList/index.ts @@ -0,0 +1,2 @@ +export * from "./AttributeList"; +export { default } from "./AttributeList"; diff --git a/src/components/ColumnPicker/ColumnPicker.tsx b/src/components/ColumnPicker/ColumnPicker.tsx new file mode 100644 index 000000000..a0d72cb09 --- /dev/null +++ b/src/components/ColumnPicker/ColumnPicker.tsx @@ -0,0 +1,103 @@ +import ClickAwayListener from "@material-ui/core/ClickAwayListener"; +import Grow from "@material-ui/core/Grow"; +import Popper from "@material-ui/core/Popper"; +import { Theme } from "@material-ui/core/styles"; +import { fade } from "@material-ui/core/styles/colorManipulator"; +import makeStyles from "@material-ui/styles/makeStyles"; +import React from "react"; + +import ColumnPickerButton from "./ColumnPickerButton"; +import ColumnPickerContent, { + ColumnPickerContentProps +} from "./ColumnPickerContent"; + +export interface ColumnPickerProps extends ColumnPickerContentProps { + className?: string; + initial?: boolean; +} + +const useStyles = makeStyles( + (theme: Theme) => ({ + popper: { + boxShadow: `0px 5px 10px 0 ${fade(theme.palette.common.black, 0.05)}`, + marginTop: theme.spacing.unit * 2, + zIndex: 1 + } + }), + { + name: "ColumnPicker" + } +); + +const ColumnPicker: React.FC = props => { + const { + className, + columns, + initial = false, + selectedColumns, + onCancel, + onColumnToggle, + onReset, + onSave + } = props; + const classes = useStyles(props); + const anchor = React.useRef(); + const [isExpanded, setExpansionState] = React.useState(false); + + React.useEffect(() => { + setTimeout(() => setExpansionState(initial), 100); + }, []); + + const handleCancel = React.useCallback(() => { + setExpansionState(false); + onCancel(); + }, []); + + const handleSave = () => { + setExpansionState(false); + onSave(); + }; + + return ( +
+ setExpansionState(prevState => !prevState)} + /> + + {({ TransitionProps, placement }) => ( + + setExpansionState(false)} + mouseEvent="onClick" + > + + + + )} + +
+ ); +}; + +export default ColumnPicker; diff --git a/src/components/ColumnPicker/ColumnPickerButton.tsx b/src/components/ColumnPicker/ColumnPickerButton.tsx new file mode 100644 index 000000000..bdd7dde47 --- /dev/null +++ b/src/components/ColumnPicker/ColumnPickerButton.tsx @@ -0,0 +1,67 @@ +import Button from "@material-ui/core/Button"; +import { Theme } from "@material-ui/core/styles"; +import { fade } from "@material-ui/core/styles/colorManipulator"; +import ArrowDropDownIcon from "@material-ui/icons/ArrowDropDown"; +import makeStyles from "@material-ui/styles/makeStyles"; +import classNames from "classnames"; +import React from "react"; + +import i18n from "@saleor/i18n"; + +interface ColumnPickerButtonProps { + active: boolean; + className?: string; + onClick: () => void; +} + +const useStyles = makeStyles( + (theme: Theme) => ({ + icon: { + marginLeft: theme.spacing.unit * 2, + transition: theme.transitions.duration.short + "ms" + }, + root: { + "& span": { + color: theme.palette.primary.main + }, + paddingRight: theme.spacing.unit + }, + rootActive: { + background: fade(theme.palette.primary.main, 0.1) + }, + rotate: { + transform: "rotate(180deg)" + } + }), + { + name: "ColumnPickerButton" + } +); + +const ColumnPickerButton: React.FC = props => { + const { active, className, onClick } = props; + const classes = useStyles(props); + + return ( + + ); +}; + +export default ColumnPickerButton; diff --git a/src/components/ColumnPicker/ColumnPickerContent.tsx b/src/components/ColumnPicker/ColumnPickerContent.tsx new file mode 100644 index 000000000..3629d19d1 --- /dev/null +++ b/src/components/ColumnPicker/ColumnPickerContent.tsx @@ -0,0 +1,128 @@ +import Button from "@material-ui/core/Button"; +import Card from "@material-ui/core/Card"; +import CardContent from "@material-ui/core/CardContent"; +import { Theme } from "@material-ui/core/styles"; +import Typography from "@material-ui/core/Typography"; +import makeStyles from "@material-ui/styles/makeStyles"; +import classNames from "classnames"; +import React from "react"; + +import useElementScroll from "@saleor/hooks/useElementScroll"; +import i18n from "@saleor/i18n"; +import { isSelected } from "@saleor/utils/lists"; +import ControlledCheckbox from "../ControlledCheckbox"; +import Hr from "../Hr"; + +export interface ColumnPickerChoice { + label: string; + value: string; +} +export interface ColumnPickerContentProps { + columns: ColumnPickerChoice[]; + selectedColumns: string[]; + onCancel: () => void; + onColumnToggle: (column: string) => void; + onReset: () => void; + onSave: () => void; +} + +const useStyles = makeStyles((theme: Theme) => ({ + actionBar: { + display: "flex", + justifyContent: "space-between" + }, + actionBarContainer: { + boxShadow: `0px 0px 0px 0px ${theme.palette.background.paper}`, + transition: theme.transitions.duration.short + "ms" + }, + content: { + display: "grid", + gridColumnGap: theme.spacing.unit * 3, + gridTemplateColumns: "repeat(3, 1fr)", + maxHeight: 256, + overflowX: "visible", + overflowY: "scroll", + padding: `${theme.spacing.unit * 2}px ${theme.spacing.unit * 3}px` + }, + contentContainer: { + padding: 0 + }, + dropShadow: { + boxShadow: `0px -5px 10px 0px ${theme.overrides.MuiCard.root.borderColor}` + } +})); + +const ColumnPickerContent: React.FC = props => { + const { + columns, + selectedColumns, + onCancel, + onColumnToggle, + onReset, + onSave + } = props; + const classes = useStyles(props); + const anchor = React.useRef(); + const scrollPosition = useElementScroll(anchor); + + const dropShadow = anchor.current + ? scrollPosition.y + anchor.current.clientHeight < + anchor.current.scrollHeight + : false; + + return ( + + + + {i18n.t( + "{{ numberOfSelected }} columns selected out of {{ numberOfTotal }}", + { + context: "pick columns to display", + numberOfSelected: selectedColumns.length, + numberOfTotal: columns.length + } + )} + + +
+ +
+ {columns.map(column => ( + a === b + )} + name={column.value} + label={column.label} + onChange={() => onColumnToggle(column.value)} + /> + ))} +
+
+
+ +
+ +
+ + +
+
+
+
+ ); +}; + +export default ColumnPickerContent; diff --git a/src/components/ColumnPicker/index.ts b/src/components/ColumnPicker/index.ts new file mode 100644 index 000000000..301ba74e4 --- /dev/null +++ b/src/components/ColumnPicker/index.ts @@ -0,0 +1,4 @@ +export { default } from "./ColumnPicker"; +export * from "./ColumnPicker"; +export * from "./ColumnPickerButton"; +export * from "./ColumnPickerContent"; diff --git a/src/components/RadioGroupField/RadioGroupField.tsx b/src/components/RadioGroupField/RadioGroupField.tsx new file mode 100644 index 000000000..b22446312 --- /dev/null +++ b/src/components/RadioGroupField/RadioGroupField.tsx @@ -0,0 +1,95 @@ +import FormControl from "@material-ui/core/FormControl"; +import FormControlLabel from "@material-ui/core/FormControlLabel"; +import FormHelperText from "@material-ui/core/FormHelperText"; +import FormLabel from "@material-ui/core/FormLabel"; +import MenuItem from "@material-ui/core/MenuItem"; +import Radio from "@material-ui/core/Radio"; +import RadioGroup from "@material-ui/core/RadioGroup"; +import { createStyles, withStyles, WithStyles } from "@material-ui/core/styles"; +import classNames from "classnames"; +import React from "react"; + +import i18n from "../../i18n"; + +const styles = createStyles({ + formControl: { + padding: "0 15px", + width: "100%" + }, + formLabel: { + marginLeft: "-5px", + paddingBottom: "10px" + }, + radioLabel: { + "& > span": { + padding: "6px" + } + } +}); + +interface RadioGroupFieldProps extends WithStyles { + choices: Array<{ + value: string; + label: string | React.ReactNode; + }>; + className?: string; + disabled?: boolean; + error?: boolean; + hint?: string; + label?: string; + name?: string; + value?: string; + onChange: (event: React.ChangeEvent) => void; +} + +export const RadioGroupField = withStyles(styles, { + name: "RadioGroupField" +})( + ({ + className, + classes, + disabled, + error, + label, + choices, + value, + onChange, + name, + hint + }: RadioGroupFieldProps) => { + return ( + + {label ? ( + {label} + ) : null} + + {choices.length > 0 ? ( + choices.map(choice => ( + } + label={choice.label} + key={choice.value} + /> + )) + ) : ( + {i18n.t("No results found")} + )} + + {hint && {hint}} + + ); + } +); +RadioGroupField.displayName = "RadioGroupField"; +export default RadioGroupField; diff --git a/src/components/RadioGroupField/index.ts b/src/components/RadioGroupField/index.ts new file mode 100644 index 000000000..58ebd26ab --- /dev/null +++ b/src/components/RadioGroupField/index.ts @@ -0,0 +1,2 @@ +export { default } from "./RadioGroupField"; +export * from "./RadioGroupField"; diff --git a/src/components/RowNumberSelect/RowNumberSelect.tsx b/src/components/RowNumberSelect/RowNumberSelect.tsx new file mode 100644 index 000000000..2a670e7ae --- /dev/null +++ b/src/components/RowNumberSelect/RowNumberSelect.tsx @@ -0,0 +1,71 @@ +import MenuItem from "@material-ui/core/MenuItem"; +import Select from "@material-ui/core/Select"; +import { Theme } from "@material-ui/core/styles"; +import { createStyles, makeStyles, useTheme } from "@material-ui/styles"; +import React from "react"; + +import i18n from "../../i18n"; +import { ListSettings } from "../../types"; + +const useStyles = makeStyles( + (theme: Theme) => + createStyles({ + label: { + fontSize: 14 + }, + select: { + "& div": { + "&:focus": { + background: "none" + }, + color: theme.palette.primary.main, + padding: "0 10px 0 5px" + }, + "& svg": { + color: theme.palette.primary.main + }, + "&:after, &:before, &:hover": { + border: "none !important" + } + } + }), + { + name: "RowNumberSelect" + } +); + +interface RowNumberSelectProps { + choices: number[]; + className?: string; + settings: ListSettings; + onChange(key: keyof ListSettings, value: any); +} + +const RowNumberSelect: React.FC = ({ + className, + choices, + settings, + onChange +}) => { + const theme = useTheme(); + const classes = useStyles({ theme }); + return ( +
+ {i18n.t("No of Rows:")} + +
+ ); +}; + +export default RowNumberSelect; diff --git a/src/components/RowNumberSelect/index.ts b/src/components/RowNumberSelect/index.ts new file mode 100644 index 000000000..380ec9594 --- /dev/null +++ b/src/components/RowNumberSelect/index.ts @@ -0,0 +1,2 @@ +export { default } from "./RowNumberSelect"; +export * from "./RowNumberSelect"; diff --git a/src/components/SortableTable/SortableHandle.tsx b/src/components/SortableTable/SortableHandle.tsx new file mode 100644 index 000000000..13843db3b --- /dev/null +++ b/src/components/SortableTable/SortableHandle.tsx @@ -0,0 +1,29 @@ +import { Theme } from "@material-ui/core/styles"; +import TableCell from "@material-ui/core/TableCell"; +import makeStyles from "@material-ui/styles/makeStyles"; +import React from "react"; +import { SortableHandle as SortableHandleHoc } from "react-sortable-hoc"; + +import Draggable from "@saleor/icons/Draggable"; + +const useStyles = makeStyles((theme: Theme) => ({ + columnDrag: { + "&:first-child": { + paddingRight: theme.spacing.unit * 2 + }, + cursor: "grab", + width: 48 + theme.spacing.unit * 1.5 + } +})); + +const SortableHandle = SortableHandleHoc(() => { + const classes = useStyles({}); + + return ( + + + + ); +}); + +export default SortableHandle; diff --git a/src/components/SortableTable/SortableTableBody.tsx b/src/components/SortableTable/SortableTableBody.tsx new file mode 100644 index 000000000..5a07bfb83 --- /dev/null +++ b/src/components/SortableTable/SortableTableBody.tsx @@ -0,0 +1,45 @@ +import { Theme } from "@material-ui/core/styles"; +import TableBody, { TableBodyProps } from "@material-ui/core/TableBody"; +import makeStyles from "@material-ui/styles/makeStyles"; +import React from "react"; +import { SortableContainer } from "react-sortable-hoc"; + +import { ReorderAction } from "@saleor/types"; + +const InnerSortableTableBody = SortableContainer( + ({ children, ...props }) => {children} +); + +export interface SortableTableBodyProps { + onSortEnd: ReorderAction; +} + +const useStyles = makeStyles((theme: Theme) => ({ + ghost: { + "& td": { + borderBottom: "none" + }, + background: theme.palette.background.paper, + fontFamily: theme.typography.fontFamily, + fontSize: theme.overrides.MuiTableCell.root.fontSize, + opacity: 0.5 + } +})); + +const SortableTableBody: React.FC< + TableBodyProps & SortableTableBodyProps +> = props => { + const classes = useStyles({}); + + return ( + + ); +}; + +export default SortableTableBody; diff --git a/src/components/SortableTable/SortableTableRow.tsx b/src/components/SortableTable/SortableTableRow.tsx new file mode 100644 index 000000000..f75299921 --- /dev/null +++ b/src/components/SortableTable/SortableTableRow.tsx @@ -0,0 +1,15 @@ +import TableRow, { TableRowProps } from "@material-ui/core/TableRow"; +import React from "react"; +import { SortableElement } from "react-sortable-hoc"; +import SortableHandle from "./SortableHandle"; + +const SortableTableRow = SortableElement( + ({ children, ...props }) => ( + + + {children} + + ) +); + +export default SortableTableRow; diff --git a/src/components/SortableTable/index.ts b/src/components/SortableTable/index.ts new file mode 100644 index 000000000..15d3a941d --- /dev/null +++ b/src/components/SortableTable/index.ts @@ -0,0 +1,5 @@ +export * from "./SortableTableBody"; +export { default as SortableTableBody } from "./SortableTableBody"; + +export * from "./SortableTableRow"; +export { default as SortableTableRow } from "./SortableTableRow"; diff --git a/src/discounts/components/VoucherDates/VoucherDates.tsx b/src/discounts/components/VoucherDates/VoucherDates.tsx new file mode 100644 index 000000000..f15672cf9 --- /dev/null +++ b/src/discounts/components/VoucherDates/VoucherDates.tsx @@ -0,0 +1,103 @@ +import Card from "@material-ui/core/Card"; +import CardContent from "@material-ui/core/CardContent"; +import TextField from "@material-ui/core/TextField"; +import React from "react"; + +import CardTitle from "@saleor/components/CardTitle"; +import { ControlledCheckbox } from "@saleor/components/ControlledCheckbox"; +import Grid from "@saleor/components/Grid"; +import i18n from "../../../i18n"; +import { FormErrors } from "../../../types"; +import { FormData } from "../VoucherDetailsPage"; + +interface VoucherDatesProps { + data: FormData; + defaultCurrency: string; + disabled: boolean; + errors: FormErrors<"endDate" | "startDate">; + onChange: (event: React.ChangeEvent) => void; +} + +const VoucherDates = ({ + data, + disabled, + errors, + onChange +}: VoucherDatesProps) => { + return ( + + + + + + + + + {data.hasEndDate && ( + + + + + )} + + + ); +}; +export default VoucherDates; diff --git a/src/discounts/components/VoucherDates/index.ts b/src/discounts/components/VoucherDates/index.ts new file mode 100644 index 000000000..da24480b6 --- /dev/null +++ b/src/discounts/components/VoucherDates/index.ts @@ -0,0 +1,2 @@ +export { default } from "./VoucherDates"; +export * from "./VoucherDates"; diff --git a/src/discounts/components/VoucherLimits/VoucherLimits.tsx b/src/discounts/components/VoucherLimits/VoucherLimits.tsx new file mode 100644 index 000000000..224c8e360 --- /dev/null +++ b/src/discounts/components/VoucherLimits/VoucherLimits.tsx @@ -0,0 +1,64 @@ +import Card from "@material-ui/core/Card"; +import CardContent from "@material-ui/core/CardContent"; +import TextField from "@material-ui/core/TextField"; +import React from "react"; + +import CardTitle from "@saleor/components/CardTitle"; +import { ControlledCheckbox } from "@saleor/components/ControlledCheckbox"; +import i18n from "../../../i18n"; +import { FormErrors } from "../../../types"; +import { FormData } from "../VoucherDetailsPage"; + +interface VoucherLimitsProps { + data: FormData; + defaultCurrency: string; + disabled: boolean; + errors: FormErrors<"usageLimit">; + onChange: (event: React.ChangeEvent) => void; +} + +const VoucherLimits = ({ + data, + disabled, + errors, + onChange +}: VoucherLimitsProps) => { + return ( + + + + + {data.hasUsageLimit && ( + + )} + + + + ); +}; +export default VoucherLimits; diff --git a/src/discounts/components/VoucherLimits/index.ts b/src/discounts/components/VoucherLimits/index.ts new file mode 100644 index 000000000..5bb995ee4 --- /dev/null +++ b/src/discounts/components/VoucherLimits/index.ts @@ -0,0 +1,2 @@ +export { default } from "./VoucherLimits"; +export * from "./VoucherLimits"; diff --git a/src/discounts/components/VoucherRequirements/VoucherRequirements.tsx b/src/discounts/components/VoucherRequirements/VoucherRequirements.tsx new file mode 100644 index 000000000..4ff9a8aa3 --- /dev/null +++ b/src/discounts/components/VoucherRequirements/VoucherRequirements.tsx @@ -0,0 +1,82 @@ +import Card from "@material-ui/core/Card"; +import CardContent from "@material-ui/core/CardContent"; +import TextField from "@material-ui/core/TextField"; +import React from "react"; + +import CardTitle from "@saleor/components/CardTitle"; +import { FormSpacer } from "@saleor/components/FormSpacer"; +import RadioGroupField from "@saleor/components/RadioGroupField"; +import { RequirementsPicker } from "@saleor/discounts/types"; +import i18n from "@saleor/i18n"; +import { FormErrors } from "@saleor/types"; +import { FormData } from "../VoucherDetailsPage"; + +interface VoucherRequirementsProps { + data: FormData; + defaultCurrency: string; + disabled: boolean; + errors: FormErrors<"minAmountSpent" | "minCheckoutItemsQuantity">; + onChange: (event: React.ChangeEvent) => void; +} + +const VoucherRequirements = ({ + data, + disabled, + errors, + onChange +}: VoucherRequirementsProps) => { + const requirementsPickerChoices = [ + { + label: i18n.t("None"), + value: RequirementsPicker.NONE + }, + { + label: i18n.t("Minimal order value"), + value: RequirementsPicker.ORDER + }, + { + label: i18n.t("Minimum quantity of items"), + value: RequirementsPicker.ITEM + } + ]; + + return ( + + + + + + {data.requirementsPicker === RequirementsPicker.ORDER ? ( + + ) : data.requirementsPicker === RequirementsPicker.ITEM ? ( + + ) : null} + + + ); +}; +export default VoucherRequirements; diff --git a/src/discounts/components/VoucherRequirements/index.ts b/src/discounts/components/VoucherRequirements/index.ts new file mode 100644 index 000000000..4f8ff6ff3 --- /dev/null +++ b/src/discounts/components/VoucherRequirements/index.ts @@ -0,0 +1,2 @@ +export { default } from "./VoucherRequirements"; +export * from "./VoucherRequirements"; diff --git a/src/discounts/components/VoucherTypes/VoucherTypes.tsx b/src/discounts/components/VoucherTypes/VoucherTypes.tsx new file mode 100644 index 000000000..65074806e --- /dev/null +++ b/src/discounts/components/VoucherTypes/VoucherTypes.tsx @@ -0,0 +1,60 @@ +import Card from "@material-ui/core/Card"; +import CardContent from "@material-ui/core/CardContent"; +import React from "react"; + +import CardTitle from "@saleor/components/CardTitle"; +import Grid from "@saleor/components/Grid"; +import RadioGroupField from "@saleor/components/RadioGroupField"; +import i18n from "../../../i18n"; +import { FormErrors } from "../../../types"; +import { DiscountValueTypeEnum } from "../../../types/globalTypes"; +import { FormData } from "../VoucherDetailsPage"; + +interface VoucherTypesProps { + data: FormData; + errors: FormErrors<"discountType">; + disabled: boolean; + onChange: (event: React.ChangeEvent) => void; +} + +const VoucherTypes = ({ + data, + disabled, + errors, + onChange +}: VoucherTypesProps) => { + const voucherTypeChoices = [ + { + label: i18n.t("Fixed Amount"), + value: DiscountValueTypeEnum.FIXED + }, + { + label: i18n.t("Percentage"), + value: DiscountValueTypeEnum.PERCENTAGE + }, + { + label: i18n.t("Free Shipping"), + value: "SHIPPING" + } + ]; + + return ( + + + + + + + + + ); +}; +export default VoucherTypes; diff --git a/src/discounts/components/VoucherTypes/index.ts b/src/discounts/components/VoucherTypes/index.ts new file mode 100644 index 000000000..89168c9e7 --- /dev/null +++ b/src/discounts/components/VoucherTypes/index.ts @@ -0,0 +1,2 @@ +export { default } from "./VoucherTypes"; +export * from "./VoucherTypes"; diff --git a/src/discounts/components/VoucherValue/VoucherValue.tsx b/src/discounts/components/VoucherValue/VoucherValue.tsx new file mode 100644 index 000000000..8529ef79a --- /dev/null +++ b/src/discounts/components/VoucherValue/VoucherValue.tsx @@ -0,0 +1,112 @@ +import Card from "@material-ui/core/Card"; +import CardContent from "@material-ui/core/CardContent"; +import Typography from "@material-ui/core/Typography"; +import React from "react"; + +import CardTitle from "@saleor/components/CardTitle"; +import ControlledSwitch from "@saleor/components/ControlledSwitch"; +import { FormSpacer } from "@saleor/components/FormSpacer"; +import Hr from "@saleor/components/Hr"; +import RadioGroupField from "@saleor/components/RadioGroupField"; +import TextFieldWithChoice from "@saleor/components/TextFieldWithChoice"; +import i18n from "../../../i18n"; +import { FormErrors } from "../../../types"; +import { DiscountValueTypeEnum } from "../../../types/globalTypes"; +import { translateVoucherTypes } from "../../translations"; +import { FormData } from "../VoucherDetailsPage"; + +interface VoucherValueProps { + data: FormData; + defaultCurrency: string; + errors: FormErrors<"discountValue" | "type">; + disabled: boolean; + variant: string; + onChange: (event: React.ChangeEvent) => void; +} + +export enum VoucherType { + ENTIRE_ORDER = "ENTIRE_ORDER", + SPECIFIC_PRODUCT = "SPECIFIC_PRODUCT" +} + +const VoucherValue = ({ + data, + defaultCurrency, + disabled, + errors, + variant, + onChange +}: VoucherValueProps) => { + const translatedVoucherTypes = translateVoucherTypes(); + const voucherTypeChoices = Object.values(VoucherType).map(type => ({ + label: translatedVoucherTypes[type], + value: type + })); + + return ( + + + + + + {variant === "update" && ( + <> + + + + )} +
+ + + {i18n.t("Only once per order", { + context: "voucher application" + })} + + {i18n.t( + "If this option is disabled, discount will be counted for every eligible product" + )} + + + } + onChange={onChange} + name={"applyOncePerOrder" as keyof FormData} + disabled={disabled} + /> +
+
+ ); +}; +export default VoucherValue; diff --git a/src/discounts/components/VoucherValue/index.ts b/src/discounts/components/VoucherValue/index.ts new file mode 100644 index 000000000..d8107f9b3 --- /dev/null +++ b/src/discounts/components/VoucherValue/index.ts @@ -0,0 +1,2 @@ +export { default } from "./VoucherValue"; +export * from "./VoucherValue"; diff --git a/src/discounts/types.ts b/src/discounts/types.ts new file mode 100644 index 000000000..9c15c2ed6 --- /dev/null +++ b/src/discounts/types.ts @@ -0,0 +1,5 @@ +export enum RequirementsPicker { + ORDER = "ORDER", + ITEM = "ITEM", + NONE = "NONE" +} diff --git a/src/hooks/useElementScroll.ts b/src/hooks/useElementScroll.ts new file mode 100644 index 000000000..0804d824e --- /dev/null +++ b/src/hooks/useElementScroll.ts @@ -0,0 +1,34 @@ +import throttle from "lodash-es/throttle"; +import { MutableRefObject, useEffect, useState } from "react"; + +function getPosition(anchor?: HTMLElement) { + if (!!anchor) { + return { + x: anchor.scrollLeft, + y: anchor.scrollTop + }; + } + return { + x: 0, + y: 0 + }; +} + +function useElementScroll(anchor: MutableRefObject) { + const [scroll, setScroll] = useState(getPosition(anchor.current)); + + useEffect(() => { + if (!!anchor.current) { + const handleScroll = throttle( + () => setScroll(getPosition(anchor.current)), + 100 + ); + anchor.current.addEventListener("scroll", handleScroll); + + return () => anchor.current.removeEventListener("scroll", handleScroll); + } + }, [anchor.current]); + + return scroll; +} +export default useElementScroll; diff --git a/src/hooks/useForm.ts b/src/hooks/useForm.ts new file mode 100644 index 000000000..e09acd9de --- /dev/null +++ b/src/hooks/useForm.ts @@ -0,0 +1,148 @@ +import isEqual from "lodash-es/isEqual"; +import { useState } from "react"; + +import { UserError } from "@saleor/types"; +import { toggle } from "@saleor/utils/lists"; +import useStateFromProps from "./useStateFromProps"; + +export interface ChangeEvent { + target: { + name: string; + value: TData; + }; +} + +export type FormChange = (event: ChangeEvent, cb?: () => void) => void; + +export interface UseFormResult { + change: FormChange; + data: T; + errors: Record; + hasChanged: boolean; + reset: () => void; + set: (data: T) => void; + submit: () => void; + triggerChange: () => void; + toggleValue: FormChange; +} + +function parseErrors(errors: UserError[]): Record { + return errors + ? errors.reduce( + (acc, curr) => + curr.field + ? { + ...acc, + [curr.field.split(":")[0]]: curr.message + } + : acc, + {} + ) + : {}; +} + +type FormData = Record; + +function merge(prevData: T, prevState: T, data: T): T { + return Object.keys(prevState).reduce( + (acc, key) => { + if (!isEqual(data[key], prevData[key])) { + acc[key as keyof T] = data[key]; + } + + return acc; + }, + { ...prevState } + ); +} + +function handleRefresh( + data: T, + newData: T, + setChanged: (status: boolean) => void +) { + if (isEqual(data, newData)) { + setChanged(false); + } +} + +function useForm( + initial: T, + errors: UserError[], + onSubmit: (data: T) => void +): UseFormResult { + const [hasChanged, setChanged] = useState(false); + const [data, setData] = useStateFromProps(initial, { + mergeFunc: merge, + onRefresh: newData => handleRefresh(data, newData, setChanged) + }); + + function toggleValue(event: ChangeEvent, cb?: () => void) { + const { name, value } = event.target; + const field = data[name as keyof T]; + + if (Array.isArray(field)) { + if (!hasChanged) { + setChanged(true); + } + setData({ + ...data, + [name]: toggle(value, field, isEqual) + }); + } + + if (typeof cb === "function") { + cb(); + } + } + + function change(event: ChangeEvent) { + const { name, value } = event.target; + + if (!(name in data)) { + console.error(`Unknown form field: ${name}`); + return; + } else { + if (data[name] !== value) { + setChanged(true); + } + setData(data => ({ + ...data, + [name]: value + })); + } + } + + function reset() { + setData(initial); + } + + function set(newData: Partial) { + setData(data => ({ + ...data, + ...newData + })); + } + + function submit() { + return onSubmit(data); + } + + function triggerChange() { + setChanged(true); + } + + return { + change, + data, + errors: parseErrors(errors), + hasChanged, + reset, + set, + submit, + toggleValue, + triggerChange + }; +} + +export default useForm; diff --git a/src/hooks/useFormset.ts b/src/hooks/useFormset.ts new file mode 100644 index 000000000..8891d48b5 --- /dev/null +++ b/src/hooks/useFormset.ts @@ -0,0 +1,51 @@ +import useStateFromProps from "./useStateFromProps"; + +export type FormsetChange = (id: string, value: TValue) => void; +export interface FormsetAtomicData { + data: TData; + id: string; + label: string; + value: TValue; +} +export type FormsetData = Array< + FormsetAtomicData +>; +export interface UseFormsetOutput { + change: FormsetChange; + data: FormsetData; + get: (id: string) => FormsetAtomicData; + // Used for some rare situations like dataset change + set: (data: FormsetData) => void; +} +function useFormset( + initial: FormsetData +): UseFormsetOutput { + const [data, setData] = useStateFromProps>( + initial || [] + ); + + function getItem(id: string): FormsetAtomicData { + return data.find(item => item.id === id); + } + + function setItemValue(id: string, value: TValue) { + const itemIndex = data.findIndex(item => item.id === id); + setData([ + ...data.slice(0, itemIndex), + { + ...data[itemIndex], + value + }, + ...data.slice(itemIndex + 1) + ]); + } + + return { + change: setItemValue, + data, + get: getItem, + set: setData + }; +} + +export default useFormset; diff --git a/src/hooks/useListActions.ts b/src/hooks/useListActions.ts new file mode 100644 index 000000000..43f38b88c --- /dev/null +++ b/src/hooks/useListActions.ts @@ -0,0 +1,47 @@ +import { useEffect, useState } from "react"; + +function useListActions( + initial: TData[] = [], + compareFunc: (a: TData, b: TData) => boolean = (a, b) => a === b +) { + const [listElements, setListElements] = useState(initial); + + useEffect(() => setListElements(initial), [JSON.stringify(initial)]); + + function isSelected(data: TData) { + return !!listElements.find(listElement => compareFunc(listElement, data)); + } + + function add(data: TData) { + setListElements([...listElements, data]); + } + + function remove(data: TData) { + setListElements( + listElements.filter(listElement => !compareFunc(listElement, data)) + ); + } + + function reset() { + setListElements([]); + } + + function toggle(data: TData) { + isSelected(data) ? remove(data) : add(data); + } + + function set(data: TData[]) { + setListElements(data); + } + + return { + add, + isSelected, + listElements, + remove, + reset, + set, + toggle + }; +} +export default useListActions; diff --git a/src/hooks/useListSettings.ts b/src/hooks/useListSettings.ts new file mode 100644 index 000000000..75c1afd86 --- /dev/null +++ b/src/hooks/useListSettings.ts @@ -0,0 +1,30 @@ +import useLocalStorage from "@saleor/hooks/useLocalStorage"; +import { AppListViewSettings, defaultListSettings } from "./../config"; +import { ListSettings, ListViews } from "./../types"; + +export interface UseListSettings { + settings: ListSettings; + updateListSettings: (key: keyof ListSettings, value: any) => void; +} +export default function useListSettings( + listName: ListViews +): UseListSettings { + const [settings, setListSettings] = useLocalStorage( + "listConfig", + defaultListSettings + ); + + const updateListSettings = (key: keyof ListSettings, value: any) => + setListSettings(settings => ({ + ...settings, + [listName]: { + ...settings[listName], + [key]: value + } + })); + + return { + settings: settings[listName] as ListSettings, + updateListSettings + }; +} diff --git a/src/hooks/useLocalStorage.ts b/src/hooks/useLocalStorage.ts new file mode 100644 index 000000000..13deab7df --- /dev/null +++ b/src/hooks/useLocalStorage.ts @@ -0,0 +1,21 @@ +import { useState } from "react"; + +export type SetLocalStorageValue = T | ((prevValue: T) => T); +export type SetLocalStorage = (value: SetLocalStorageValue) => void; +export default function useLocalStorage( + key: string, + initialValue: T +): [T, SetLocalStorage] { + const [storedValue, setStoredValue] = useState(() => { + const item = window.localStorage.getItem(key); + return item ? JSON.parse(item) : initialValue; + }); + + const setValue = (value: SetLocalStorageValue) => { + const valueToStore = value instanceof Function ? value(storedValue) : value; + setStoredValue(valueToStore); + window.localStorage.setItem(key, JSON.stringify(valueToStore)); + }; + + return [storedValue, setValue]; +} diff --git a/src/hooks/useModalDialogErrors/index.ts b/src/hooks/useModalDialogErrors/index.ts new file mode 100644 index 000000000..0fd055b5f --- /dev/null +++ b/src/hooks/useModalDialogErrors/index.ts @@ -0,0 +1,2 @@ +export { default } from "./useModalDialogErrors"; +export * from "./useModalDialogErrors"; diff --git a/src/hooks/useModalDialogErrors/useModalDialogErrors.test.ts b/src/hooks/useModalDialogErrors/useModalDialogErrors.test.ts new file mode 100644 index 000000000..e61aafc0c --- /dev/null +++ b/src/hooks/useModalDialogErrors/useModalDialogErrors.test.ts @@ -0,0 +1,44 @@ +import { renderHook } from "@testing-library/react-hooks"; + +import useModalDialogErrors from "./useModalDialogErrors"; + +const errors = ["err1", "err2"]; + +test("Does not render errors after close", () => { + const { result, rerender } = renderHook( + ({ errors, open }) => useModalDialogErrors(errors, open), + { + initialProps: { + errors: [] as string[], + open: false + } + } + ); + + // Open modal + rerender({ + errors: [], + open: true + }); + expect(result.current.length).toBe(0); + + // Throw errors + rerender({ + errors, + open: true + }); + expect(result.current.length).toBe(2); + + // Close modal + rerender({ + errors, + open: false + }); + + // Open modal + rerender({ + errors, + open: true + }); + expect(result.current.length).toBe(0); +}); diff --git a/src/hooks/useModalDialogErrors/useModalDialogErrors.ts b/src/hooks/useModalDialogErrors/useModalDialogErrors.ts new file mode 100644 index 000000000..54a393743 --- /dev/null +++ b/src/hooks/useModalDialogErrors/useModalDialogErrors.ts @@ -0,0 +1,17 @@ +import useModalDialogOpen from "../useModalDialogOpen"; +import useStateFromProps from "../useStateFromProps"; + +function useModalDialogErrors( + errors: TError[], + open: boolean +): TError[] { + const [state, setState] = useStateFromProps(errors); + + useModalDialogOpen(open, { + onClose: () => setState([]) + }); + + return state; +} + +export default useModalDialogErrors; diff --git a/src/hooks/useModalDialogOpen/index.ts b/src/hooks/useModalDialogOpen/index.ts new file mode 100644 index 000000000..53579d8b8 --- /dev/null +++ b/src/hooks/useModalDialogOpen/index.ts @@ -0,0 +1,2 @@ +export { default } from "./useModalDialogOpen"; +export * from "./useModalDialogOpen"; diff --git a/src/hooks/useModalDialogOpen/useModalDialogOpen.test.ts b/src/hooks/useModalDialogOpen/useModalDialogOpen.test.ts new file mode 100644 index 000000000..4df311372 --- /dev/null +++ b/src/hooks/useModalDialogOpen/useModalDialogOpen.test.ts @@ -0,0 +1,55 @@ +import { renderHook } from "@testing-library/react-hooks"; + +import useModalDialogOpen from "./useModalDialogOpen"; + +const onClose = jest.fn(); +const onOpen = jest.fn(); + +const cbs = { + onClose, + onOpen +}; + +test("Does not render errors after close", () => { + const { rerender } = renderHook( + ({ open, cbs }) => useModalDialogOpen(open, cbs), + { + initialProps: { + cbs, + open: false + } + } + ); + + // Open modal + rerender({ + cbs, + open: true + }); + expect(onOpen).toBeCalledTimes(1); + expect(onClose).toBeCalledTimes(0); + + // Rerender modal + rerender({ + cbs, + open: true + }); + expect(onOpen).toBeCalledTimes(1); + expect(onClose).toBeCalledTimes(0); + + // Close modal + rerender({ + cbs, + open: false + }); + expect(onOpen).toBeCalledTimes(1); + expect(onClose).toBeCalledTimes(1); + + // Open modal + rerender({ + cbs, + open: true + }); + expect(onOpen).toBeCalledTimes(2); + expect(onClose).toBeCalledTimes(1); +}); diff --git a/src/hooks/useModalDialogOpen/useModalDialogOpen.ts b/src/hooks/useModalDialogOpen/useModalDialogOpen.ts new file mode 100644 index 000000000..8cfb88b2d --- /dev/null +++ b/src/hooks/useModalDialogOpen/useModalDialogOpen.ts @@ -0,0 +1,26 @@ +import { useEffect, useState } from "react"; + +function useModalDialogOpen( + open: boolean, + cbs: { + onClose?: () => void; + onOpen?: () => void; + } +): void { + const [prevOpen, setPrevOpen] = useState(open); + + useEffect(() => { + if (open !== prevOpen) { + setPrevOpen(open); + if (cbs.onOpen && open) { + cbs.onOpen(); + } + + if (cbs.onClose && !open) { + cbs.onClose(); + } + } + }, [open]); +} + +export default useModalDialogOpen; diff --git a/src/hooks/useMultiAutocomplete.ts b/src/hooks/useMultiAutocomplete.ts new file mode 100644 index 000000000..af3a375a2 --- /dev/null +++ b/src/hooks/useMultiAutocomplete.ts @@ -0,0 +1,28 @@ +import { MultiAutocompleteChoiceType } from "@saleor/components/MultiAutocompleteSelectField"; +import { maybe } from "@saleor/misc"; +import useListActions from "./useListActions"; + +function useMultiAutocomplete(initial: MultiAutocompleteChoiceType[] = []) { + const { listElements, toggle } = useListActions( + initial, + (a, b) => a.value === b.value + ); + const handleSelect = ( + event: React.ChangeEvent, + choices: MultiAutocompleteChoiceType[] + ) => { + const value: string = event.target.value; + const match = choices.find(choice => choice.value === value); + toggle({ + label: maybe(() => match.label, value), + value + }); + }; + + return { + change: handleSelect, + data: listElements + }; +} + +export default useMultiAutocomplete; diff --git a/src/hooks/useSearchQuery.ts b/src/hooks/useSearchQuery.ts new file mode 100644 index 000000000..eac204bfc --- /dev/null +++ b/src/hooks/useSearchQuery.ts @@ -0,0 +1,29 @@ +import React from "react"; + +import { ChangeEvent } from "@saleor/hooks/useForm"; + +export type UseSearchQuery = [string, (event: ChangeEvent) => void, () => void]; +function useSearchQuery( + onFetch: (query: string) => void, + initial?: string +): UseSearchQuery { + const [query, setQuery] = React.useState(initial || ""); + const change = (event: ChangeEvent) => { + const value = event.target.value; + + onFetch(value); + setQuery(value); + }; + + const reset = () => + change({ + target: { + name: "", + value: initial || "" + } + }); + + return [query, change, reset]; +} + +export default useSearchQuery; diff --git a/src/hooks/useStateFromProps.ts b/src/hooks/useStateFromProps.ts new file mode 100644 index 000000000..b70bbb2a5 --- /dev/null +++ b/src/hooks/useStateFromProps.ts @@ -0,0 +1,35 @@ +import isEqual from "lodash-es/isEqual"; +import { Dispatch, SetStateAction, useState } from "react"; + +export interface UseStateFromPropsOpts { + mergeFunc?: (prevData: T, state: T, newData: T) => T; + onRefresh?: (data: T) => void; +} + +function useStateFromProps( + data: T, + opts?: UseStateFromPropsOpts +): [T, Dispatch>] { + const [state, setState] = useState(data); + const [prevData, setPrevData] = useState(data); + if (!opts) { + opts = {}; + } + + const { mergeFunc, onRefresh } = opts; + const shouldUpdate = !isEqual(prevData, data); + + if (shouldUpdate) { + const newData = + typeof mergeFunc === "function" ? mergeFunc(prevData, state, data) : data; + setState(newData); + setPrevData(data); + if (typeof onRefresh === "function") { + onRefresh(newData); + } + } + + return [state, setState]; +} + +export default useStateFromProps; diff --git a/src/hooks/useWindowScroll.ts b/src/hooks/useWindowScroll.ts new file mode 100644 index 000000000..5218d58c9 --- /dev/null +++ b/src/hooks/useWindowScroll.ts @@ -0,0 +1,24 @@ +import throttle from "lodash-es/throttle"; +import { useEffect, useState } from "react"; + +function getPosition() { + return { + x: window.pageXOffset, + y: window.pageYOffset + }; +} + +function useWindowScroll() { + const [scroll, setScroll] = useState(getPosition); + + useEffect(() => { + const handleScroll = throttle(() => setScroll(getPosition()), 100); + + window.addEventListener("scroll", handleScroll); + + return () => window.removeEventListener("scroll", handleScroll); + }, []); + + return scroll; +} +export default useWindowScroll; diff --git a/src/productTypes/components/AssignAttributeDialog/AssignAttributeDialog.tsx b/src/productTypes/components/AssignAttributeDialog/AssignAttributeDialog.tsx new file mode 100644 index 000000000..add4b20ff --- /dev/null +++ b/src/productTypes/components/AssignAttributeDialog/AssignAttributeDialog.tsx @@ -0,0 +1,192 @@ +import Button from "@material-ui/core/Button"; +import CircularProgress from "@material-ui/core/CircularProgress"; +import Dialog from "@material-ui/core/Dialog"; +import DialogActions from "@material-ui/core/DialogActions"; +import DialogContent from "@material-ui/core/DialogContent"; +import DialogContentText from "@material-ui/core/DialogContentText"; +import DialogTitle from "@material-ui/core/DialogTitle"; +import { Theme } from "@material-ui/core/styles"; +import Table from "@material-ui/core/Table"; +import TableBody from "@material-ui/core/TableBody"; +import TableCell from "@material-ui/core/TableCell"; +import TableRow from "@material-ui/core/TableRow"; +import TextField from "@material-ui/core/TextField"; +import Typography from "@material-ui/core/Typography"; +import makeStyles from "@material-ui/styles/makeStyles"; +import React from "react"; +import InfiniteScroll from "react-infinite-scroller"; + +import Checkbox from "@saleor/components/Checkbox"; +import ConfirmButton, { + ConfirmButtonTransitionState +} from "@saleor/components/ConfirmButton"; +import useModalDialogErrors from "@saleor/hooks/useModalDialogErrors"; +import useModalDialogOpen from "@saleor/hooks/useModalDialogOpen"; +import useSearchQuery from "@saleor/hooks/useSearchQuery"; +import i18n from "@saleor/i18n"; +import { maybe, renderCollection } from "@saleor/misc"; +import { FetchMoreProps } from "@saleor/types"; +import { SearchAttributes_productType_availableAttributes_edges_node } from "../../containers/SearchAttributes/types/SearchAttributes"; + +const useStyles = makeStyles((theme: Theme) => ({ + checkboxCell: { + paddingLeft: 0 + }, + loadMoreLoaderContainer: { + alignItems: "center", + display: "flex", + height: theme.spacing.unit * 3, + justifyContent: "center" + }, + scrollArea: { + overflowY: "scroll" + }, + wideCell: { + width: "100%" + } +})); + +export interface AssignAttributeDialogProps extends FetchMoreProps { + confirmButtonState: ConfirmButtonTransitionState; + errors: string[]; + open: boolean; + attributes: SearchAttributes_productType_availableAttributes_edges_node[]; + selected: string[]; + onClose: () => void; + onOpen: () => void; + onSubmit: () => void; + onToggle: (id: string) => void; +} + +const AssignAttributeDialog: React.FC = ({ + attributes, + confirmButtonState, + errors: apiErrors, + hasMore, + loading, + open, + selected, + onClose, + onFetch, + onFetchMore, + onOpen, + onSubmit, + onToggle +}: AssignAttributeDialogProps) => { + const classes = useStyles({}); + const [query, onQueryChange, resetQuery] = useSearchQuery(onFetch); + const errors = useModalDialogErrors(apiErrors, open); + + useModalDialogOpen(open, { + onClose: resetQuery, + onOpen + }); + + return ( + + {i18n.t("Assign Attribute")} + + + }} + /> + + + + + + } + threshold={100} + key="infinite-scroll" + > + + + {renderCollection( + attributes, + attribute => { + if (!attribute) { + return null; + } + const isChecked = !!selected.find( + selectedAttribute => selectedAttribute === attribute.id + ); + + return ( + attribute.id)}> + + onToggle(attribute.id)} + /> + + + {attribute.name} + + {attribute.slug} + + + + ); + }, + () => + !loading && ( + + + {i18n.t("No results found")} + + + ) + )} + +
+
+
+ {errors.length > 0 && ( + + {errors.map((error, errorIndex) => ( + + {error} + + ))} + + )} + + + + {i18n.t("Assign attributes", { context: "button" })} + + +
+ ); +}; +AssignAttributeDialog.displayName = "AssignAttributeDialog"; +export default AssignAttributeDialog; diff --git a/src/productTypes/components/AssignAttributeDialog/index.ts b/src/productTypes/components/AssignAttributeDialog/index.ts new file mode 100644 index 000000000..2960aa002 --- /dev/null +++ b/src/productTypes/components/AssignAttributeDialog/index.ts @@ -0,0 +1,2 @@ +export { default } from './AssignAttributeDialog'; +export * from './AssignAttributeDialog'; \ No newline at end of file diff --git a/src/productTypes/components/ProductTypeAttributeUnassignDialog/ProductTypeAttributeUnassignDialog.tsx b/src/productTypes/components/ProductTypeAttributeUnassignDialog/ProductTypeAttributeUnassignDialog.tsx new file mode 100644 index 000000000..e99ce0a0c --- /dev/null +++ b/src/productTypes/components/ProductTypeAttributeUnassignDialog/ProductTypeAttributeUnassignDialog.tsx @@ -0,0 +1,49 @@ +import DialogContentText from "@material-ui/core/DialogContentText"; +import React from "react"; + +import ActionDialog from "@saleor/components/ActionDialog"; +import { ConfirmButtonTransitionState } from "@saleor/components/ConfirmButton"; +import i18n from "@saleor/i18n"; + +export interface ProductTypeAttributeUnassignDialogProps { + attributeName: string; + confirmButtonState: ConfirmButtonTransitionState; + open: boolean; + productTypeName: string; + onClose: () => void; + onConfirm: () => void; +} + +const ProductTypeAttributeUnassignDialog: React.FC< + ProductTypeAttributeUnassignDialogProps +> = ({ + attributeName, + confirmButtonState, + open, + productTypeName, + onClose, + onConfirm +}) => ( + + {{ attributeName }} from {{ productTypeName }}?", + { + attributeName, + productTypeName + } + ) + }} + /> + +); +ProductTypeAttributeUnassignDialog.displayName = + "ProductTypeAttributeUnassignDialog"; +export default ProductTypeAttributeUnassignDialog; diff --git a/src/productTypes/components/ProductTypeAttributeUnassignDialog/index.ts b/src/productTypes/components/ProductTypeAttributeUnassignDialog/index.ts new file mode 100644 index 000000000..dc8bd0a03 --- /dev/null +++ b/src/productTypes/components/ProductTypeAttributeUnassignDialog/index.ts @@ -0,0 +1,2 @@ +export { default } from './ProductTypeAttributeUnassignDialog'; +export * from './ProductTypeAttributeUnassignDialog'; \ No newline at end of file diff --git a/src/productTypes/components/ProductTypeBulkAttributeUnassignDialog/ProductTypeBulkAttributeUnassignDialog.tsx b/src/productTypes/components/ProductTypeBulkAttributeUnassignDialog/ProductTypeBulkAttributeUnassignDialog.tsx new file mode 100644 index 000000000..0f73c08c4 --- /dev/null +++ b/src/productTypes/components/ProductTypeBulkAttributeUnassignDialog/ProductTypeBulkAttributeUnassignDialog.tsx @@ -0,0 +1,49 @@ +import DialogContentText from "@material-ui/core/DialogContentText"; +import React from "react"; + +import ActionDialog from "@saleor/components/ActionDialog"; +import { ConfirmButtonTransitionState } from "@saleor/components/ConfirmButton"; +import i18n from "@saleor/i18n"; + +export interface ProductTypeBulkAttributeUnassignDialogProps { + attributeQuantity: string; + confirmButtonState: ConfirmButtonTransitionState; + open: boolean; + productTypeName: string; + onClose: () => void; + onConfirm: () => void; +} + +const ProductTypeBulkAttributeUnassignDialog: React.FC< + ProductTypeBulkAttributeUnassignDialogProps +> = ({ + attributeQuantity, + confirmButtonState, + open, + productTypeName, + onClose, + onConfirm +}) => ( + + {{ attributeQuantity }} attributes from {{ productTypeName }}?", + { + attributeQuantity, + productTypeName + } + ) + }} + /> + +); +ProductTypeBulkAttributeUnassignDialog.displayName = + "ProductTypeBulkAttributeUnassignDialog"; +export default ProductTypeBulkAttributeUnassignDialog; diff --git a/src/productTypes/components/ProductTypeBulkAttributeUnassignDialog/index.ts b/src/productTypes/components/ProductTypeBulkAttributeUnassignDialog/index.ts new file mode 100644 index 000000000..e6df37e86 --- /dev/null +++ b/src/productTypes/components/ProductTypeBulkAttributeUnassignDialog/index.ts @@ -0,0 +1,2 @@ +export { default } from './ProductTypeBulkAttributeUnassignDialog'; +export * from './ProductTypeBulkAttributeUnassignDialog'; \ No newline at end of file diff --git a/src/productTypes/components/ProductTypeDeleteDialog/ProductTypeDeleteDialog.tsx b/src/productTypes/components/ProductTypeDeleteDialog/ProductTypeDeleteDialog.tsx new file mode 100644 index 000000000..56d42e316 --- /dev/null +++ b/src/productTypes/components/ProductTypeDeleteDialog/ProductTypeDeleteDialog.tsx @@ -0,0 +1,43 @@ +import DialogContentText from "@material-ui/core/DialogContentText"; +import React from "react"; + +import ActionDialog from "@saleor/components/ActionDialog"; +import { ConfirmButtonTransitionState } from "@saleor/components/ConfirmButton"; +import i18n from "@saleor/i18n"; + +export interface ProductTypeDeleteDialogProps { + confirmButtonState: ConfirmButtonTransitionState; + open: boolean; + name: string; + onClose: () => void; + onConfirm: () => void; +} + +const ProductTypeDeleteDialog: React.FC = ({ + confirmButtonState, + open, + name, + onClose, + onConfirm +}) => ( + + {{ name }}?", + { + name + } + ) + }} + /> + +); +ProductTypeDeleteDialog.displayName = "ProductTypeDeleteDialog"; +export default ProductTypeDeleteDialog; diff --git a/src/productTypes/components/ProductTypeDeleteDialog/index.ts b/src/productTypes/components/ProductTypeDeleteDialog/index.ts new file mode 100644 index 000000000..4c9e6f92d --- /dev/null +++ b/src/productTypes/components/ProductTypeDeleteDialog/index.ts @@ -0,0 +1,2 @@ +export { default } from './ProductTypeDeleteDialog'; +export * from './ProductTypeDeleteDialog'; \ No newline at end of file diff --git a/src/productTypes/containers/SearchAttributes/index.tsx b/src/productTypes/containers/SearchAttributes/index.tsx new file mode 100644 index 000000000..6af8faa86 --- /dev/null +++ b/src/productTypes/containers/SearchAttributes/index.tsx @@ -0,0 +1,42 @@ +import gql from "graphql-tag"; + +import { pageInfoFragment } from "@saleor/queries"; +import BaseSearch from "../../../containers/BaseSearch"; +import { + SearchAttributes, + SearchAttributesVariables +} from "./types/SearchAttributes"; + +export const searchAttributes = gql` + ${pageInfoFragment} + query SearchAttributes( + $id: ID! + $after: String + $first: Int! + $query: String! + ) { + productType(id: $id) { + id + availableAttributes( + after: $after + first: $first + filter: { search: $query } + ) { + edges { + node { + id + name + slug + } + } + pageInfo { + ...PageInfoFragment + } + } + } + } +`; + +export default BaseSearch( + searchAttributes +); diff --git a/src/productTypes/containers/SearchAttributes/types/SearchAttributes.ts b/src/productTypes/containers/SearchAttributes/types/SearchAttributes.ts new file mode 100644 index 000000000..b45411fa9 --- /dev/null +++ b/src/productTypes/containers/SearchAttributes/types/SearchAttributes.ts @@ -0,0 +1,50 @@ +/* tslint:disable */ +/* eslint-disable */ +// This file was automatically generated and should not be edited. + +// ==================================================== +// GraphQL query operation: SearchAttributes +// ==================================================== + +export interface SearchAttributes_productType_availableAttributes_edges_node { + __typename: "Attribute"; + id: string; + name: string | null; + slug: string | null; +} + +export interface SearchAttributes_productType_availableAttributes_edges { + __typename: "AttributeCountableEdge"; + node: SearchAttributes_productType_availableAttributes_edges_node; +} + +export interface SearchAttributes_productType_availableAttributes_pageInfo { + __typename: "PageInfo"; + endCursor: string | null; + hasNextPage: boolean; + hasPreviousPage: boolean; + startCursor: string | null; +} + +export interface SearchAttributes_productType_availableAttributes { + __typename: "AttributeCountableConnection"; + edges: SearchAttributes_productType_availableAttributes_edges[]; + pageInfo: SearchAttributes_productType_availableAttributes_pageInfo; +} + +export interface SearchAttributes_productType { + __typename: "ProductType"; + id: string; + availableAttributes: SearchAttributes_productType_availableAttributes | null; +} + +export interface SearchAttributes { + productType: SearchAttributes_productType | null; +} + +export interface SearchAttributesVariables { + id: string; + after?: string | null; + first: number; + query: string; +} diff --git a/src/productTypes/types/AssignAttribute.ts b/src/productTypes/types/AssignAttribute.ts new file mode 100644 index 000000000..d6ec1df81 --- /dev/null +++ b/src/productTypes/types/AssignAttribute.ts @@ -0,0 +1,74 @@ +/* tslint:disable */ +/* eslint-disable */ +// This file was automatically generated and should not be edited. + +import { AttributeAssignInput } from "./../../types/globalTypes"; + +// ==================================================== +// GraphQL mutation operation: AssignAttribute +// ==================================================== + +export interface AssignAttribute_attributeAssign_errors { + __typename: "Error"; + field: string | null; + message: string | null; +} + +export interface AssignAttribute_attributeAssign_productType_taxType { + __typename: "TaxType"; + description: string | null; + taxCode: string | null; +} + +export interface AssignAttribute_attributeAssign_productType_productAttributes { + __typename: "Attribute"; + id: string; + name: string | null; + slug: string | null; + visibleInStorefront: boolean; + filterableInDashboard: boolean; + filterableInStorefront: boolean; +} + +export interface AssignAttribute_attributeAssign_productType_variantAttributes { + __typename: "Attribute"; + id: string; + name: string | null; + slug: string | null; + visibleInStorefront: boolean; + filterableInDashboard: boolean; + filterableInStorefront: boolean; +} + +export interface AssignAttribute_attributeAssign_productType_weight { + __typename: "Weight"; + unit: string; + value: number; +} + +export interface AssignAttribute_attributeAssign_productType { + __typename: "ProductType"; + id: string; + name: string; + hasVariants: boolean; + isShippingRequired: boolean; + taxType: AssignAttribute_attributeAssign_productType_taxType | null; + productAttributes: (AssignAttribute_attributeAssign_productType_productAttributes | null)[] | null; + variantAttributes: (AssignAttribute_attributeAssign_productType_variantAttributes | null)[] | null; + weight: AssignAttribute_attributeAssign_productType_weight | null; +} + +export interface AssignAttribute_attributeAssign { + __typename: "AttributeAssign"; + errors: AssignAttribute_attributeAssign_errors[] | null; + productType: AssignAttribute_attributeAssign_productType | null; +} + +export interface AssignAttribute { + attributeAssign: AssignAttribute_attributeAssign | null; +} + +export interface AssignAttributeVariables { + id: string; + operations: AttributeAssignInput[]; +} diff --git a/src/productTypes/types/ProductTypeAttributeReorder.ts b/src/productTypes/types/ProductTypeAttributeReorder.ts new file mode 100644 index 000000000..32bc41c66 --- /dev/null +++ b/src/productTypes/types/ProductTypeAttributeReorder.ts @@ -0,0 +1,75 @@ +/* tslint:disable */ +/* eslint-disable */ +// This file was automatically generated and should not be edited. + +import { ReorderInput, AttributeTypeEnum } from "./../../types/globalTypes"; + +// ==================================================== +// GraphQL mutation operation: ProductTypeAttributeReorder +// ==================================================== + +export interface ProductTypeAttributeReorder_productTypeReorderAttributes_errors { + __typename: "Error"; + field: string | null; + message: string | null; +} + +export interface ProductTypeAttributeReorder_productTypeReorderAttributes_productType_taxType { + __typename: "TaxType"; + description: string | null; + taxCode: string | null; +} + +export interface ProductTypeAttributeReorder_productTypeReorderAttributes_productType_productAttributes { + __typename: "Attribute"; + id: string; + name: string | null; + slug: string | null; + visibleInStorefront: boolean; + filterableInDashboard: boolean; + filterableInStorefront: boolean; +} + +export interface ProductTypeAttributeReorder_productTypeReorderAttributes_productType_variantAttributes { + __typename: "Attribute"; + id: string; + name: string | null; + slug: string | null; + visibleInStorefront: boolean; + filterableInDashboard: boolean; + filterableInStorefront: boolean; +} + +export interface ProductTypeAttributeReorder_productTypeReorderAttributes_productType_weight { + __typename: "Weight"; + unit: string; + value: number; +} + +export interface ProductTypeAttributeReorder_productTypeReorderAttributes_productType { + __typename: "ProductType"; + id: string; + name: string; + hasVariants: boolean; + isShippingRequired: boolean; + taxType: ProductTypeAttributeReorder_productTypeReorderAttributes_productType_taxType | null; + productAttributes: (ProductTypeAttributeReorder_productTypeReorderAttributes_productType_productAttributes | null)[] | null; + variantAttributes: (ProductTypeAttributeReorder_productTypeReorderAttributes_productType_variantAttributes | null)[] | null; + weight: ProductTypeAttributeReorder_productTypeReorderAttributes_productType_weight | null; +} + +export interface ProductTypeAttributeReorder_productTypeReorderAttributes { + __typename: "ProductTypeReorderAttributes"; + errors: ProductTypeAttributeReorder_productTypeReorderAttributes_errors[] | null; + productType: ProductTypeAttributeReorder_productTypeReorderAttributes_productType | null; +} + +export interface ProductTypeAttributeReorder { + productTypeReorderAttributes: ProductTypeAttributeReorder_productTypeReorderAttributes | null; +} + +export interface ProductTypeAttributeReorderVariables { + move: ReorderInput; + productTypeId: string; + type: AttributeTypeEnum; +} diff --git a/src/productTypes/types/UnassignAttribute.ts b/src/productTypes/types/UnassignAttribute.ts new file mode 100644 index 000000000..f47c53826 --- /dev/null +++ b/src/productTypes/types/UnassignAttribute.ts @@ -0,0 +1,72 @@ +/* tslint:disable */ +/* eslint-disable */ +// This file was automatically generated and should not be edited. + +// ==================================================== +// GraphQL mutation operation: UnassignAttribute +// ==================================================== + +export interface UnassignAttribute_attributeUnassign_errors { + __typename: "Error"; + field: string | null; + message: string | null; +} + +export interface UnassignAttribute_attributeUnassign_productType_taxType { + __typename: "TaxType"; + description: string | null; + taxCode: string | null; +} + +export interface UnassignAttribute_attributeUnassign_productType_productAttributes { + __typename: "Attribute"; + id: string; + name: string | null; + slug: string | null; + visibleInStorefront: boolean; + filterableInDashboard: boolean; + filterableInStorefront: boolean; +} + +export interface UnassignAttribute_attributeUnassign_productType_variantAttributes { + __typename: "Attribute"; + id: string; + name: string | null; + slug: string | null; + visibleInStorefront: boolean; + filterableInDashboard: boolean; + filterableInStorefront: boolean; +} + +export interface UnassignAttribute_attributeUnassign_productType_weight { + __typename: "Weight"; + unit: string; + value: number; +} + +export interface UnassignAttribute_attributeUnassign_productType { + __typename: "ProductType"; + id: string; + name: string; + hasVariants: boolean; + isShippingRequired: boolean; + taxType: UnassignAttribute_attributeUnassign_productType_taxType | null; + productAttributes: (UnassignAttribute_attributeUnassign_productType_productAttributes | null)[] | null; + variantAttributes: (UnassignAttribute_attributeUnassign_productType_variantAttributes | null)[] | null; + weight: UnassignAttribute_attributeUnassign_productType_weight | null; +} + +export interface UnassignAttribute_attributeUnassign { + __typename: "AttributeUnassign"; + errors: UnassignAttribute_attributeUnassign_errors[] | null; + productType: UnassignAttribute_attributeUnassign_productType | null; +} + +export interface UnassignAttribute { + attributeUnassign: UnassignAttribute_attributeUnassign | null; +} + +export interface UnassignAttributeVariables { + id: string; + ids: (string | null)[]; +} diff --git a/src/products/components/ProductAttributes/ProductAttributes.tsx b/src/products/components/ProductAttributes/ProductAttributes.tsx new file mode 100644 index 000000000..e7c085491 --- /dev/null +++ b/src/products/components/ProductAttributes/ProductAttributes.tsx @@ -0,0 +1,214 @@ +import Card from "@material-ui/core/Card"; +import CardContent from "@material-ui/core/CardContent"; +import IconButton from "@material-ui/core/IconButton"; +import { Theme } from "@material-ui/core/styles"; +import Typography from "@material-ui/core/Typography"; +import ArrowDropDownIcon from "@material-ui/icons/ArrowDropDown"; +import makeStyles from "@material-ui/styles/makeStyles"; +import classNames from "classnames"; +import React from "react"; + +import CardTitle from "@saleor/components/CardTitle"; +import Grid from "@saleor/components/Grid"; +import Hr from "@saleor/components/Hr"; +import MultiAutocompleteSelectField, { + MultiAutocompleteChoiceType +} from "@saleor/components/MultiAutocompleteSelectField"; +import SingleAutocompleteSelectField, { + SingleAutocompleteChoiceType +} from "@saleor/components/SingleAutocompleteSelectField"; +import { FormsetAtomicData, FormsetChange } from "@saleor/hooks/useFormset"; +import i18n from "@saleor/i18n"; +import { maybe } from "@saleor/misc"; +import { ProductDetails_product_attributes_attribute_values } from "@saleor/products/types/ProductDetails"; +import { AttributeInputTypeEnum } from "@saleor/types/globalTypes"; + +export interface ProductAttributeInputData { + inputType: AttributeInputTypeEnum; + isRequired: boolean; + values: ProductDetails_product_attributes_attribute_values[]; +} +export type ProductAttributeInput = FormsetAtomicData< + ProductAttributeInputData, + string[] +>; +export interface ProductAttributesProps { + attributes: ProductAttributeInput[]; + disabled: boolean; + onChange: FormsetChange; + onMultiChange: FormsetChange; +} + +const useStyles = makeStyles((theme: Theme) => ({ + attributeSection: { + "&:last-of-type": { + paddingBottom: 0 + }, + padding: `${theme.spacing.unit * 2}px 0` + }, + attributeSectionLabel: { + alignItems: "center", + display: "flex" + }, + card: { + overflow: "visible" + }, + cardContent: { + "&:last-child": { + paddingBottom: theme.spacing.unit + }, + paddingTop: theme.spacing.unit + }, + expansionBar: { + display: "flex" + }, + expansionBarButton: { + marginBottom: theme.spacing.unit + }, + expansionBarButtonIcon: { + transition: theme.transitions.duration.short + "ms" + }, + expansionBarLabel: { + color: theme.palette.text.disabled, + fontSize: 14 + }, + expansionBarLabelContainer: { + alignItems: "center", + display: "flex", + flex: 1 + }, + rotate: { + transform: "rotate(180deg)" + } +})); + +function getMultiChoices( + values: ProductDetails_product_attributes_attribute_values[] +): MultiAutocompleteChoiceType[] { + return values.map(value => ({ + label: value.name, + value: value.slug + })); +} + +function getMultiDisplayValue( + attribute: ProductAttributeInput +): MultiAutocompleteChoiceType[] { + return attribute.value.map(attributeValue => { + const definedAttributeValue = attribute.data.values.find( + definedValue => definedValue.slug === attributeValue + ); + if (!!definedAttributeValue) { + return { + label: definedAttributeValue.name, + value: definedAttributeValue.slug + }; + } + + return { + label: attributeValue, + value: attributeValue + }; + }); +} + +function getSingleChoices( + values: ProductDetails_product_attributes_attribute_values[] +): SingleAutocompleteChoiceType[] { + return values.map(value => ({ + label: value.name, + value: value.slug + })); +} + +const ProductAttributes: React.FC = ({ + attributes, + disabled, + onChange, + onMultiChange +}) => { + const classes = useStyles({}); + const [expanded, setExpansionStatus] = React.useState(true); + const toggleExpansion = () => setExpansionStatus(!expanded); + + return ( + + + +
+
+ + {i18n.t("{{ number }} Attributes", { + context: "number of attribute", + number: attributes.length + })} + +
+ + + +
+ {expanded && attributes.length > 0 && ( + <> +
+ {attributes.map((attribute, attributeIndex) => ( + + {attributeIndex > 0 &&
} + +
+ {attribute.label} +
+
+ {attribute.data.inputType === + AttributeInputTypeEnum.DROPDOWN ? ( + + attribute.data.values.find( + value => value.slug === attribute.value[0] + ).name, + attribute.value[0] + )} + emptyOption + name={`attribute:${attribute.label}`} + label={i18n.t("Value")} + value={attribute.value[0]} + onChange={event => + onChange(attribute.id, event.target.value) + } + allowCustomValues={!attribute.data.isRequired} + /> + ) : ( + + onMultiChange(attribute.id, event.target.value) + } + allowCustomValues={!attribute.data.isRequired} + /> + )} +
+
+
+ ))} + + )} +
+
+ ); +}; +ProductAttributes.displayName = "ProductAttributes"; +export default ProductAttributes; diff --git a/src/products/components/ProductAttributes/index.ts b/src/products/components/ProductAttributes/index.ts new file mode 100644 index 000000000..090807d29 --- /dev/null +++ b/src/products/components/ProductAttributes/index.ts @@ -0,0 +1,2 @@ +export { default } from './ProductAttributes'; +export * from './ProductAttributes'; \ No newline at end of file diff --git a/src/products/components/ProductListPage/ProductListPage.tsx b/src/products/components/ProductListPage/ProductListPage.tsx new file mode 100644 index 000000000..0b075177b --- /dev/null +++ b/src/products/components/ProductListPage/ProductListPage.tsx @@ -0,0 +1,133 @@ +import Button from "@material-ui/core/Button"; +import Card from "@material-ui/core/Card"; +import { Theme } from "@material-ui/core/styles"; +import AddIcon from "@material-ui/icons/Add"; +import makeStyles from "@material-ui/styles/makeStyles"; +import React from "react"; + +import { CategoryDetails_category_products_edges_node } from "@saleor/categories/types/CategoryDetails"; +import ColumnPicker, { + ColumnPickerChoice +} from "@saleor/components/ColumnPicker"; +import Container from "@saleor/components/Container"; +import PageHeader from "@saleor/components/PageHeader"; +import ProductList from "@saleor/components/ProductList"; +import { ProductListColumns } from "@saleor/config"; +import useStateFromProps from "@saleor/hooks/useStateFromProps"; +import i18n from "@saleor/i18n"; +import { FilterPageProps, ListActions, PageListProps } from "@saleor/types"; +import { toggle } from "@saleor/utils/lists"; +import { ProductListUrlFilters } from "../../urls"; +import ProductListFilter from "../ProductListFilter"; + +export interface ProductListPageProps + extends PageListProps, + ListActions, + FilterPageProps { + currencySymbol: string; + products: CategoryDetails_category_products_edges_node[]; +} + +const useStyles = makeStyles((theme: Theme) => ({ + columnPicker: { + marginRight: theme.spacing.unit * 3 + } +})); + +export const ProductListPage: React.FC = props => { + const { + currencySymbol, + currentTab, + defaultSettings, + filtersList, + filterTabs, + initialSearch, + settings, + onAdd, + onAll, + onSearchChange, + onFilterAdd, + onFilterSave, + onTabChange, + onFilterDelete, + onUpdateListSettings, + ...listProps + } = props; + const classes = useStyles(props); + const [selectedColumns, setSelectedColumns] = useStateFromProps( + settings.columns + ); + + const handleCancel = React.useCallback( + () => setSelectedColumns(settings.columns), + [settings.columns] + ); + + const handleColumnToggle = (column: ProductListColumns) => + setSelectedColumns(prevSelectedColumns => + toggle(column, prevSelectedColumns, (a, b) => a === b) + ); + + const handleReset = () => setSelectedColumns(defaultSettings.columns); + + const handleSave = () => onUpdateListSettings("columns", selectedColumns); + + const columns: ColumnPickerChoice[] = [ + { + label: i18n.t("Published"), + value: "isPublished" as ProductListColumns + }, + { + label: i18n.t("Price"), + value: "price" as ProductListColumns + }, + { + label: i18n.t("Type"), + value: "productType" as ProductListColumns + } + ]; + + return ( + + + + + + + + + + + ); +}; +ProductListPage.displayName = "ProductListPage"; +export default ProductListPage; diff --git a/src/products/components/ProductListPage/index.ts b/src/products/components/ProductListPage/index.ts new file mode 100644 index 000000000..a5e54aa30 --- /dev/null +++ b/src/products/components/ProductListPage/index.ts @@ -0,0 +1,2 @@ +export { default } from "./ProductListPage"; +export * from "./ProductListPage"; diff --git a/src/products/utils/data.ts b/src/products/utils/data.ts new file mode 100644 index 000000000..05cc7bad1 --- /dev/null +++ b/src/products/utils/data.ts @@ -0,0 +1,224 @@ +import { RawDraftContentState } from "draft-js"; + +import { MultiAutocompleteChoiceType } from "@saleor/components/MultiAutocompleteSelectField"; +import { SingleAutocompleteChoiceType } from "@saleor/components/SingleAutocompleteSelectField"; +import { maybe } from "@saleor/misc"; +import { + ProductDetails_product, + ProductDetails_product_collections, + ProductDetails_product_variants +} from "@saleor/products/types/ProductDetails"; +import { UserError } from "@saleor/types"; +import { ProductAttributeInput } from "../components/ProductAttributes"; +import { VariantAttributeInput } from "../components/ProductVariantAttributes"; +import { ProductCreateData_productTypes_edges_node_productAttributes } from "../types/ProductCreateData"; +import { + ProductVariant, + ProductVariant_attributes_attribute +} from "../types/ProductVariant"; +import { + ProductVariantCreateData_product, + ProductVariantCreateData_product_productType_variantAttributes +} from "../types/ProductVariantCreateData"; + +export interface Collection { + id: string; + label: string; +} + +interface Node { + id: string; + name: string; +} + +export interface ProductType { + hasVariants: boolean; + id: string; + name: string; + productAttributes: ProductCreateData_productTypes_edges_node_productAttributes[]; +} + +export function getAttributeInputFromProduct( + product: ProductDetails_product +): ProductAttributeInput[] { + return maybe( + (): ProductAttributeInput[] => + product.attributes.map(attribute => ({ + data: { + inputType: attribute.attribute.inputType, + isRequired: attribute.attribute.valueRequired, + values: attribute.attribute.values + }, + id: attribute.attribute.id, + label: attribute.attribute.name, + value: attribute.values.map(value => value.slug) + })), + [] + ); +} + +export interface ProductAttributeValueChoices { + id: string; + values: MultiAutocompleteChoiceType[]; +} +export function getSelectedAttributesFromProduct( + product: ProductDetails_product +): ProductAttributeValueChoices[] { + return maybe( + () => + product.attributes.map(attribute => ({ + id: attribute.attribute.id, + values: attribute.values.map(value => ({ + label: value.name, + value: value.slug + })) + })), + [] + ); +} + +export function getAttributeInputFromProductType( + productType: ProductType +): ProductAttributeInput[] { + return productType.productAttributes.map(attribute => ({ + data: { + inputType: attribute.inputType, + isRequired: attribute.valueRequired, + values: attribute.values + }, + id: attribute.id, + label: attribute.name, + value: [] + })); +} + +export function getAttributeInputFromVariant( + product: ProductVariant +): VariantAttributeInput[] { + return maybe( + (): VariantAttributeInput[] => + product.attributes.map(attribute => ({ + data: { + values: attribute.attribute.values + }, + id: attribute.attribute.id, + label: attribute.attribute.name, + value: attribute.value.slug + })), + [] + ); +} + +export function getVariantAttributeInputFromProduct( + product: ProductVariantCreateData_product +): VariantAttributeInput[] { + return maybe(() => + product.productType.variantAttributes.map(attribute => ({ + data: { + values: attribute.values + }, + id: attribute.id, + label: attribute.name, + value: "" + })) + ); +} + +export function getCollectionInput( + productCollections: ProductDetails_product_collections[] +): Collection[] { + return maybe( + () => + productCollections.map(collection => ({ + id: collection.id, + label: collection.name + })), + [] + ); +} + +export function getChoices(nodes: Node[]): SingleAutocompleteChoiceType[] { + return maybe( + () => + nodes.map(node => ({ + label: node.name, + value: node.id + })), + [] + ); +} + +export interface ProductUpdatePageFormData { + basePrice: number; + category: string | null; + collections: string[]; + chargeTaxes: boolean; + description: RawDraftContentState; + isPublished: boolean; + name: string; + publicationDate: string; + seoDescription: string; + seoTitle: string; + sku: string; + stockQuantity: number; +} + +export function getProductUpdatePageFormData( + product: ProductDetails_product, + variants: ProductDetails_product_variants[] +): ProductUpdatePageFormData { + return { + basePrice: maybe(() => product.basePrice.amount, 0), + category: maybe(() => product.category.id, ""), + chargeTaxes: maybe(() => product.chargeTaxes, false), + collections: maybe( + () => product.collections.map(collection => collection.id), + [] + ), + description: maybe(() => JSON.parse(product.descriptionJson)), + isPublished: maybe(() => product.isPublished, false), + name: maybe(() => product.name, ""), + publicationDate: maybe(() => product.publicationDate, ""), + seoDescription: maybe(() => product.seoDescription, ""), + seoTitle: maybe(() => product.seoTitle, ""), + sku: maybe(() => + product.productType.hasVariants + ? undefined + : variants && variants[0] + ? variants[0].sku + : undefined + ), + stockQuantity: maybe(() => + product.productType.hasVariants + ? undefined + : variants && variants[0] + ? variants[0].quantity + : undefined + ) + }; +} + +export function getVariantAttributeErrors( + errors: UserError[], + variantAttributes: Array< + | ProductVariantCreateData_product_productType_variantAttributes + | ProductVariant_attributes_attribute + > +): Record { + return maybe( + () => + errors.reduce((acc, err) => { + const slug = err.field.split(":")[1]; + const attribute = variantAttributes.find( + attribute => attribute.slug === slug + ); + + if (!!attribute) { + acc[attribute.id] = err.message; + } + + return acc; + }, {}), + {} + ); +} diff --git a/src/products/utils/handlers.ts b/src/products/utils/handlers.ts new file mode 100644 index 000000000..7f0dc5071 --- /dev/null +++ b/src/products/utils/handlers.ts @@ -0,0 +1,117 @@ +import { FormChange } from "@saleor/hooks/useForm"; +import { FormsetChange, FormsetData } from "@saleor/hooks/useFormset"; +import { maybe } from "@saleor/misc"; +import { toggle } from "@saleor/utils/lists"; +import { ProductAttributeInputData } from "../components/ProductAttributes"; +import { + getAttributeInputFromProductType, + ProductAttributeValueChoices, + ProductType +} from "./data"; + +export function createAttributeChangeHandler( + changeAttributeData: FormsetChange, + setSelectedAttributes: (data: ProductAttributeValueChoices[]) => void, + selectedAttributes: ProductAttributeValueChoices[], + attributes: FormsetData, + triggerChange: () => void +): FormsetChange { + return (attributeId: string, value: string) => { + const attributeValue = attributes + .find(attribute => attribute.id === attributeId) + .data.values.find(attributeValue => attributeValue.slug === value); + + const valueChoice = { + label: maybe(() => attributeValue.name, value), + value + }; + + const itemIndex = selectedAttributes.findIndex( + item => item.id === attributeId + ); + const attribute = selectedAttributes[itemIndex]; + + setSelectedAttributes([ + ...selectedAttributes.slice(0, itemIndex), + { + ...attribute, + values: [valueChoice] + }, + ...selectedAttributes.slice(itemIndex + 1) + ]); + + triggerChange(); + changeAttributeData(attributeId, [value]); + }; +} + +export function createAttributeMultiChangeHandler( + changeAttributeData: FormsetChange, + setSelectedAttributes: (data: ProductAttributeValueChoices[]) => void, + selectedAttributes: ProductAttributeValueChoices[], + attributes: FormsetData, + triggerChange: () => void +): FormsetChange { + return (attributeId: string, value: string) => { + const attributeValue = attributes + .find(attribute => attribute.id === attributeId) + .data.values.find(attributeValue => attributeValue.slug === value); + + const valueChoice = { + label: attributeValue ? attributeValue.name : value, + value + }; + + const itemIndex = selectedAttributes.findIndex( + item => item.id === attributeId + ); + const attributeValues = selectedAttributes[itemIndex].values; + + const newAttributeValues = toggle( + valueChoice, + attributeValues, + (a, b) => a.value === b.value + ); + + const newSelectedAttributes = [ + ...selectedAttributes.slice(0, itemIndex), + { + ...selectedAttributes[itemIndex], + values: newAttributeValues + }, + ...selectedAttributes.slice(itemIndex + 1) + ]; + setSelectedAttributes(newSelectedAttributes); + + triggerChange(); + changeAttributeData( + attributeId, + newAttributeValues.map(({ value }) => value) + ); + }; +} + +export function createProductTypeSelectHandler( + change: FormChange, + setAttributes: (data: FormsetData) => void, + setSelectedAttributes: (data: ProductAttributeValueChoices[]) => void, + setProductType: (productType: ProductType) => void, + productTypeChoiceList: ProductType[] +): FormChange { + return (event: React.ChangeEvent) => { + const id = event.target.value; + const selectedProductType = productTypeChoiceList.find( + productType => productType.id === id + ); + setProductType(selectedProductType); + change(event); + + setAttributes(getAttributeInputFromProductType(selectedProductType)); + setSelectedAttributes( + selectedProductType.productAttributes.map(attribute => ({ + id: attribute.id, + values: [] + })) + ); + }; +} diff --git a/src/products/views/ProductUpdate/ProductUpdate.tsx b/src/products/views/ProductUpdate/ProductUpdate.tsx new file mode 100644 index 000000000..846a85654 --- /dev/null +++ b/src/products/views/ProductUpdate/ProductUpdate.tsx @@ -0,0 +1,333 @@ +import DialogContentText from "@material-ui/core/DialogContentText"; +import IconButton from "@material-ui/core/IconButton"; +import DeleteIcon from "@material-ui/icons/Delete"; +import React from "react"; + +import ActionDialog from "@saleor/components/ActionDialog"; +import { WindowTitle } from "@saleor/components/WindowTitle"; +import useBulkActions from "@saleor/hooks/useBulkActions"; +import useNavigator from "@saleor/hooks/useNavigator"; +import useNotifier from "@saleor/hooks/useNotifier"; +import placeholderImg from "../../../../images/placeholder255x255.png"; +import { DEFAULT_INITIAL_SEARCH_DATA } from "../../../config"; +import SearchCategories from "../../../containers/SearchCategories"; +import SearchCollections from "../../../containers/SearchCollections"; +import i18n from "../../../i18n"; +import { getMutationState, maybe } from "../../../misc"; +import { productTypeUrl } from "../../../productTypes/urls"; +import ProductUpdatePage from "../../components/ProductUpdatePage"; +import ProductUpdateOperations from "../../containers/ProductUpdateOperations"; +import { TypedProductDetailsQuery } from "../../queries"; +import { + ProductImageCreate, + ProductImageCreateVariables +} from "../../types/ProductImageCreate"; +import { ProductUpdate as ProductUpdateMutationResult } from "../../types/ProductUpdate"; +import { ProductVariantBulkDelete } from "../../types/ProductVariantBulkDelete"; +import { + productImageUrl, + productListUrl, + productUrl, + ProductUrlDialog, + ProductUrlQueryParams, + productVariantAddUrl, + productVariantEditUrl +} from "../../urls"; +import { + createImageReorderHandler, + createImageUploadHandler, + createUpdateHandler +} from "./handlers"; + +interface ProductUpdateProps { + id: string; + params: ProductUrlQueryParams; +} + +export const ProductUpdate: React.StatelessComponent = ({ + id, + params +}) => { + const navigate = useNavigator(); + const notify = useNotifier(); + const { isSelected, listElements, reset, toggle, toggleAll } = useBulkActions( + params.ids + ); + + const openModal = (action: ProductUrlDialog) => + navigate( + productUrl(id, { + action + }) + ); + + return ( + + {({ search: searchCategories, result: searchCategoriesOpts }) => ( + + {({ search: searchCollections, result: searchCollectionsOpts }) => ( + + {({ data, loading, refetch }) => { + const handleDelete = () => { + notify({ text: i18n.t("Product removed") }); + navigate(productListUrl()); + }; + const handleUpdate = (data: ProductUpdateMutationResult) => { + if (data.productUpdate.errors.length === 0) { + notify({ text: i18n.t("Saved changes") }); + } else { + const attributeError = data.productUpdate.errors.find( + err => err.field === "attributes" + ); + if (!!attributeError) { + notify({ text: attributeError.message }); + } + } + }; + + const handleImageCreate = (data: ProductImageCreate) => { + const imageError = data.productImageCreate.errors.find( + error => + error.field === + ("image" as keyof ProductImageCreateVariables) + ); + if (imageError) { + notify({ + text: imageError.message + }); + } + }; + const handleImageDeleteSuccess = () => + notify({ + text: i18n.t("Image successfully deleted") + }); + const handleVariantAdd = () => + navigate(productVariantAddUrl(id)); + + const handleBulkProductVariantDelete = ( + data: ProductVariantBulkDelete + ) => { + if (data.productVariantBulkDelete.errors.length === 0) { + navigate(productUrl(id), true); + reset(); + refetch(); + } + }; + + const product = data ? data.product : undefined; + return ( + + {({ + bulkProductVariantDelete, + createProductImage, + deleteProduct, + deleteProductImage, + reorderProductImages, + updateProduct, + updateSimpleProduct + }) => { + const handleImageDelete = (id: string) => () => + deleteProductImage.mutate({ id }); + const handleImageEdit = (imageId: string) => () => + navigate(productImageUrl(id, imageId)); + const handleSubmit = createUpdateHandler( + product, + updateProduct.mutate, + updateSimpleProduct.mutate + ); + const handleImageUpload = createImageUploadHandler( + id, + createProductImage.mutate + ); + const handleImageReorder = createImageReorderHandler( + product, + reorderProductImages.mutate + ); + + const disableFormSave = + createProductImage.opts.loading || + deleteProduct.opts.loading || + reorderProductImages.opts.loading || + updateProduct.opts.loading || + loading; + const formTransitionState = getMutationState( + updateProduct.opts.called || + updateSimpleProduct.opts.called, + updateProduct.opts.loading || + updateSimpleProduct.opts.loading, + maybe( + () => updateProduct.opts.data.productUpdate.errors + ), + maybe( + () => + updateSimpleProduct.opts.data.productUpdate.errors + ), + maybe( + () => + updateSimpleProduct.opts.data.productVariantUpdate + .errors + ) + ); + const deleteTransitionState = getMutationState( + deleteProduct.opts.called, + deleteProduct.opts.loading, + maybe( + () => deleteProduct.opts.data.productDelete.errors + ) + ); + + const bulkProductVariantDeleteTransitionState = getMutationState( + bulkProductVariantDelete.opts.called, + bulkProductVariantDelete.opts.loading, + maybe( + () => + bulkProductVariantDelete.opts.data + .productVariantBulkDelete.errors + ) + ); + + const categories = maybe( + () => searchCategoriesOpts.data.categories.edges, + [] + ).map(edge => edge.node); + const collections = maybe( + () => searchCollectionsOpts.data.collections.edges, + [] + ).map(edge => edge.node); + const errors = maybe( + () => updateProduct.opts.data.productUpdate.errors, + [] + ); + + return ( + <> + data.product.name)} /> + data.product.images)} + header={maybe(() => product.name)} + placeholderImage={placeholderImg} + product={product} + variants={maybe(() => product.variants)} + onAttributesEdit={() => + navigate( + productTypeUrl(data.product.productType.id) + ) + } + onBack={() => { + navigate(productListUrl()); + }} + onDelete={() => openModal("remove")} + onProductShow={() => { + if (product) { + window.open(product.url); + } + }} + onImageReorder={handleImageReorder} + onSubmit={handleSubmit} + onVariantAdd={handleVariantAdd} + onVariantShow={variantId => () => + navigate( + productVariantEditUrl(product.id, variantId) + )} + onImageUpload={handleImageUpload} + onImageEdit={handleImageEdit} + onImageDelete={handleImageDelete} + toolbar={ + + navigate( + productUrl(id, { + action: "remove-variants", + ids: listElements + }) + ) + } + > + + + } + isChecked={isSelected} + selected={listElements.length} + toggle={toggle} + toggleAll={toggleAll} + /> + navigate(productUrl(id), true)} + confirmButtonState={deleteTransitionState} + onConfirm={() => deleteProduct.mutate({ id })} + variant="delete" + title={i18n.t("Remove product")} + > + {{ name }}?", + { + name: product ? product.name : undefined + } + ) + }} + /> + + navigate(productUrl(id), true)} + confirmButtonState={ + bulkProductVariantDeleteTransitionState + } + onConfirm={() => + bulkProductVariantDelete.mutate({ + ids: params.ids + }) + } + variant="delete" + title={i18n.t("Remove product variants")} + > + {{ number }} variants?", + { + number: maybe( + () => params.ids.length.toString(), + "..." + ) + } + ) + }} + /> + + + ); + }} + + ); + }} + + )} + + )} + + ); +}; +export default ProductUpdate; diff --git a/src/products/views/ProductUpdate/handlers.ts b/src/products/views/ProductUpdate/handlers.ts new file mode 100644 index 000000000..fd356f33a --- /dev/null +++ b/src/products/views/ProductUpdate/handlers.ts @@ -0,0 +1,77 @@ +import { decimal } 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 { ReorderEvent } from "@saleor/types"; +import { arrayMove } from "react-sortable-hoc"; + +export function createUpdateHandler( + product: ProductDetails_product, + updateProduct: (variables: ProductUpdateVariables) => void, + updateSimpleProduct: (variables: SimpleProductUpdateVariables) => void +) { + return (data: ProductUpdatePageSubmitData) => { + const productVariables: ProductUpdateVariables = { + attributes: data.attributes.map(attribute => ({ + id: attribute.id, + values: attribute.value[0] === "" ? [] : attribute.value + })), + basePrice: decimal(data.basePrice), + category: data.category, + chargeTaxes: data.chargeTaxes, + collections: data.collections, + descriptionJson: JSON.stringify(data.description), + id: product.id, + isPublished: data.isPublished, + name: data.name, + publicationDate: + data.publicationDate !== "" ? data.publicationDate : null, + seo: { + description: data.seoDescription, + title: data.seoTitle + } + }; + + if (product.productType.hasVariants) { + updateProduct(productVariables); + } else { + updateSimpleProduct({ + ...productVariables, + productVariantId: product.variants[0].id, + productVariantInput: { + quantity: data.stockQuantity, + sku: data.sku + } + }); + } + }; +} + +export function createImageUploadHandler( + id: string, + createProductImage: (variables: ProductImageCreateVariables) => void +) { + return (file: File) => + createProductImage({ + alt: "", + image: file, + product: id + }); +} + +export function createImageReorderHandler( + product: ProductDetails_product, + reorderProductImages: (variables: ProductImageReorderVariables) => void +) { + return ({ newIndex, oldIndex }: ReorderEvent) => { + let ids = product.images.map(image => image.id); + ids = arrayMove(ids, oldIndex, newIndex); + reorderProductImages({ + imagesIds: ids, + productId: product.id + }); + }; +} diff --git a/src/products/views/ProductUpdate/index.ts b/src/products/views/ProductUpdate/index.ts new file mode 100644 index 000000000..7d893197c --- /dev/null +++ b/src/products/views/ProductUpdate/index.ts @@ -0,0 +1 @@ +export { default } from "./ProductUpdate"; diff --git a/src/siteSettings/components/SiteSettingsAddress/SiteSettingsAddress.tsx b/src/siteSettings/components/SiteSettingsAddress/SiteSettingsAddress.tsx new file mode 100644 index 000000000..966078c03 --- /dev/null +++ b/src/siteSettings/components/SiteSettingsAddress/SiteSettingsAddress.tsx @@ -0,0 +1,151 @@ +import Card from "@material-ui/core/Card"; +import CardContent from "@material-ui/core/CardContent"; +import { createStyles, withStyles, WithStyles } from "@material-ui/core/styles"; +import TextField from "@material-ui/core/TextField"; +import React from "react"; + +import CardTitle from "@saleor/components/CardTitle"; +import FormSpacer from "@saleor/components/FormSpacer"; +import Grid from "@saleor/components/Grid"; +import SingleAutocompleteSelectField, { + SingleAutocompleteChoiceType +} from "@saleor/components/SingleAutocompleteSelectField"; +import { AddressTypeInput } from "@saleor/customers/types"; +import { ChangeEvent } from "@saleor/hooks/useForm"; +import i18n from "@saleor/i18n"; +import { FormErrors } from "@saleor/types"; +import { SiteSettingsPageFormData } from "../SiteSettingsPage"; + +interface SiteSettingsAddressProps extends WithStyles { + countries: SingleAutocompleteChoiceType[]; + data: SiteSettingsPageFormData; + displayCountry: string; + errors: FormErrors; + disabled: boolean; + onChange: (event: ChangeEvent) => void; + onCountryChange: (event: ChangeEvent) => void; +} + +const styles = createStyles({ + root: { + overflow: "visible" + } +}); + +const SiteSettingsAddress = withStyles(styles, { name: "SiteSettingsAddress" })( + ({ + classes, + countries, + data, + disabled, + displayCountry, + errors, + onChange, + onCountryChange + }: SiteSettingsAddressProps) => ( + + + + + + + + + + + + + + + + + + + + + + + ) +); +SiteSettingsAddress.displayName = "SiteSettingsAddress"; +export default SiteSettingsAddress; diff --git a/src/siteSettings/components/SiteSettingsAddress/index.ts b/src/siteSettings/components/SiteSettingsAddress/index.ts new file mode 100644 index 000000000..b15a23918 --- /dev/null +++ b/src/siteSettings/components/SiteSettingsAddress/index.ts @@ -0,0 +1,2 @@ +export { default } from "./SiteSettingsAddress"; +export * from "./SiteSettingsAddress"; diff --git a/src/storybook/stories/attributes/AttributeBulkDeleteDialog.tsx b/src/storybook/stories/attributes/AttributeBulkDeleteDialog.tsx new file mode 100644 index 000000000..0a09020eb --- /dev/null +++ b/src/storybook/stories/attributes/AttributeBulkDeleteDialog.tsx @@ -0,0 +1,19 @@ +import { storiesOf } from "@storybook/react"; +import React from "react"; + +import AttributeBulkDeleteDialog, { + AttributeBulkDeleteDialogProps +} from "../../../attributes/components/AttributeBulkDeleteDialog"; +import Decorator from "../../Decorator"; + +const props: AttributeBulkDeleteDialogProps = { + confirmButtonState: "default", + onClose: () => undefined, + onConfirm: () => undefined, + open: true, + quantity: "5" +}; + +storiesOf("Attributes / Delete multiple attributes", module) + .addDecorator(Decorator) + .add("default", () => ); diff --git a/src/storybook/stories/attributes/AttributeDeleteDialog.tsx b/src/storybook/stories/attributes/AttributeDeleteDialog.tsx new file mode 100644 index 000000000..23df15a80 --- /dev/null +++ b/src/storybook/stories/attributes/AttributeDeleteDialog.tsx @@ -0,0 +1,19 @@ +import { storiesOf } from "@storybook/react"; +import React from "react"; + +import AttributeDeleteDialog, { + AttributeDeleteDialogProps +} from "../../../attributes/components/AttributeDeleteDialog"; +import Decorator from "../../Decorator"; + +const props: AttributeDeleteDialogProps = { + confirmButtonState: "default", + name: "Size", + onClose: () => undefined, + onConfirm: () => undefined, + open: true +}; + +storiesOf("Attributes / Attribute delete", module) + .addDecorator(Decorator) + .add("default", () => ); diff --git a/src/storybook/stories/attributes/AttributeListPage.tsx b/src/storybook/stories/attributes/AttributeListPage.tsx new file mode 100644 index 000000000..e757661f3 --- /dev/null +++ b/src/storybook/stories/attributes/AttributeListPage.tsx @@ -0,0 +1,23 @@ +import { storiesOf } from "@storybook/react"; +import React from "react"; + +import AttributeListPage, { + AttributeListPageProps +} from "@saleor/attributes/components/AttributeListPage"; +import { attributes } from "@saleor/attributes/fixtures"; +import { listActionsProps, pageListProps } from "@saleor/fixtures"; +import Decorator from "../../Decorator"; + +const props: AttributeListPageProps = { + ...pageListProps.default, + ...listActionsProps, + attributes +}; + +storiesOf("Views / Attributes / Attribute list", module) + .addDecorator(Decorator) + .add("default", () => ) + .add("loading", () => ( + + )) + .add("no data", () => ); diff --git a/src/storybook/stories/attributes/AttributePage.tsx b/src/storybook/stories/attributes/AttributePage.tsx new file mode 100644 index 000000000..e2d8f5b68 --- /dev/null +++ b/src/storybook/stories/attributes/AttributePage.tsx @@ -0,0 +1,54 @@ +import { storiesOf } from "@storybook/react"; +import React from "react"; + +import AttributePage, { + AttributePageProps +} from "@saleor/attributes/components/AttributePage"; +import { attribute } from "@saleor/attributes/fixtures"; +import { formError } from "@saleor/storybook/misc"; +import { AttributeInputTypeEnum } from "@saleor/types/globalTypes"; +import Decorator from "../../Decorator"; + +const props: AttributePageProps = { + attribute, + disabled: false, + errors: [], + onBack: () => undefined, + onDelete: () => undefined, + onSubmit: () => undefined, + onValueAdd: () => undefined, + onValueDelete: () => undefined, + onValueReorder: () => undefined, + onValueUpdate: () => undefined, + saveButtonBarState: "default", + values: attribute.values +}; + +storiesOf("Views / Attributes / Attribute details", module) + .addDecorator(Decorator) + .add("default", () => ) + .add("loading", () => ( + + )) + .add("no values", () => ) + .add("form errors", () => ( + + )) + .add("multiple select input", () => ( + + )) + .add("create", () => ); diff --git a/src/storybook/stories/attributes/AttributeValueDeleteDialog.tsx b/src/storybook/stories/attributes/AttributeValueDeleteDialog.tsx new file mode 100644 index 000000000..08f6a5fde --- /dev/null +++ b/src/storybook/stories/attributes/AttributeValueDeleteDialog.tsx @@ -0,0 +1,20 @@ +import { storiesOf } from "@storybook/react"; +import React from "react"; + +import AttributeValueDeleteDialog, { + AttributeValueDeleteDialogProps +} from "../../../attributes/components/AttributeValueDeleteDialog"; +import Decorator from "../../Decorator"; + +const props: AttributeValueDeleteDialogProps = { + attributeName: "Size", + confirmButtonState: "default", + name: "XS", + onClose: () => undefined, + onConfirm: () => undefined, + open: true +}; + +storiesOf("Attributes / Attribute value delete", module) + .addDecorator(Decorator) + .add("default", () => ); diff --git a/src/storybook/stories/attributes/AttributeValueEditDialog.tsx b/src/storybook/stories/attributes/AttributeValueEditDialog.tsx new file mode 100644 index 000000000..a04afb0cd --- /dev/null +++ b/src/storybook/stories/attributes/AttributeValueEditDialog.tsx @@ -0,0 +1,31 @@ +import { storiesOf } from "@storybook/react"; +import React from "react"; + +import { attribute } from "@saleor/attributes/fixtures"; +import { formError } from "@saleor/storybook/misc"; +import { AttributeValueType } from "@saleor/types/globalTypes"; +import AttributeValueEditDialog, { + AttributeValueEditDialogProps +} from "../../../attributes/components/AttributeValueEditDialog"; +import Decorator from "../../Decorator"; + +const props: AttributeValueEditDialogProps = { + attributeValue: { + ...attribute.values[0], + type: AttributeValueType.STRING, + value: "" + }, + confirmButtonState: "default", + disabled: false, + errors: [], + onClose: () => undefined, + onSubmit: () => undefined, + open: true +}; + +storiesOf("Attributes / Attribute value edit", module) + .addDecorator(Decorator) + .add("default", () => ) + .add("form errors", () => ( + + )); diff --git a/src/storybook/stories/components/AssignAttributeDialog.tsx b/src/storybook/stories/components/AssignAttributeDialog.tsx new file mode 100644 index 000000000..d438dcc9a --- /dev/null +++ b/src/storybook/stories/components/AssignAttributeDialog.tsx @@ -0,0 +1,33 @@ +import { storiesOf } from "@storybook/react"; +import React from "react"; + +import { attributes } from "@saleor/attributes/fixtures"; +import { fetchMoreProps } from "@saleor/fixtures"; +import AssignAttributeDialog, { + AssignAttributeDialogProps +} from "@saleor/productTypes/components/AssignAttributeDialog"; +import { formError } from "@saleor/storybook/misc"; +import Decorator from "../../Decorator"; + +const props: AssignAttributeDialogProps = { + ...fetchMoreProps, + attributes: attributes.slice(0, 5), + confirmButtonState: "default", + errors: [], + onClose: () => undefined, + onOpen: () => undefined, + onSubmit: () => undefined, + onToggle: () => undefined, + open: true, + selected: [attributes[0].id, attributes[3].id] +}; + +storiesOf("Generics / Assign attributes dialog", module) + .addDecorator(Decorator) + .add("default", () => ) + .add("loading", () => ( + + )) + .add("errors", () => ( + + )); diff --git a/src/storybook/stories/components/AssignProductDialog.tsx b/src/storybook/stories/components/AssignProductDialog.tsx new file mode 100644 index 000000000..cdc91c8f0 --- /dev/null +++ b/src/storybook/stories/components/AssignProductDialog.tsx @@ -0,0 +1,23 @@ +import { storiesOf } from "@storybook/react"; +import React from "react"; + +import AssignProductDialog, { + AssignProductDialogProps +} from "@saleor/components/AssignProductDialog"; +import { products } from "@saleor/products/fixtures"; +import placeholderImage from "../../../../images/placeholder60x60.png"; +import Decorator from "../../Decorator"; + +const props: AssignProductDialogProps = { + confirmButtonState: "default", + loading: false, + onClose: () => undefined, + onFetch: () => undefined, + onSubmit: () => undefined, + open: true, + products: products(placeholderImage) +}; + +storiesOf("Generics / Assign product", module) + .addDecorator(Decorator) + .add("default", () => ); diff --git a/src/storybook/stories/components/ColumnPicker.tsx b/src/storybook/stories/components/ColumnPicker.tsx new file mode 100644 index 000000000..9da2bc9cc --- /dev/null +++ b/src/storybook/stories/components/ColumnPicker.tsx @@ -0,0 +1,45 @@ +import { storiesOf } from "@storybook/react"; +import React from "react"; + +import ColumnPicker, { + ColumnPickerProps +} from "@saleor/components/ColumnPicker"; +import { ColumnPickerChoice } from "@saleor/components/ColumnPicker/ColumnPickerContent"; +import CardDecorator from "@saleor/storybook/CardDecorator"; +import Decorator from "../../Decorator"; + +const columns: ColumnPickerChoice[] = [ + { label: "Name", value: "name" }, + { label: "Value", value: "value" }, + { label: "Type", value: "type" }, + { label: "Size", value: "size" }, + { label: "Status", value: "isPublished" }, + { label: "Price", value: "price" }, + { label: "Digital", value: "isDigital" }, + ...Array(15) + .fill(0) + .map((_, index) => ({ + label: "Attribute " + (index + 1), + value: "attribute_" + index + })) +]; + +const props: ColumnPickerProps = { + columns, + initial: true, + onCancel: () => undefined, + onColumnToggle: () => undefined, + onReset: () => undefined, + onSave: () => undefined, + selectedColumns: [1, 3, 4, 6].map(index => columns[index].value) +}; + +storiesOf("Generics / Column picker", module) + .addDecorator(storyFn => ( +
+ {storyFn()} +
+ )) + .addDecorator(CardDecorator) + .addDecorator(Decorator) + .add("default", () => ); diff --git a/src/storybook/stories/productTypes/ProductTypeAttributeUnassignDialog.tsx b/src/storybook/stories/productTypes/ProductTypeAttributeUnassignDialog.tsx new file mode 100644 index 000000000..b8e6861db --- /dev/null +++ b/src/storybook/stories/productTypes/ProductTypeAttributeUnassignDialog.tsx @@ -0,0 +1,20 @@ +import { storiesOf } from "@storybook/react"; +import React from "react"; + +import ProductTypeAttributeUnassignDialog, { + ProductTypeAttributeUnassignDialogProps +} from "../../../productTypes/components/ProductTypeAttributeUnassignDialog"; +import Decorator from "../../Decorator"; + +const props: ProductTypeAttributeUnassignDialogProps = { + attributeName: "Size", + confirmButtonState: "default", + onClose: () => undefined, + onConfirm: () => undefined, + open: true, + productTypeName: "Shoes" +}; + +storiesOf("Views / Product types / Unassign attribute", module) + .addDecorator(Decorator) + .add("default", () => ); diff --git a/src/storybook/stories/productTypes/ProductTypeBulkAttributeUnassignDialog.tsx b/src/storybook/stories/productTypes/ProductTypeBulkAttributeUnassignDialog.tsx new file mode 100644 index 000000000..1c3941025 --- /dev/null +++ b/src/storybook/stories/productTypes/ProductTypeBulkAttributeUnassignDialog.tsx @@ -0,0 +1,20 @@ +import { storiesOf } from "@storybook/react"; +import React from "react"; + +import ProductTypeBulkAttributeUnassignDialog, { + ProductTypeBulkAttributeUnassignDialogProps +} from "../../../productTypes/components/ProductTypeBulkAttributeUnassignDialog"; +import Decorator from "../../Decorator"; + +const props: ProductTypeBulkAttributeUnassignDialogProps = { + attributeQuantity: "4", + confirmButtonState: "default", + onClose: () => undefined, + onConfirm: () => undefined, + open: true, + productTypeName: "Shoes" +}; + +storiesOf("Views / Product types / Unassign multiple attributes", module) + .addDecorator(Decorator) + .add("default", () => ); diff --git a/src/storybook/stories/productTypes/ProductTypeDeleteDialog.tsx b/src/storybook/stories/productTypes/ProductTypeDeleteDialog.tsx new file mode 100644 index 000000000..6ffe808fe --- /dev/null +++ b/src/storybook/stories/productTypes/ProductTypeDeleteDialog.tsx @@ -0,0 +1,19 @@ +import { storiesOf } from "@storybook/react"; +import React from "react"; + +import ProductTypeDeleteDialog, { + ProductTypeDeleteDialogProps +} from "../../../productTypes/components/ProductTypeDeleteDialog"; +import Decorator from "../../Decorator"; + +const props: ProductTypeDeleteDialogProps = { + confirmButtonState: "default", + name: "Shoes", + onClose: () => undefined, + onConfirm: () => undefined, + open: true +}; + +storiesOf("Product types / ProductTypeDeleteDialog", module) + .addDecorator(Decorator) + .add("default", () => ); diff --git a/src/storybook/stories/products/ProductListPage.tsx b/src/storybook/stories/products/ProductListPage.tsx new file mode 100644 index 000000000..c5c43dc0a --- /dev/null +++ b/src/storybook/stories/products/ProductListPage.tsx @@ -0,0 +1,50 @@ +import { storiesOf } from "@storybook/react"; +import React from "react"; + +import { defaultListSettings } from "@saleor/config"; +import { ListViews } from "@saleor/types"; +import placeholderImage from "../../../../images/placeholder255x255.png"; +import { category as categoryFixture } from "../../../categories/fixtures"; +import { + filterPageProps, + filters, + listActionsProps, + pageListProps +} from "../../../fixtures"; +import ProductListPage, { + ProductListPageProps +} from "../../../products/components/ProductListPage"; +import Decorator from "../../Decorator"; + +const products = categoryFixture(placeholderImage).products.edges.map( + edge => edge.node +); + +const props: ProductListPageProps = { + ...listActionsProps, + ...pageListProps.default, + ...filterPageProps, + defaultSettings: defaultListSettings[ListViews.PRODUCT_LIST], + products, + settings: { + ...pageListProps.default.settings, + columns: ["isPublished", "productType", "price"] + } +}; + +storiesOf("Views / Products / Product list", module) + .addDecorator(Decorator) + .add("default", () => ) + .add("with custom filters", () => ( + + )) + .add("loading", () => ( + + )) + .add("no data", () => ); diff --git a/src/utils/errors.ts b/src/utils/errors.ts new file mode 100644 index 000000000..e7920f83b --- /dev/null +++ b/src/utils/errors.ts @@ -0,0 +1,14 @@ +import { maybe } from "@saleor/misc"; +import { UserError } from "@saleor/types"; + +export function getFieldError(errors: UserError[], field: string): string { + const err = errors.find(err => err.field === field); + + return maybe(() => err.message); +} + +export function getErrors(errors: UserError[]): string[] { + return errors + .filter(err => ["", null].includes(err.field)) + .map(err => err.message); +} diff --git a/src/utils/handlers/multiAutocompleteSelectChangeHandler.ts b/src/utils/handlers/multiAutocompleteSelectChangeHandler.ts new file mode 100644 index 000000000..72f792740 --- /dev/null +++ b/src/utils/handlers/multiAutocompleteSelectChangeHandler.ts @@ -0,0 +1,21 @@ +import { MultiAutocompleteChoiceType } from "@saleor/components/MultiAutocompleteSelectField"; +import { ChangeEvent, FormChange } from "@saleor/hooks/useForm"; +import { toggle } from "../lists"; + +function createMultiAutocompleteSelectHandler( + change: FormChange, + setSelected: (choices: MultiAutocompleteChoiceType[]) => void, + selected: MultiAutocompleteChoiceType[], + choices: MultiAutocompleteChoiceType[] +): FormChange { + return (event: ChangeEvent) => { + change(event); + + const id = event.target.value; + const choice = choices.find(choice => choice.value === id); + + setSelected(toggle(choice, selected, (a, b) => a.value === b.value)); + }; +} + +export default createMultiAutocompleteSelectHandler; diff --git a/src/utils/handlers/queryChangeHandler.ts b/src/utils/handlers/queryChangeHandler.ts new file mode 100644 index 000000000..8297c44cb --- /dev/null +++ b/src/utils/handlers/queryChangeHandler.ts @@ -0,0 +1,12 @@ +import { ChangeEvent } from "@saleor/hooks/useForm"; + +export function onQueryChange( + event: ChangeEvent, + onFetch: (data: string) => void, + setQuery: (data: string) => void +) { + const value = event.target.value; + + onFetch(value); + setQuery(value); +} diff --git a/src/utils/handlers/singleAutocompleteSelectChangeHandler.ts b/src/utils/handlers/singleAutocompleteSelectChangeHandler.ts new file mode 100644 index 000000000..ee2265185 --- /dev/null +++ b/src/utils/handlers/singleAutocompleteSelectChangeHandler.ts @@ -0,0 +1,17 @@ +import { SingleAutocompleteChoiceType } from "@saleor/components/SingleAutocompleteSelectField"; +import { FormChange } from "@saleor/hooks/useForm"; + +function createSingleAutocompleteSelectHandler( + change: FormChange, + setSelected: (value: string) => void, + choices: SingleAutocompleteChoiceType[] +): FormChange { + return (event: React.ChangeEvent) => { + change(event); + + const value = event.target.value; + setSelected(choices.find(category => category.value === value).label); + }; +} + +export default createSingleAutocompleteSelectHandler; diff --git a/src/utils/i18n.ts b/src/utils/i18n.ts new file mode 100644 index 000000000..2781c0056 --- /dev/null +++ b/src/utils/i18n.ts @@ -0,0 +1,5 @@ +import i18n from "@saleor/i18n"; + +export function translateBoolean(value: boolean): string { + return value ? i18n.t("Yes") : i18n.t("No"); +} diff --git a/src/utils/lists/__snapshots__/lists.test.ts.snap b/src/utils/lists/__snapshots__/lists.test.ts.snap new file mode 100644 index 000000000..998d4c2e4 --- /dev/null +++ b/src/utils/lists/__snapshots__/lists.test.ts.snap @@ -0,0 +1,65 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Properly calculates output arrays Adds 1`] = ` +Array [ + "lorem", + "ipsum", + "dolor", + "sit", +] +`; + +exports[`Properly calculates output arrays Adds at index 1`] = ` +Array [ + "lorem", + "ipsum", + "sit", + "dolor", +] +`; + +exports[`Properly calculates output arrays Moves 1`] = ` +Array [ + "ipsum", + "lorem", + "dolor", +] +`; + +exports[`Properly calculates output arrays Removes 1`] = ` +Array [ + "lorem", + "dolor", +] +`; + +exports[`Properly calculates output arrays Removes at index 1`] = ` +Array [ + "lorem", + "dolor", +] +`; + +exports[`Properly calculates output arrays Toggles 1`] = ` +Array [ + "ipsum", + "dolor", +] +`; + +exports[`Properly calculates output arrays Toggles 2`] = ` +Array [ + "lorem", + "ipsum", + "dolor", + "sit", +] +`; + +exports[`Properly calculates output arrays Updates at index 1`] = ` +Array [ + "lorem", + "amet", + "dolor", +] +`; diff --git a/src/utils/lists/index.ts b/src/utils/lists/index.ts new file mode 100644 index 000000000..fc00ec863 --- /dev/null +++ b/src/utils/lists/index.ts @@ -0,0 +1 @@ +export * from "./lists"; diff --git a/src/utils/lists/lists.test.ts b/src/utils/lists/lists.test.ts new file mode 100644 index 000000000..dc6976b1c --- /dev/null +++ b/src/utils/lists/lists.test.ts @@ -0,0 +1,48 @@ +import { + add, + addAtIndex, + isSelected, + move, + remove, + removeAtIndex, + toggle, + updateAtIndex +} from "./lists"; + +const initialArray = ["lorem", "ipsum", "dolor"]; + +describe("Properly calculates output arrays", () => { + it("Adds", () => { + expect(add("sit", initialArray)).toMatchSnapshot(); + }); + + it("Adds at index", () => { + expect(addAtIndex("sit", initialArray, 2)).toMatchSnapshot(); + }); + + it("Updates at index", () => { + expect(updateAtIndex("amet", initialArray, 1)).toMatchSnapshot(); + }); + + it("Removes", () => { + expect(remove("ipsum", initialArray, (a, b) => a === b)).toMatchSnapshot(); + }); + + it("Removes at index", () => { + expect(removeAtIndex(initialArray, 1)).toMatchSnapshot(); + }); + + it("Matches", () => { + expect(isSelected("lorem", initialArray, (a, b) => a === b)).toBe(true); + expect(isSelected("sit", initialArray, (a, b) => a === b)).toBe(false); + }); + + it("Toggles", () => { + expect(toggle("lorem", initialArray, (a, b) => a === b)).toMatchSnapshot(); + expect(toggle("sit", initialArray, (a, b) => a === b)).toMatchSnapshot(); + }); + + it("Moves", () => { + expect(move("lorem", initialArray, (a, b) => a === b, 1)).toMatchSnapshot(); + }); +}); diff --git a/src/utils/lists/lists.ts b/src/utils/lists/lists.ts new file mode 100644 index 000000000..21005bacc --- /dev/null +++ b/src/utils/lists/lists.ts @@ -0,0 +1,61 @@ +type List = TData[]; +type Compare = (a: TData, b: TData) => boolean; + +export function isSelected( + data: TData, + list: List, + compare: Compare +) { + return !!list.find(listElement => compare(listElement, data)); +} + +export function add(data: TData, list: List) { + return [...list, data]; +} + +export function addAtIndex( + data: TData, + list: List, + index: number +) { + return [...list.slice(0, index), data, ...list.slice(index)]; +} + +export function move( + data: TData, + list: List, + compare: Compare, + index: number +) { + return addAtIndex(data, remove(data, list, compare), index); +} + +export function updateAtIndex( + data: TData, + list: List, + index: number +) { + return addAtIndex(data, removeAtIndex(list, index), index); +} + +export function remove( + data: TData, + list: List, + compare: Compare +) { + return list.filter(listElement => !compare(listElement, data)); +} + +export function removeAtIndex(list: List, index: number) { + return [...list.slice(0, index), ...list.slice(index + 1)]; +} + +export function toggle( + data: TData, + list: List, + compare: Compare +) { + return isSelected(data, list, compare) + ? remove(data, list, compare) + : add(data, list); +} diff --git a/src/utils/maps.ts b/src/utils/maps.ts new file mode 100644 index 000000000..11b76640e --- /dev/null +++ b/src/utils/maps.ts @@ -0,0 +1,12 @@ +import { MultiAutocompleteChoiceType } from "@saleor/components/MultiAutocompleteSelectField"; +import { ShopInfo_shop_countries } from "@saleor/components/Shop/types/ShopInfo"; +import { SingleAutocompleteChoiceType } from "@saleor/components/SingleAutocompleteSelectField"; + +export function mapCountriesToChoices( + countries: ShopInfo_shop_countries[] +): Array { + return countries.map(country => ({ + label: country.country, + value: country.code + })); +}