diff --git a/package.json b/package.json index b1d825397a..a10503fbd2 100644 --- a/package.json +++ b/package.json @@ -129,6 +129,7 @@ ], "coverageDirectory": "reports/coverage", "reporters": [ + "default", "jest-junit" ], "roots": [ diff --git a/src/components/AccountDropdown/AccountDropdown.js b/src/components/AccountDropdown/AccountDropdown.js index 2045e72513..cfce399ad6 100644 --- a/src/components/AccountDropdown/AccountDropdown.js +++ b/src/components/AccountDropdown/AccountDropdown.js @@ -14,7 +14,7 @@ const styles = (theme) => ({ width: 320, padding: theme.spacing.unit * 2 }, - authContent: { + marginBottom: { marginBottom: theme.spacing.unit * 2 } }); @@ -73,6 +73,11 @@ class AccountDropdown extends Component {
{authStore.isAuthenticated ? +
+ +
diff --git a/src/components/ProfileAddressBook/ProfileAddressBook.js b/src/components/ProfileAddressBook/ProfileAddressBook.js new file mode 100644 index 0000000000..f8db7ed025 --- /dev/null +++ b/src/components/ProfileAddressBook/ProfileAddressBook.js @@ -0,0 +1,75 @@ +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import { inject, observer } from "mobx-react"; +import Grid from "@material-ui/core/Grid"; +import { withStyles } from "@material-ui/core/styles"; +import AddressBook from "@reactioncommerce/components/AddressBook/v1"; +import withAddressBook from "containers/address/withAddressBook"; +import relayConnectionToArray from "lib/utils/relayConnectionToArray"; +import ErrorPage from "../../pages/_error"; + +const styles = (theme) => ({ + accountProfileInfoContainer: { + marginBottom: theme.spacing.unit * 4 + } +}); + +@withStyles(styles) +@withAddressBook +@inject("authStore") +@inject("uiStore") +@observer +class ProfileAddressBook extends Component { + static propTypes = { + authStore: PropTypes.shape({ + account: PropTypes.object.isRequired + }), + classes: PropTypes.object, + onAddressAdded: PropTypes.func.isRequired, + onAddressDeleted: PropTypes.func.isRequired, + onAddressEdited: PropTypes.func.isRequired, + shop: PropTypes.shape({ + name: PropTypes.string.isRequired, + description: PropTypes.string + }) + }; + + renderAddressBook() { + const { + authStore: { account: { addressBook } }, + onAddressAdded, + onAddressEdited, + onAddressDeleted + } = this.props; + // Use relayConnectionToArray to remove edges / nodes levels from addressBook object + const addresses = (addressBook && relayConnectionToArray(addressBook)) || []; + + // Create data object to pass to AddressBook component + const accountAddressBook = { + addressBook: addresses + }; + + return ( + + ); + } + + render() { + const { authStore: { account }, shop } = this.props; + + if (account && !account._id) return ; + + return ( + + {this.renderAddressBook()} + + ); + } +} + +export default ProfileAddressBook; diff --git a/src/components/ProfileAddressBook/index.js b/src/components/ProfileAddressBook/index.js new file mode 100644 index 0000000000..db6aa58a66 --- /dev/null +++ b/src/components/ProfileAddressBook/index.js @@ -0,0 +1 @@ +export { default } from "./ProfileAddressBook"; diff --git a/src/containers/account/viewer.gql b/src/containers/account/viewer.gql index 857ba0cdc1..a3773fce0c 100644 --- a/src/containers/account/viewer.gql +++ b/src/containers/account/viewer.gql @@ -1,6 +1,25 @@ query viewer { viewer { _id + addressBook { + edges { + node { + _id + address1 + address2 + city + company + country + fullName + isBillingDefault + isCommercial + isShippingDefault + phone + postal + region + } + } + } emailRecords { address } diff --git a/src/containers/address/fragments.gql b/src/containers/address/fragments.gql new file mode 100644 index 0000000000..3bb4fcd978 --- /dev/null +++ b/src/containers/address/fragments.gql @@ -0,0 +1,20 @@ +fragment AddressCommon on Address { + address1 + address2 + city + postal + country + region +} + +fragment FullAddressCommon on Address { + ...AddressCommon + _id + fullName + company + isBillingDefault + isCommercial + isShippingDefault + metafields + phone +} diff --git a/src/containers/address/mutations.gql b/src/containers/address/mutations.gql new file mode 100644 index 0000000000..38aaf7f5bd --- /dev/null +++ b/src/containers/address/mutations.gql @@ -0,0 +1,59 @@ +mutation addAccountAddressBookEntry($input: AddAccountAddressBookEntryInput!) { + addAccountAddressBookEntry(input: $input) { + address { + _id + address1 + address2 + city + company + country + fullName + isBillingDefault + isCommercial + isShippingDefault + phone + postal + region + } + } +} + +mutation updateAccountAddressBookEntry($input: UpdateAccountAddressBookEntryInput!) { + updateAccountAddressBookEntry(input: $input) { + address { + _id + address1 + address2 + city + company + country + fullName + isBillingDefault + isCommercial + isShippingDefault + phone + postal + region + } + } +} + +mutation removeAccountAddressBookEntry($input: RemoveAccountAddressBookEntryInput!) { + removeAccountAddressBookEntry(input: $input) { + address { + _id + address1 + address2 + city + company + country + fullName + isBillingDefault + isCommercial + isShippingDefault + phone + postal + region + } + } +} diff --git a/src/containers/address/withAddressBook.js b/src/containers/address/withAddressBook.js new file mode 100644 index 0000000000..7928c33fe9 --- /dev/null +++ b/src/containers/address/withAddressBook.js @@ -0,0 +1,160 @@ +import React, { Component, Fragment } from "react"; +import PropTypes from "prop-types"; +import hoistNonReactStatic from "hoist-non-react-statics"; +import { withApollo } from "react-apollo"; +import { inject, observer } from "mobx-react"; +import relayConnectionToArray from "lib/utils/relayConnectionToArray"; +import viewerQuery from "../account/viewer.gql"; +import { + addAccountAddressBookEntry, + updateAccountAddressBookEntry, + removeAccountAddressBookEntry +} from "./mutations.gql"; + +export default function withAddressBook(Comp) { + @withApollo + @inject("authStore") + @observer + class WithAddressBook extends Component { + static propTypes = { + authStore: PropTypes.shape({ + account: PropTypes.object.isRequired + }), + client: PropTypes.shape({ + query: PropTypes.func.isRequired + }) + }; + + state = {}; + + get accountId() { + const { authStore } = this.props; + return authStore && authStore.account && authStore.account._id; + } + + get accountAddressBook() { + const { authStore: { account: { addressBook } } } = this.props; + return (addressBook && relayConnectionToArray(addressBook)) || []; + } + + /** + * @name handleAddAccountAddressBookEntry + * @summary Adds an address to current user's address book + * @param {Object} address Address to add + * @return {Undefined} undefined + */ + handleAddAccountAddressBookEntry = (address) => { + const { client: apolloClient } = this.props; + + // TEMP delete `addressName` prop until API supports it. + delete address.addressName; + + apolloClient.mutate({ + mutation: addAccountAddressBookEntry, + variables: { + input: { + address, + accountId: this.accountId + } + }, + update: (cache, { data: mutationData }) => { + if (mutationData && mutationData.addAccountAddressBookEntry) { + const { address: newAddressEntry } = mutationData.addAccountAddressBookEntry; + if (newAddressEntry) { + const cacheData = cache.readQuery({ query: viewerQuery }); + if (!cacheData.viewer.addressBook) { + cacheData.viewer.addressBook = { edges: [] }; + } + cacheData.viewer.addressBook.edges.push({ + __typename: "AddressEdge", + node: newAddressEntry + }); + // Update Apollo cache + cache.writeQuery({ + query: viewerQuery, + data: cacheData + }); + } + } + } + }); + }; + + /** + * @name handleEditAccountAddressBookEntry + * @summary Updates an address in current user's address book + * @param {String} addressId _id of address to update + * @param {Object} updates Field updates + * @return {Undefined} undefined + */ + handleEditAccountAddressBookEntry = (addressId, updates) => { + const { client: apolloClient } = this.props; + apolloClient.mutate({ + mutation: updateAccountAddressBookEntry, + variables: { + input: { + addressId, + accountId: this.accountId, + updates + } + } + }); + }; + + /** + * @name handleRemoveAccountAddressBookEntry + * @summary Asks user to confirm, then deletes address from current user's address book + * @param {String} addressId _id of address to delete + * @return {Undefined} undefined + */ + handleRemoveAccountAddressBookEntry = (addressId) => { + const { client: apolloClient } = this.props; + + if (!confirm("Delete this address?")) { + return; + } + + apolloClient.mutate({ + mutation: removeAccountAddressBookEntry, + variables: { + input: { + addressId, + accountId: this.accountId + } + }, + update: (cache, { data: mutationData }) => { + if (mutationData && mutationData.removeAccountAddressBookEntry) { + const { address: removedAddressEntry } = mutationData.removeAccountAddressBookEntry; + if (removedAddressEntry) { + const cacheData = cache.readQuery({ query: viewerQuery }); + const removedIndex = cacheData.viewer.addressBook.edges.findIndex((edge) => edge.node._id === addressId); + cacheData.viewer.addressBook.edges.splice(removedIndex, 1); + // Update Apollo cache + cache.writeQuery({ + query: viewerQuery, + data: cacheData + }); + } + } + } + }); + }; + + render() { + return ( + + + + ); + } + } + + hoistNonReactStatic(WithAddressBook, Comp); + + return WithAddressBook; +} diff --git a/src/custom/componentsContext.js b/src/custom/componentsContext.js index b407a92844..c5d89af94c 100644 --- a/src/custom/componentsContext.js +++ b/src/custom/componentsContext.js @@ -16,11 +16,16 @@ import iconAmericanExpress from "@reactioncommerce/components/svg/iconAmericanEx import iconClear from "@reactioncommerce/components/svg/iconClear"; import iconDiscover from "@reactioncommerce/components/svg/iconDiscover"; import iconError from "@reactioncommerce/components/svg/iconError"; +import iconExpand from "@reactioncommerce/components/svg/iconExpand"; import iconLock from "@reactioncommerce/components/svg/iconLock"; +import iconPlus from "@reactioncommerce/components/svg/iconPlus"; import iconMastercard from "@reactioncommerce/components/svg/iconMastercard"; import iconValid from "@reactioncommerce/components/svg/iconValid"; import iconVisa from "@reactioncommerce/components/svg/iconVisa"; import spinner from "@reactioncommerce/components/svg/spinner"; +import Accordion from "@reactioncommerce/components/Accordion/v1"; +import AccordionFormList from "@reactioncommerce/components/AccordionFormList/v1"; +import AddressBook from "@reactioncommerce/components/AddressBook/v1"; import Address from "@reactioncommerce/components/Address/v1"; import AddressCapture from "@reactioncommerce/components/AddressCapture/v1"; import AddressChoice from "@reactioncommerce/components/AddressChoice/v1"; @@ -40,6 +45,7 @@ import CheckoutActionComplete from "@reactioncommerce/components/CheckoutActionC import CheckoutActionIncomplete from "@reactioncommerce/components/CheckoutActionIncomplete/v1"; import ErrorsBlock from "@reactioncommerce/components/ErrorsBlock/v1"; import Field from "@reactioncommerce/components/Field/v1"; +import InPageMenuItem from "@reactioncommerce/components/InPageMenuItem/v1"; import InlineAlert from "@reactioncommerce/components/InlineAlert/v1"; import InventoryStatus from "@reactioncommerce/components/InventoryStatus/v1"; import Link from "components/Link"; @@ -62,6 +68,9 @@ import withLocales from "../lib/utils/withLocales"; const AddressFormWithLocales = withLocales(AddressForm); export default { + Accordion, + AccordionFormList, + AddressBook, Address, AddressCapture, AddressChoice, @@ -88,10 +97,13 @@ export default { iconClear, iconDiscover, iconError, + iconExpand, iconLock, iconMastercard, + iconPlus, iconValid, iconVisa, + InPageMenuItem, MiniCartSummary, PhoneNumberInput, Price, diff --git a/src/custom/routes.js b/src/custom/routes.js index 7754fd6d8e..5b5114ee94 100644 --- a/src/custom/routes.js +++ b/src/custom/routes.js @@ -14,7 +14,10 @@ function defineRoutes(routes) { .add("shopProduct", "/shop/:shopSlug/product/:slugOrId", "product") .add("product", "/product/:slugOrId/:variantId?", "product") .add("shop", "/shop/:shopId/:tag", "productGrid") - .add("tag", "/tag/:slug", "tag"); + .add("tag", "/tag/:slug", "tag") + .add("profileAddressBook", "/profile/address", "profile") + .add("profileOrders", "/profile/orders", "profile") + .add("profilePaymentMethods", "/profile/payments", "profile"); } module.exports = defineRoutes; diff --git a/src/pages/profile.js b/src/pages/profile.js new file mode 100644 index 0000000000..5b05cf30b3 --- /dev/null +++ b/src/pages/profile.js @@ -0,0 +1,128 @@ +import React, { Component, Fragment } from "react"; +import PropTypes from "prop-types"; +import { inject, observer } from "mobx-react"; +import Helmet from "react-helmet"; +import Grid from "@material-ui/core/Grid"; +import { withStyles } from "@material-ui/core/styles"; +import AccountProfileInfo from "@reactioncommerce/components/AccountProfileInfo/v1"; +import InPageMenu from "@reactioncommerce/components/InPageMenu/v1"; +import ProfileAddressBook from "components/ProfileAddressBook"; +import withAddressBook from "containers/address/withAddressBook"; +import ErrorPage from "./_error"; + +const styles = (theme) => ({ + accountProfileInfoContainer: { + marginBottom: theme.spacing.unit * 4 + } +}); + +@withStyles(styles) +@withAddressBook +@inject("authStore") +@inject("uiStore") +@observer +class Profile extends Component { + static propTypes = { + authStore: PropTypes.shape({ + account: PropTypes.object.isRequired + }), + classes: PropTypes.object, + onAddressAdded: PropTypes.func.isRequired, + onAddressDeleted: PropTypes.func.isRequired, + onAddressEdited: PropTypes.func.isRequired, + shop: PropTypes.shape({ + name: PropTypes.string.isRequired, + description: PropTypes.string + }) + }; + + renderMainContent() { + const { router: { asPath }, shop } = this.props; + + if (asPath === "/profile/address") { + return ; + } + + if (asPath === "/profile/orders") { + return "Orders placeholder"; + } + + if (asPath === "/profile/payments") { + return "Payment Methods placeholder"; + } + + return ; + } + + renderAccountProfileInfo() { + const { authStore: { account }, classes } = this.props; + + return ( +
+ +
+ ); + } + + renderNavigation() { + const { classes, router: { asPath } } = this.props; + + const menuItems = [ + { + href: "/profile/address", + route: "/profile/address", + label: "Address Book", + isSelected: asPath === "/profile/address" + } + // { + // href: "/profile/orders", + // route: "/profile/orders", + // label: "Orders", + // isSelected: asPath === "/profile/orders" + // }, + // { + // href: "/profile/payments", + // route: "/profile/payments", + // label: "Payment Methods", + // isSelected: asPath === "/profile/payments" + // } + ]; + + return ( +
+ +
+ ); + } + + render() { + const { authStore: { account }, shop } = this.props; + + // If there is no logged in user, return Not Found page + if (account && !account._id) return ; + + return ( + + +
+ + {/* MUI grid doesn't have an offset. Use blank grid item instead. */} + + {this.renderAccountProfileInfo()} + {this.renderNavigation()} + + + {this.renderMainContent()} + + {/* MUI grid doesn't have an offset. Use blank grid item instead. */} + +
+
+ ); + } +} + +export default Profile;