diff --git a/packages/peregrine/lib/Apollo/clearCustomerDataFromCache.js b/packages/peregrine/lib/Apollo/clearCustomerDataFromCache.js new file mode 100644 index 0000000000..431561ea59 --- /dev/null +++ b/packages/peregrine/lib/Apollo/clearCustomerDataFromCache.js @@ -0,0 +1,14 @@ +import { deleteCacheEntry } from './deleteCacheEntry'; + +/** + * Deletes all references to Customer from the apollo cache including entries + * that start with "$" which were automatically created by Apollo InMemoryCache. + * By coincidence this rule additionally clears CustomerAddress entries, but + * we'll need to keep this in mind by adding additional patterns as MyAccount + * features are completed. + * + * @param {ApolloClient} client + */ +export const clearCustomerDataFromCache = async client => { + await deleteCacheEntry(client, key => key.match(/^\$?Customer/)); +}; diff --git a/packages/peregrine/lib/talons/AuthModal/useAuthModal.js b/packages/peregrine/lib/talons/AuthModal/useAuthModal.js index 557b5bfbc8..03a8734379 100644 --- a/packages/peregrine/lib/talons/AuthModal/useAuthModal.js +++ b/packages/peregrine/lib/talons/AuthModal/useAuthModal.js @@ -4,6 +4,7 @@ import { useApolloClient, useMutation } from '@apollo/react-hooks'; import { useUserContext } from '../../context/user'; import { clearCartDataFromCache } from '../../Apollo/clearCartDataFromCache'; +import { clearCustomerDataFromCache } from '../../Apollo/clearCustomerDataFromCache'; const UNAUTHED_ONLY = ['CREATE_ACCOUNT', 'FORGOT_PASSWORD', 'SIGN_IN']; @@ -76,6 +77,7 @@ export const useAuthModal = props => { // Delete cart/user data from the redux store. await signOut({ revokeToken }); await clearCartDataFromCache(apolloClient); + await clearCustomerDataFromCache(apolloClient); // Refresh the page as a way to say "re-initialize". An alternative // would be to call apolloClient.resetStore() but that would require diff --git a/packages/peregrine/lib/talons/CheckoutPage/AddressBook/__tests__/__snapshots__/useAddressBook.spec.js.snap b/packages/peregrine/lib/talons/CheckoutPage/AddressBook/__tests__/__snapshots__/useAddressBook.spec.js.snap new file mode 100644 index 0000000000..0bdaa8873f --- /dev/null +++ b/packages/peregrine/lib/talons/CheckoutPage/AddressBook/__tests__/__snapshots__/useAddressBook.spec.js.snap @@ -0,0 +1,49 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`callbacks update and return state handleApplyAddress 1`] = ` +Object { + "variables": Object { + "addressId": 2, + "cartId": "cart123", + }, +} +`; + +exports[`returns the correct shape 1`] = ` +Object { + "activeAddress": undefined, + "customerAddresses": Array [ + Object { + "firstname": "Philip", + "id": 1, + "lastname": "Fry", + "street": Array [ + "3000 57th Street", + ], + }, + Object { + "firstname": "Bender", + "id": 2, + "lastname": "Rodríguez", + "street": Array [ + "3000 57th Street", + ], + }, + Object { + "firstname": "John", + "id": 3, + "lastname": "Zoidberg", + "street": Array [ + "1 Dumpster Alley", + ], + }, + ], + "handleAddAddress": [Function], + "handleApplyAddress": [Function], + "handleCancel": [Function], + "handleEditAddress": [Function], + "handleSelectAddress": [Function], + "isLoading": true, + "selectedAddress": 2, +} +`; diff --git a/packages/peregrine/lib/talons/CheckoutPage/AddressBook/__tests__/__snapshots__/useAddressCard.spec.js.snap b/packages/peregrine/lib/talons/CheckoutPage/AddressBook/__tests__/__snapshots__/useAddressCard.spec.js.snap new file mode 100644 index 0000000000..7184f975eb --- /dev/null +++ b/packages/peregrine/lib/talons/CheckoutPage/AddressBook/__tests__/__snapshots__/useAddressCard.spec.js.snap @@ -0,0 +1,24 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`event handlers fire callbacks handleEditAddress 1`] = ` +Object { + "country": Object { + "code": "US", + }, + "email": "fry@planet.express", + "firstname": "Philip", + "id": 66, + "region": Object { + "id": 22, + }, +} +`; + +exports[`returns correct shape 1`] = ` +Object { + "handleClick": [Function], + "handleEditAddress": [Function], + "handleKeyPress": [Function], + "hasUpdate": false, +} +`; diff --git a/packages/peregrine/lib/talons/CheckoutPage/AddressBook/__tests__/useAddressBook.spec.js b/packages/peregrine/lib/talons/CheckoutPage/AddressBook/__tests__/useAddressBook.spec.js new file mode 100644 index 0000000000..50472cb114 --- /dev/null +++ b/packages/peregrine/lib/talons/CheckoutPage/AddressBook/__tests__/useAddressBook.spec.js @@ -0,0 +1,198 @@ +import React from 'react'; +import { act } from 'react-test-renderer'; + +import { useAddressBook } from '../useAddressBook'; +import createTestInstance from '../../../../util/createTestInstance'; +import { useAppContext } from '../../../../context/app'; + +const mockGetCustomerAddresses = jest.fn().mockReturnValue({ + data: { + customer: { + addresses: [ + { + firstname: 'Philip', + id: 1, + lastname: 'Fry', + street: ['3000 57th Street'] + }, + { + firstname: 'Bender', + id: 2, + lastname: 'Rodríguez', + street: ['3000 57th Street'] + }, + { + firstname: 'John', + id: 3, + lastname: 'Zoidberg', + street: ['1 Dumpster Alley'] + } + ] + } + }, + error: false, + loading: false +}); + +const mockGetCustomerCartAddress = jest.fn().mockReturnValue({ + data: { + customerCart: { + shipping_addresses: [ + { + firstname: 'Bender', + lastname: 'Rodríguez', + street: ['3000 57th Street'] + } + ] + } + }, + error: false, + loading: false +}); + +const mockSetCustomerAddressOnCart = jest.fn(); + +jest.mock('@apollo/react-hooks', () => ({ + useQuery: jest.fn().mockImplementation(query => { + if (query === 'getCustomerAddressesQuery') + return mockGetCustomerAddresses(); + + if (query === 'getCustomerCartAddressQuery') + return mockGetCustomerCartAddress(); + + return; + }), + useMutation: jest.fn(() => [ + mockSetCustomerAddressOnCart, + { loading: true } + ]) +})); + +jest.mock('../../../../context/app', () => { + const state = {}; + const api = { + toggleDrawer: jest.fn() + }; + const useAppContext = jest.fn(() => [state, api]); + + return { useAppContext }; +}); + +jest.mock('../../../../context/cart', () => { + const state = { + cartId: 'cart123' + }; + const api = {}; + const useCartContext = jest.fn(() => [state, api]); + + return { useCartContext }; +}); + +const Component = props => { + const talonProps = useAddressBook(props); + return ; +}; + +const toggleActiveContent = jest.fn(); +const mockProps = { + mutations: {}, + queries: { + getCustomerAddressesQuery: 'getCustomerAddressesQuery', + getCustomerCartAddressQuery: 'getCustomerCartAddressQuery' + }, + toggleActiveContent +}; + +test('returns the correct shape', () => { + const tree = createTestInstance(); + const { root } = tree; + const { talonProps } = root.findByType('i').props; + + expect(talonProps).toMatchSnapshot(); +}); + +test('auto selects new address', () => { + mockGetCustomerAddresses.mockReturnValueOnce({ + data: { + customer: { + addresses: [ + { + firstname: 'Flexo', + id: 44, + lastname: 'Rodríguez', + street: ['3000 57th Street'] + } + ] + } + }, + error: false, + loading: false + }); + + const tree = createTestInstance(); + + act(() => { + tree.update(); + }); + + const { root } = tree; + const { talonProps } = root.findByType('i').props; + expect(talonProps.selectedAddress).toBe(3); +}); + +describe('callbacks update and return state', () => { + const tree = createTestInstance(); + const { root } = tree; + const { talonProps } = root.findByType('i').props; + + test('handleEditAddress', () => { + const [, { toggleDrawer }] = useAppContext(); + const { handleEditAddress } = talonProps; + + act(() => { + handleEditAddress('activeAddress'); + }); + + const { talonProps: newTalonProps } = root.findByType('i').props; + + expect(toggleDrawer).toHaveBeenCalled(); + expect(newTalonProps.activeAddress).toBe('activeAddress'); + }); + + test('handleAddAddress', () => { + const [, { toggleDrawer }] = useAppContext(); + const { handleAddAddress } = talonProps; + + act(() => { + handleAddAddress(); + }); + + const { talonProps: newTalonProps } = root.findByType('i').props; + + expect(toggleDrawer).toHaveBeenCalled(); + expect(newTalonProps.activeAddress).toBeUndefined(); + }); + + test('handleSelectAddress', () => { + const { handleSelectAddress } = talonProps; + + act(() => { + handleSelectAddress(318); + }); + + const { talonProps: newTalonProps } = root.findByType('i').props; + expect(newTalonProps.selectedAddress).toBe(318); + }); + + test('handleApplyAddress', async () => { + const { handleApplyAddress } = talonProps; + + await act(() => { + handleApplyAddress(); + }); + + expect(mockSetCustomerAddressOnCart).toHaveBeenCalled(); + expect(mockSetCustomerAddressOnCart.mock.calls[0][0]).toMatchSnapshot(); + expect(toggleActiveContent).toHaveBeenCalled(); + }); +}); diff --git a/packages/peregrine/lib/talons/CheckoutPage/AddressBook/__tests__/useAddressCard.spec.js b/packages/peregrine/lib/talons/CheckoutPage/AddressBook/__tests__/useAddressCard.spec.js new file mode 100644 index 0000000000..476f3c6006 --- /dev/null +++ b/packages/peregrine/lib/talons/CheckoutPage/AddressBook/__tests__/useAddressCard.spec.js @@ -0,0 +1,86 @@ +import React from 'react'; +import { act } from 'react-test-renderer'; + +import createTestInstance from '../../../../util/createTestInstance'; +import { useAddressCard } from '../useAddressCard'; + +const address = { + country_code: 'US', + email: 'fry@planet.express', + firstname: 'Philip', + id: 66, + region: { + region_id: 22 + } +}; +const onEdit = jest.fn(); +const onSelection = jest.fn(); + +const mockProps = { + address, + onEdit, + onSelection +}; + +const Component = props => { + const talonProps = useAddressCard(props); + return ; +}; + +test('returns correct shape', () => { + const tree = createTestInstance(); + const { root } = tree; + const { talonProps } = root.findByType('i').props; + + expect(talonProps).toMatchSnapshot(); +}); + +test('returns correct value for update animation', () => { + const tree = createTestInstance(); + const { root } = tree; + const { talonProps } = root.findByType('i').props; + + expect(talonProps.hasUpdate).toBe(false); + + act(() => { + tree.update( + + ); + }); + + const { talonProps: newTalonProps } = root.findByType('i').props; + + expect(newTalonProps.hasUpdate).toBe(true); +}); + +describe('event handlers fire callbacks', () => { + const tree = createTestInstance(); + const { root } = tree; + const { talonProps } = root.findByType('i').props; + + test('handleClick', () => { + const { handleClick } = talonProps; + handleClick(); + expect(onSelection).toHaveBeenCalledWith(66); + }); + + test('handleKeyPress', () => { + const { handleKeyPress } = talonProps; + + handleKeyPress({ key: 'Tab' }); + expect(onSelection).not.toBeCalled(); + + handleKeyPress({ key: 'Enter' }); + expect(onSelection).toHaveBeenCalledWith(66); + }); + + test('handleEditAddress', () => { + const { handleEditAddress } = talonProps; + handleEditAddress(); + expect(onEdit).toHaveBeenCalled(); + expect(onEdit.mock.calls[0][0]).toMatchSnapshot(); + }); +}); diff --git a/packages/peregrine/lib/talons/CheckoutPage/AddressBook/useAddressBook.js b/packages/peregrine/lib/talons/CheckoutPage/AddressBook/useAddressBook.js new file mode 100644 index 0000000000..a317fa1630 --- /dev/null +++ b/packages/peregrine/lib/talons/CheckoutPage/AddressBook/useAddressBook.js @@ -0,0 +1,149 @@ +import { useCallback, useEffect, useState, useRef } from 'react'; +import { useMutation, useQuery } from '@apollo/react-hooks'; + +import { useAppContext } from '../../../context/app'; +import { useCartContext } from '../../../context/cart'; + +export const useAddressBook = props => { + const { + mutations: { setCustomerAddressOnCartMutation }, + queries: { getCustomerAddressesQuery, getCustomerCartAddressQuery }, + toggleActiveContent + } = props; + + const [, { toggleDrawer }] = useAppContext(); + const [{ cartId }] = useCartContext(); + + const addressCount = useRef(); + const [activeAddress, setActiveAddress] = useState(); + const [selectedAddress, setSelectedAddress] = useState(); + + const [ + setCustomerAddressOnCart, + { loading: setCustomerAddressOnCartLoading } + ] = useMutation(setCustomerAddressOnCartMutation); + + const { + data: customerAddressesData, + error: customerAddressesError, + loading: customerAddressesLoading + } = useQuery(getCustomerAddressesQuery); + + const { + data: customerCartAddressData, + error: customerCartAddressError, + loading: customerCartAddressLoading + } = useQuery(getCustomerCartAddressQuery); + + useEffect(() => { + if (customerAddressesError) { + console.error(customerAddressesError); + } + + if (customerCartAddressError) { + console.error(customerCartAddressError); + } + }, [customerAddressesError, customerCartAddressError]); + + const isLoading = + customerAddressesLoading || + customerCartAddressLoading || + setCustomerAddressOnCartLoading; + + const customerAddresses = + (customerAddressesData && customerAddressesData.customer.addresses) || + []; + + useEffect(() => { + if (customerAddresses.length !== addressCount.current) { + // Auto-select newly added address when count changes + if (addressCount.current) { + const newestAddress = + customerAddresses[customerAddresses.length - 1]; + setSelectedAddress(newestAddress.id); + } + + addressCount.current = customerAddresses.length; + } + }, [customerAddresses]); + + const handleEditAddress = useCallback( + address => { + setActiveAddress(address); + toggleDrawer('shippingInformation.edit'); + }, + [toggleDrawer] + ); + + const handleAddAddress = useCallback(() => { + handleEditAddress(); + }, [handleEditAddress]); + + const handleSelectAddress = useCallback(addressId => { + setSelectedAddress(addressId); + }, []); + + // GraphQL doesn't return which customer address is selected, so perform + // a simple search to initialize this selected address value. + if ( + customerAddresses.length && + customerCartAddressData && + !selectedAddress + ) { + const { customerCart } = customerCartAddressData; + const { shipping_addresses: shippingAddresses } = customerCart; + if (shippingAddresses.length) { + const primaryCartAddress = shippingAddresses[0]; + + const foundSelectedAddress = customerAddresses.find( + customerAddress => + customerAddress.street[0] === + primaryCartAddress.street[0] && + customerAddress.firstname === + primaryCartAddress.firstname && + customerAddress.lastname === primaryCartAddress.lastname + ); + + if (foundSelectedAddress) { + setSelectedAddress(foundSelectedAddress.id); + } + } + } + + const handleApplyAddress = useCallback(async () => { + try { + await setCustomerAddressOnCart({ + variables: { + cartId, + addressId: selectedAddress + } + }); + } catch (error) { + console.error(error); + } + + toggleActiveContent(); + }, [ + cartId, + selectedAddress, + setCustomerAddressOnCart, + toggleActiveContent + ]); + + const handleCancel = useCallback(() => { + setSelectedAddress(); + toggleActiveContent(); + }, [toggleActiveContent]); + + return { + activeAddress, + customerAddresses, + isLoading, + handleAddAddress, + handleApplyAddress, + handleCancel, + handleSelectAddress, + handleEditAddress, + selectedAddress + }; +}; diff --git a/packages/peregrine/lib/talons/CheckoutPage/AddressBook/useAddressCard.js b/packages/peregrine/lib/talons/CheckoutPage/AddressBook/useAddressCard.js new file mode 100644 index 0000000000..74dec54dd5 --- /dev/null +++ b/packages/peregrine/lib/talons/CheckoutPage/AddressBook/useAddressCard.js @@ -0,0 +1,68 @@ +import { useCallback, useEffect, useMemo, useState, useRef } from 'react'; + +export const useAddressCard = props => { + const { address, onEdit, onSelection } = props; + const { id: addressId } = address; + + const [hasUpdate, setHasUpdate] = useState(false); + const hasRendered = useRef(false); + + useEffect(() => { + let updateTimer; + if (address !== undefined) { + if (hasRendered.current) { + setHasUpdate(true); + updateTimer = setTimeout(() => { + setHasUpdate(false); + }, 2000); + } else { + hasRendered.current = true; + } + } + + return () => { + if (updateTimer) { + clearTimeout(updateTimer); + } + }; + }, [hasRendered, address]); + + const addressForEdit = useMemo(() => { + const { country_code: countryCode, region, ...addressRest } = address; + const { region_id: regionId } = region; + + return { + ...addressRest, + country: { + code: countryCode + }, + region: { + id: regionId + } + }; + }, [address]); + + const handleClick = useCallback(() => { + onSelection(addressId); + }, [addressId, onSelection]); + + const handleKeyPress = useCallback( + event => { + if (event.key === 'Enter') { + onSelection(addressId); + } + }, + [addressId, onSelection] + ); + + const handleEditAddress = useCallback(() => { + onEdit(addressForEdit); + }, [addressForEdit, onEdit]); + + return { + handleClick, + handleEditAddress, + handleKeyPress, + hasUpdate + }; +}; diff --git a/packages/peregrine/lib/talons/CheckoutPage/OrderConfirmationPage/useCreateAccount.js b/packages/peregrine/lib/talons/CheckoutPage/OrderConfirmationPage/useCreateAccount.js index 164dd61e70..c7b806452b 100644 --- a/packages/peregrine/lib/talons/CheckoutPage/OrderConfirmationPage/useCreateAccount.js +++ b/packages/peregrine/lib/talons/CheckoutPage/OrderConfirmationPage/useCreateAccount.js @@ -4,6 +4,7 @@ import { useUserContext } from '@magento/peregrine/lib/context/user'; import { useCartContext } from '@magento/peregrine/lib/context/cart'; import { useAwaitQuery } from '@magento/peregrine/lib/hooks/useAwaitQuery'; import { clearCartDataFromCache } from '../../../Apollo/clearCartDataFromCache'; +import { clearCustomerDataFromCache } from '../../../Apollo/clearCustomerDataFromCache'; /** * Returns props necessary to render CreateAccount component. In particular this @@ -98,6 +99,7 @@ export const useCreateAccount = props => { await removeCart(); await clearCartDataFromCache(apolloClient); + await clearCustomerDataFromCache(apolloClient); await createCart({ fetchCartId diff --git a/packages/peregrine/lib/talons/CheckoutPage/ShippingInformation/AddressForm/__tests__/__snapshots__/useCustomerForm.spec.js.snap b/packages/peregrine/lib/talons/CheckoutPage/ShippingInformation/AddressForm/__tests__/__snapshots__/useCustomerForm.spec.js.snap new file mode 100644 index 0000000000..d0bfe0a437 --- /dev/null +++ b/packages/peregrine/lib/talons/CheckoutPage/ShippingInformation/AddressForm/__tests__/__snapshots__/useCustomerForm.spec.js.snap @@ -0,0 +1,93 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`return correct shape for initial address entry 1`] = ` +Object { + "handleCancel": [Function], + "handleSubmit": [Function], + "hasDefaultShipping": false, + "initialValues": Object { + "country": "US", + "email": "fry@planet.express", + "firstname": "Philip", + "lastname": "Fry", + "region": null, + }, + "isLoading": false, + "isSaving": false, + "isUpdate": false, +} +`; + +exports[`return correct shape for new address and fire create mutation 1`] = ` +Object { + "handleCancel": [Function], + "handleSubmit": [Function], + "hasDefaultShipping": true, + "initialValues": Object { + "country": "US", + "region": null, + }, + "isLoading": true, + "isSaving": false, + "isUpdate": false, +} +`; + +exports[`return correct shape for new address and fire create mutation 2`] = ` +Object { + "refetchQueries": Array [ + Object { + "query": "getCustomerAddressesQuery", + }, + Object { + "query": "getCustomerAddressesQuery", + }, + ], + "variables": Object { + "address": Object { + "country_code": "US", + "firstname": "Philip", + "region": Object { + "region_id": 2, + }, + }, + }, +} +`; + +exports[`return correct shape for update address and fire update mutation 1`] = ` +Object { + "handleCancel": [Function], + "handleSubmit": [Function], + "hasDefaultShipping": true, + "initialValues": Object { + "city": "New York", + "country": "US", + "id": 66, + "region": null, + }, + "isLoading": false, + "isSaving": false, + "isUpdate": true, +} +`; + +exports[`return correct shape for update address and fire update mutation 2`] = ` +Object { + "refetchQueries": Array [ + Object { + "query": "getCustomerAddressesQuery", + }, + ], + "variables": Object { + "address": Object { + "country_code": "UK", + "firstname": "Bender", + "region": Object { + "region_id": 5, + }, + }, + "addressId": 66, + }, +} +`; diff --git a/packages/peregrine/lib/talons/CheckoutPage/ShippingInformation/EditForm/__tests__/__snapshots__/useEditForm.spec.js.snap b/packages/peregrine/lib/talons/CheckoutPage/ShippingInformation/AddressForm/__tests__/__snapshots__/useGuestForm.spec.js.snap similarity index 100% rename from packages/peregrine/lib/talons/CheckoutPage/ShippingInformation/EditForm/__tests__/__snapshots__/useEditForm.spec.js.snap rename to packages/peregrine/lib/talons/CheckoutPage/ShippingInformation/AddressForm/__tests__/__snapshots__/useGuestForm.spec.js.snap diff --git a/packages/peregrine/lib/talons/CheckoutPage/ShippingInformation/AddressForm/__tests__/useCustomerForm.spec.js b/packages/peregrine/lib/talons/CheckoutPage/ShippingInformation/AddressForm/__tests__/useCustomerForm.spec.js new file mode 100644 index 0000000000..ddd81b29fa --- /dev/null +++ b/packages/peregrine/lib/talons/CheckoutPage/ShippingInformation/AddressForm/__tests__/useCustomerForm.spec.js @@ -0,0 +1,179 @@ +import React from 'react'; +import { useMutation, useQuery } from '@apollo/react-hooks'; + +import createTestInstance from '../../../../../util/createTestInstance'; +import { useCustomerForm } from '../useCustomerForm'; + +const mockCreateCustomerAddress = jest.fn(); +const mockUpdateCustomerAddress = jest.fn(); + +jest.mock('@apollo/react-hooks', () => ({ + useMutation: jest.fn().mockImplementation(mutation => { + if (mutation === 'createCustomerAddressMutation') + return [mockCreateCustomerAddress, { loading: false }]; + + if (mutation === 'updateCustomerAddressMutation') + return [mockUpdateCustomerAddress, { loading: false }]; + + return; + }), + useQuery: jest.fn().mockReturnValue({ + data: { + customer: { + default_shipping: null, + email: 'fry@planet.express', + firstname: 'Philip', + lastname: 'Fry' + } + }, + error: null, + loading: false + }) +})); + +const Component = props => { + const talonProps = useCustomerForm(props); + return ; +}; + +const afterSubmit = jest.fn(); +const onCancel = jest.fn(); +const shippingData = { + country: { + code: 'US' + }, + region: { + id: null + } +}; + +const mockProps = { + afterSubmit, + mutations: { + createCustomerAddressMutation: 'createCustomerAddressMutation', + updateCustomerAddressMutation: 'updateCustomerAddressMutation' + }, + onCancel, + queries: { + getCustomerQuery: 'getCustomerQuery', + getCustomerAddressesQuery: 'getCustomerAddressesQuery', + getDefaultShippingQuery: 'getCustomerAddressesQuery' + }, + shippingData +}; + +test('return correct shape for initial address entry', () => { + const tree = createTestInstance(); + const { root } = tree; + const { talonProps } = root.findByType('i').props; + + expect(talonProps).toMatchSnapshot(); +}); + +test('return correct shape for new address and fire create mutation', async () => { + useQuery.mockReturnValueOnce({ + data: { + customer: { + default_shipping: 5, + email: 'fry@planet.express', + firstname: 'Philip', + lastname: 'Fry' + } + }, + error: null, + loading: true + }); + + const tree = createTestInstance(); + const { root } = tree; + const { talonProps } = root.findByType('i').props; + + expect(talonProps).toMatchSnapshot(); + + const { handleSubmit } = talonProps; + + await handleSubmit({ + country: 'US', + email: 'fry@planet.express', + firstname: 'Philip', + region: 2 + }); + + expect(mockCreateCustomerAddress).toHaveBeenCalled(); + expect(mockCreateCustomerAddress.mock.calls[0][0]).toMatchSnapshot(); +}); + +test('return correct shape for update address and fire update mutation', async () => { + useQuery.mockReturnValueOnce({ + data: { + customer: { + default_shipping: 5, + email: 'fry@planet.express', + firstname: 'Philip', + lastname: 'Fry' + } + }, + error: null, + loading: false + }); + + const tree = createTestInstance( + + ); + const { root } = tree; + const { talonProps } = root.findByType('i').props; + + expect(talonProps).toMatchSnapshot(); + + const { handleSubmit } = talonProps; + + await handleSubmit({ + country: 'UK', + email: 'bender@planet.express', + firstname: 'Bender', + region: 5 + }); + + expect(mockUpdateCustomerAddress).toHaveBeenCalled(); + expect(mockUpdateCustomerAddress.mock.calls[0][0]).toMatchSnapshot(); + expect(afterSubmit).toHaveBeenCalled(); +}); + +test('update isSaving while mutations are in flight', () => { + useMutation.mockReturnValueOnce([ + jest.fn(), + { + loading: true + } + ]); + + const tree = createTestInstance( + + ); + const { root } = tree; + const { talonProps } = root.findByType('i').props; + + expect(talonProps.isSaving).toBe(true); +}); + +test('handleCancel fires provided callback', () => { + const tree = createTestInstance( + + ); + const { root } = tree; + const { talonProps } = root.findByType('i').props; + const { handleCancel } = talonProps; + + handleCancel(); + + expect(onCancel).toHaveBeenCalled(); +}); diff --git a/packages/peregrine/lib/talons/CheckoutPage/ShippingInformation/EditForm/__tests__/useEditForm.spec.js b/packages/peregrine/lib/talons/CheckoutPage/ShippingInformation/AddressForm/__tests__/useGuestForm.spec.js similarity index 96% rename from packages/peregrine/lib/talons/CheckoutPage/ShippingInformation/EditForm/__tests__/useEditForm.spec.js rename to packages/peregrine/lib/talons/CheckoutPage/ShippingInformation/AddressForm/__tests__/useGuestForm.spec.js index b9361a0c51..868b32bd08 100644 --- a/packages/peregrine/lib/talons/CheckoutPage/ShippingInformation/EditForm/__tests__/useEditForm.spec.js +++ b/packages/peregrine/lib/talons/CheckoutPage/ShippingInformation/AddressForm/__tests__/useGuestForm.spec.js @@ -3,7 +3,7 @@ import { act } from 'react-test-renderer'; import { useMutation } from '@apollo/react-hooks'; import createTestInstance from '../../../../../util/createTestInstance'; -import { useEditForm } from '../useEditForm'; +import { useGuestForm } from '../useGuestForm'; jest.mock('@apollo/react-hooks', () => ({ useMutation: jest @@ -22,7 +22,7 @@ jest.mock('../../../../../context/cart', () => { }); const Component = props => { - const talonProps = useEditForm(props); + const talonProps = useGuestForm(props); return ; }; diff --git a/packages/peregrine/lib/talons/CheckoutPage/ShippingInformation/AddressForm/useCustomerForm.js b/packages/peregrine/lib/talons/CheckoutPage/ShippingInformation/AddressForm/useCustomerForm.js new file mode 100644 index 0000000000..2a046407b1 --- /dev/null +++ b/packages/peregrine/lib/talons/CheckoutPage/ShippingInformation/AddressForm/useCustomerForm.js @@ -0,0 +1,137 @@ +import { useCallback, useEffect } from 'react'; +import { useMutation, useQuery } from '@apollo/react-hooks'; + +export const useCustomerForm = props => { + const { + afterSubmit, + mutations: { + createCustomerAddressMutation, + updateCustomerAddressMutation + }, + onCancel, + queries: { + getCustomerQuery, + getCustomerAddressesQuery, + getDefaultShippingQuery + }, + shippingData + } = props; + + const [ + createCustomerAddress, + { loading: createCustomerAddressLoading } + ] = useMutation(createCustomerAddressMutation); + + const [ + updateCustomerAddress, + { loading: updateCustomerAddressLoading } + ] = useMutation(updateCustomerAddressMutation); + + const { + error: getCustomerError, + data: customerData, + loading: getCustomerLoading + } = useQuery(getCustomerQuery); + + useEffect(() => { + if (getCustomerError) { + console.error(getCustomerError); + } + }, [getCustomerError]); + + const isSaving = + createCustomerAddressLoading || updateCustomerAddressLoading; + + // Simple heuristic to indicate form was submitted prior to this render + const isUpdate = !!shippingData.city; + + const { country, region } = shippingData; + const { code: countryCode } = country; + const { id: regionId } = region; + + let initialValues = { + ...shippingData, + country: countryCode, + region: regionId && regionId.toString() + }; + + const hasDefaultShipping = + !!customerData && !!customerData.customer.default_shipping; + + // For first time creation pre-fill the form with Customer data + if (!isUpdate && !getCustomerLoading && !hasDefaultShipping) { + const { customer } = customerData; + const { email, firstname, lastname } = customer; + const defaultUserData = { email, firstname, lastname }; + initialValues = { + ...initialValues, + ...defaultUserData + }; + } + + const handleSubmit = useCallback( + async formValues => { + // eslint-disable-next-line no-unused-vars + const { country, email, region, ...address } = formValues; + try { + const customerAddress = { + ...address, + country_code: country, + region: { + region_id: region + } + }; + + if (isUpdate) { + const { id: addressId } = shippingData; + await updateCustomerAddress({ + variables: { + addressId, + address: customerAddress + }, + refetchQueries: [{ query: getCustomerAddressesQuery }] + }); + } else { + await createCustomerAddress({ + variables: { + address: customerAddress + }, + refetchQueries: [ + { query: getCustomerAddressesQuery }, + { query: getDefaultShippingQuery } + ] + }); + } + } catch (error) { + console.error(error); + } + + if (afterSubmit) { + afterSubmit(); + } + }, + [ + afterSubmit, + createCustomerAddress, + getCustomerAddressesQuery, + getDefaultShippingQuery, + isUpdate, + shippingData, + updateCustomerAddress + ] + ); + + const handleCancel = useCallback(() => { + onCancel(); + }, [onCancel]); + + return { + handleCancel, + handleSubmit, + hasDefaultShipping, + initialValues, + isLoading: getCustomerLoading, + isSaving, + isUpdate + }; +}; diff --git a/packages/peregrine/lib/talons/CheckoutPage/ShippingInformation/EditForm/useEditForm.js b/packages/peregrine/lib/talons/CheckoutPage/ShippingInformation/AddressForm/useGuestForm.js similarity index 81% rename from packages/peregrine/lib/talons/CheckoutPage/ShippingInformation/EditForm/useEditForm.js rename to packages/peregrine/lib/talons/CheckoutPage/ShippingInformation/AddressForm/useGuestForm.js index c2e31dc9c8..eb2bec3382 100644 --- a/packages/peregrine/lib/talons/CheckoutPage/ShippingInformation/EditForm/useEditForm.js +++ b/packages/peregrine/lib/talons/CheckoutPage/ShippingInformation/AddressForm/useGuestForm.js @@ -3,17 +3,18 @@ import { useMutation } from '@apollo/react-hooks'; import { useCartContext } from '../../../../context/cart'; -export const useEditForm = props => { +export const useGuestForm = props => { const { afterSubmit, - mutations: { setShippingInformationMutation }, + mutations: { setGuestShippingMutation }, onCancel, shippingData } = props; const [{ cartId }] = useCartContext(); - const [setShippingInformation, { called, loading }] = useMutation( - setShippingInformationMutation + + const [setGuestShipping, { loading }] = useMutation( + setGuestShippingMutation ); const { country, region } = shippingData; @@ -33,7 +34,7 @@ export const useEditForm = props => { async formValues => { const { country, email, ...address } = formValues; try { - await setShippingInformation({ + await setGuestShipping({ variables: { cartId, email, @@ -51,7 +52,7 @@ export const useEditForm = props => { afterSubmit(); } }, - [afterSubmit, cartId, setShippingInformation] + [afterSubmit, cartId, setGuestShipping] ); const handleCancel = useCallback(() => { @@ -62,7 +63,7 @@ export const useEditForm = props => { handleCancel, handleSubmit, initialValues, - isSaving: called && loading, + isSaving: loading, isUpdate }; }; diff --git a/packages/peregrine/lib/talons/CheckoutPage/ShippingInformation/__tests__/__snapshots__/useShippingInformation.spec.js.snap b/packages/peregrine/lib/talons/CheckoutPage/ShippingInformation/__tests__/__snapshots__/useShippingInformation.spec.js.snap index 19a8601266..cd15479a5a 100644 --- a/packages/peregrine/lib/talons/CheckoutPage/ShippingInformation/__tests__/__snapshots__/useShippingInformation.spec.js.snap +++ b/packages/peregrine/lib/talons/CheckoutPage/ShippingInformation/__tests__/__snapshots__/useShippingInformation.spec.js.snap @@ -4,7 +4,9 @@ exports[`return correct shape with mock data from estimate 1`] = ` Object { "doneEditing": false, "handleEditShipping": [Function], - "loading": false, + "hasUpdate": false, + "isLoading": false, + "isSignedIn": false, "shippingData": Object { "city": "", "country": "USA", @@ -25,7 +27,9 @@ exports[`return correct shape with no data filled in 1`] = ` Object { "doneEditing": false, "handleEditShipping": [Function], - "loading": false, + "hasUpdate": false, + "isLoading": false, + "isSignedIn": false, "shippingData": undefined, } `; @@ -34,7 +38,9 @@ exports[`return correct shape with real data 1`] = ` Object { "doneEditing": true, "handleEditShipping": [Function], - "loading": false, + "hasUpdate": false, + "isLoading": false, + "isSignedIn": false, "shippingData": Object { "city": "Manhattan", "country": "USA", @@ -56,7 +62,9 @@ exports[`return correct shape without cart id 1`] = ` Object { "doneEditing": false, "handleEditShipping": [Function], - "loading": true, + "hasUpdate": false, + "isLoading": false, + "isSignedIn": false, "shippingData": undefined, } `; diff --git a/packages/peregrine/lib/talons/CheckoutPage/ShippingInformation/__tests__/useShippingInformation.spec.js b/packages/peregrine/lib/talons/CheckoutPage/ShippingInformation/__tests__/useShippingInformation.spec.js index a46efc6981..449131d8a4 100644 --- a/packages/peregrine/lib/talons/CheckoutPage/ShippingInformation/__tests__/useShippingInformation.spec.js +++ b/packages/peregrine/lib/talons/CheckoutPage/ShippingInformation/__tests__/useShippingInformation.spec.js @@ -1,22 +1,35 @@ import React from 'react'; -import { useLazyQuery } from '@apollo/react-hooks'; +import { act } from 'react-test-renderer'; +import { useMutation } from '@apollo/react-hooks'; import { useAppContext } from '../../../../context/app'; import { useCartContext } from '../../../../context/cart'; import createTestInstance from '../../../../util/createTestInstance'; import { useShippingInformation } from '../useShippingInformation'; +import { useUserContext } from '../../../../context/user'; + +const mockGetShippingInformationResult = jest.fn().mockReturnValue({ + data: null, + loading: false +}); + +const mockGetDefaultShippingResult = jest.fn().mockReturnValue({ + data: null, + loading: false +}); jest.mock('@apollo/react-hooks', () => ({ - useLazyQuery: jest.fn().mockReturnValue([ - jest.fn(), - { - called: false, - data: null, - error: null, - loading: false - } - ]) + useQuery: jest.fn().mockImplementation(query => { + if (query === 'getShippingInformationQuery') + return mockGetShippingInformationResult(); + + if (query === 'getDefaultShippingQuery') + return mockGetDefaultShippingResult(); + + return; + }), + useMutation: jest.fn().mockReturnValue([jest.fn(), { loading: false }]) })); jest.mock('../../../../context/app', () => { @@ -39,15 +52,32 @@ jest.mock('../../../../context/cart', () => { return { useCartContext }; }); +jest.mock('../../../../context/user', () => { + const state = { + isSignedIn: false + }; + const api = {}; + const useUserContext = jest.fn(() => [state, api]); + + return { useUserContext }; +}); + const Component = props => { const talonProps = useShippingInformation(props); return ; }; +const mockProps = { + mutations: {}, + onSave: jest.fn(), + queries: { + getDefaultShippingQuery: 'getDefaultShippingQuery', + getShippingInformationQuery: 'getShippingInformationQuery' + } +}; + test('return correct shape without cart id', () => { - const tree = createTestInstance( - - ); + const tree = createTestInstance(); const { root } = tree; const { talonProps } = root.findByType('i').props; @@ -55,24 +85,17 @@ test('return correct shape without cart id', () => { }); test('return correct shape with no data filled in', () => { - useLazyQuery.mockReturnValueOnce([ - jest.fn(), - { - called: true, - data: { - cart: { - email: null, - shipping_addresses: [] - } - }, - error: null, - loading: false - } - ]); + mockGetShippingInformationResult.mockReturnValueOnce({ + data: { + cart: { + email: null, + shipping_addresses: [] + } + }, + loading: false + }); - const tree = createTestInstance( - - ); + const tree = createTestInstance(); const { root } = tree; const { talonProps } = root.findByType('i').props; @@ -80,35 +103,28 @@ test('return correct shape with no data filled in', () => { }); test('return correct shape with mock data from estimate', () => { - useLazyQuery.mockReturnValueOnce([ - jest.fn(), - { - called: true, - data: { - cart: { - email: null, - shipping_addresses: [ - { - city: 'city', - country: 'USA', - firstname: 'firstname', - lastname: 'lastname', - postcode: '10019', - region: 'New York', - street: ['street'], - telephone: 'telephone' - } - ] - } - }, - error: null, - loading: false - } - ]); + mockGetShippingInformationResult.mockReturnValueOnce({ + data: { + cart: { + email: null, + shipping_addresses: [ + { + city: 'city', + country: 'USA', + firstname: 'firstname', + lastname: 'lastname', + postcode: '10019', + region: 'New York', + street: ['street'], + telephone: 'telephone' + } + ] + } + }, + loading: false + }); - const tree = createTestInstance( - - ); + const tree = createTestInstance(); const { root } = tree; const { talonProps } = root.findByType('i').props; @@ -116,48 +132,56 @@ test('return correct shape with mock data from estimate', () => { }); test('return correct shape with real data', () => { - useLazyQuery.mockReturnValueOnce([ - jest.fn(), - { - called: true, - data: { - cart: { - email: null, - shipping_addresses: [ - { - city: 'Manhattan', - country: 'USA', - email: 'fry@planet.express', - firstname: 'Philip', - lastname: 'Fry', - postcode: '10019', - region: 'New York', - street: ['3000 57th Street', 'Suite 200'], - telephone: '(123) 456-7890' - } - ] - } - }, - error: null, - loading: false - } - ]); + mockGetShippingInformationResult.mockReturnValueOnce({ + data: { + cart: { + email: null, + shipping_addresses: [ + { + city: 'Manhattan', + country: 'USA', + email: 'fry@planet.express', + firstname: 'Philip', + lastname: 'Fry', + postcode: '10019', + region: 'New York', + street: ['3000 57th Street', 'Suite 200'], + telephone: '(123) 456-7890' + } + ] + } + }, + loading: false + }); - const tree = createTestInstance( - - ); + const tree = createTestInstance(); const { root } = tree; const { talonProps } = root.findByType('i').props; expect(talonProps).toMatchSnapshot(); }); -test('edit handler calls toggle drawer', () => { +test('edit handler calls toggle drawer for guest', () => { const [, { toggleDrawer }] = useAppContext(); useCartContext.mockReturnValueOnce([{}]); + const tree = createTestInstance(); + const { root } = tree; + const { talonProps } = root.findByType('i').props; + const { handleEditShipping } = talonProps; + + handleEditShipping(); + + expect(toggleDrawer).toHaveBeenCalled(); +}); + +test('edit handler calls toggle active content for customer', () => { + useCartContext.mockReturnValueOnce([{}]); + useUserContext.mockReturnValueOnce([{ isSignedIn: true }]); + + const toggleActiveContent = jest.fn(); const tree = createTestInstance( - + ); const { root } = tree; const { talonProps } = root.findByType('i').props; @@ -165,5 +189,96 @@ test('edit handler calls toggle drawer', () => { handleEditShipping(); - expect(toggleDrawer).toHaveBeenCalled(); + expect(toggleActiveContent).toHaveBeenCalled(); +}); + +test('customer default address is auto selected', () => { + mockGetShippingInformationResult.mockReturnValueOnce({ + data: { + cart: { + email: null, + shipping_addresses: [] + } + }, + loading: false + }); + + mockGetDefaultShippingResult.mockReturnValueOnce({ + data: { + customer: { + default_shipping: '1' + } + }, + loading: false + }); + + const setDefaultAddressOnCart = jest.fn(); + useMutation.mockReturnValueOnce([ + setDefaultAddressOnCart, + { loading: false } + ]); + + createTestInstance(); + + expect(setDefaultAddressOnCart).toHaveBeenCalled(); +}); + +test('receives update on data change', () => { + mockGetShippingInformationResult.mockReturnValueOnce({ + data: { + cart: { + email: null, + shipping_addresses: [ + { + city: 'Manhattan', + country: 'USA', + email: 'fry@planet.express', + firstname: 'Philip', + lastname: 'Fry', + postcode: '10019', + region: 'New York', + street: ['3000 57th Street', 'Suite 200'], + telephone: '(123) 456-7890' + } + ] + } + }, + loading: false + }); + + mockGetShippingInformationResult.mockReturnValueOnce({ + data: { + cart: { + email: null, + shipping_addresses: [ + { + city: 'Manhattan', + country: 'USA', + email: 'bender@planet.express', + firstname: 'Bender', + lastname: 'Rodríguez', + postcode: '10019', + region: 'New York', + street: ['00100 100 001 00100'], + telephone: '(555) 456-7890' + } + ] + } + }, + loading: false + }); + + const tree = createTestInstance(); + const { root } = tree; + const { talonProps } = root.findByType('i').props; + const { hasUpdate } = talonProps; + + expect(hasUpdate).toBe(false); + + act(() => { + tree.update(); + }); + + const { talonProps: newTalonProps } = root.findByType('i').props; + expect(newTalonProps.hasUpdate).toBe(true); }); diff --git a/packages/peregrine/lib/talons/CheckoutPage/ShippingInformation/useShippingInformation.js b/packages/peregrine/lib/talons/CheckoutPage/ShippingInformation/useShippingInformation.js index acb05ee064..f71ed53452 100644 --- a/packages/peregrine/lib/talons/CheckoutPage/ShippingInformation/useShippingInformation.js +++ b/packages/peregrine/lib/talons/CheckoutPage/ShippingInformation/useShippingInformation.js @@ -1,37 +1,55 @@ -import { useCallback, useEffect, useMemo } from 'react'; -import { useLazyQuery } from '@apollo/react-hooks'; +import { useCallback, useEffect, useMemo, useState, useRef } from 'react'; +import { useMutation, useQuery } from '@apollo/react-hooks'; import { useAppContext } from '../../../context/app'; import { useCartContext } from '../../../context/cart'; +import { useUserContext } from '../../../context/user'; import { MOCKED_ADDRESS } from '../../CartPage/PriceAdjustments/ShippingMethods/useShippingForm'; export const useShippingInformation = props => { const { + mutations: { setDefaultAddressOnCartMutation }, onSave, - queries: { getShippingInformationQuery } + queries: { getDefaultShippingQuery, getShippingInformationQuery }, + toggleActiveContent } = props; const [, { toggleDrawer }] = useAppContext(); const [{ cartId }] = useCartContext(); + const [{ isSignedIn }] = useUserContext(); - const [fetchShippingInformation, { called, data, loading }] = useLazyQuery( - getShippingInformationQuery - ); + const [hasUpdate, setHasUpdate] = useState(false); + const hasLoadedData = useRef(false); - useEffect(() => { - if (cartId) { - fetchShippingInformation({ - variables: { - cartId - } - }); + const { + data: shippingInformationData, + loading: getShippingInformationLoading + } = useQuery(getShippingInformationQuery, { + skip: !cartId, + variables: { + cartId } - }, [cartId, fetchShippingInformation]); + }); + + const { + data: defaultShippingData, + loading: getDefaultShippingLoading + } = useQuery(getDefaultShippingQuery, { skip: !isSignedIn }); + + const [ + setDefaultAddressOnCart, + { loading: setDefaultAddressLoading } + ] = useMutation(setDefaultAddressOnCartMutation); + + const isLoading = + getShippingInformationLoading || + getDefaultShippingLoading || + setDefaultAddressLoading; const shippingData = useMemo(() => { let filteredData; - if (data) { - const { cart } = data; + if (shippingInformationData) { + const { cart } = shippingInformationData; const { email, shipping_addresses: shippingAddresses } = cart; if (shippingAddresses.length) { const primaryAddress = shippingAddresses[0]; @@ -56,7 +74,7 @@ export const useShippingInformation = props => { } return filteredData; - }, [data]); + }, [shippingInformationData]); // Simple heuristic to check shipping data existed prior to this render. // On first submission, when we have data, we should tell the checkout page @@ -69,14 +87,66 @@ export const useShippingInformation = props => { } }, [doneEditing, onSave]); + useEffect(() => { + let updateTimer; + if (shippingData !== undefined) { + if (hasLoadedData.current) { + setHasUpdate(true); + updateTimer = setTimeout(() => { + setHasUpdate(false); + }, 2000); + } else { + hasLoadedData.current = true; + } + } + + return () => { + if (updateTimer) { + clearTimeout(updateTimer); + } + }; + }, [hasLoadedData, shippingData]); + + useEffect(() => { + if ( + shippingInformationData && + !doneEditing && + cartId && + defaultShippingData + ) { + const { customer } = defaultShippingData; + const { default_shipping: defaultAddressId } = customer; + if (defaultAddressId) { + setDefaultAddressOnCart({ + variables: { + cartId, + addressId: parseInt(defaultAddressId) + } + }); + } + } + }, [ + cartId, + doneEditing, + defaultShippingData, + setDefaultAddressOnCart, + shippingInformationData + ]); + const handleEditShipping = useCallback(() => { - toggleDrawer('shippingInformation.edit'); - }, [toggleDrawer]); + if (isSignedIn) { + toggleActiveContent(); + } else { + toggleDrawer('shippingInformation.edit'); + } + }, [isSignedIn, toggleActiveContent, toggleDrawer]); return { doneEditing, handleEditShipping, - loading: !called || loading, + hasUpdate, + isLoading, + isSignedIn, shippingData }; }; diff --git a/packages/peregrine/lib/talons/CheckoutPage/useCheckoutPage.js b/packages/peregrine/lib/talons/CheckoutPage/useCheckoutPage.js index 39d73599a8..87afca4f93 100644 --- a/packages/peregrine/lib/talons/CheckoutPage/useCheckoutPage.js +++ b/packages/peregrine/lib/talons/CheckoutPage/useCheckoutPage.js @@ -1,8 +1,9 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useState } from 'react'; import { useApolloClient, useLazyQuery, - useMutation + useMutation, + useQuery } from '@apollo/react-hooks'; import { useAppContext } from '../../context/app'; @@ -20,7 +21,11 @@ export const CHECKOUT_STEP = { export const useCheckoutPage = props => { const { mutations: { createCartMutation, placeOrderMutation }, - queries: { getCheckoutDetailsQuery, getOrderDetailsQuery } + queries: { + getCheckoutDetailsQuery, + getCustomerQuery, + getOrderDetailsQuery + } } = props; const [reviewOrderButtonClicked, setReviewOrderButtonClicked] = useState( @@ -29,6 +34,7 @@ export const useCheckoutPage = props => { const apolloClient = useApolloClient(); const [isUpdating, setIsUpdating] = useState(false); + const [activeContent, setActiveContent] = useState('checkout'); const [checkoutStep, setCheckoutStep] = useState( CHECKOUT_STEP.SHIPPING_ADDRESS ); @@ -56,10 +62,29 @@ export const useCheckoutPage = props => { fetchPolicy: 'network-only' }); - const [ - getCheckoutDetails, - { data: checkoutData, called: checkoutCalled, loading: checkoutLoading } - ] = useLazyQuery(getCheckoutDetailsQuery); + const { data: customerData, loading: customerLoading } = useQuery( + getCustomerQuery, + { skip: !isSignedIn } + ); + + const { + data: checkoutData, + called: checkoutCalled, + loading: checkoutLoading + } = useQuery(getCheckoutDetailsQuery, { + skip: !cartId, + variables: { + cartId + } + }); + + const customer = customerData && customerData.customer; + + const toggleActiveContent = useCallback(() => { + const nextContentState = + activeContent === 'checkout' ? 'addressBook' : 'checkout'; + setActiveContent(nextContentState); + }, [activeContent]); const handleSignIn = useCallback(() => { // TODO: set navigation state to "SIGN_IN". useNavigation:showSignIn doesn't work. @@ -133,25 +158,20 @@ export const useCheckoutPage = props => { removeCart ]); - useEffect(() => { - if (cartId) { - getCheckoutDetails({ - variables: { - cartId - } - }); - } - }, [cartId, getCheckoutDetails]); - return { + activeContent, checkoutStep, + customer, error: placeOrderError, handleSignIn, handlePlaceOrder, hasError: !!placeOrderError, isCartEmpty: !(checkoutData && checkoutData.cart.total_quantity), isGuestCheckout: !isSignedIn, - isLoading: !checkoutCalled || (checkoutCalled && checkoutLoading), + isLoading: + !checkoutCalled || + (checkoutCalled && checkoutLoading) || + customerLoading, isUpdating, orderDetailsData, orderDetailsLoading, @@ -166,6 +186,7 @@ export const useCheckoutPage = props => { setPaymentInformationDone, resetReviewOrderButtonClicked, handleReviewOrder, - reviewOrderButtonClicked + reviewOrderButtonClicked, + toggleActiveContent }; }; diff --git a/packages/peregrine/lib/talons/CreateAccount/useCreateAccount.js b/packages/peregrine/lib/talons/CreateAccount/useCreateAccount.js index 6ef1693e46..c77bf964bb 100644 --- a/packages/peregrine/lib/talons/CreateAccount/useCreateAccount.js +++ b/packages/peregrine/lib/talons/CreateAccount/useCreateAccount.js @@ -4,6 +4,7 @@ import { useUserContext } from '@magento/peregrine/lib/context/user'; import { useCartContext } from '@magento/peregrine/lib/context/cart'; import { useAwaitQuery } from '@magento/peregrine/lib/hooks/useAwaitQuery'; import { clearCartDataFromCache } from '../../Apollo/clearCartDataFromCache'; +import { clearCustomerDataFromCache } from '../../Apollo/clearCustomerDataFromCache'; /** * Returns props necessary to render CreateAccount component. In particular this @@ -96,6 +97,7 @@ export const useCreateAccount = props => { await removeCart(); await clearCartDataFromCache(apolloClient); + await clearCustomerDataFromCache(apolloClient); await createCart({ fetchCartId diff --git a/packages/peregrine/lib/talons/Region/__tests__/__snapshots__/useRegion.spec.js.snap b/packages/peregrine/lib/talons/Region/__tests__/__snapshots__/useRegion.spec.js.snap index 22f4a99b96..2c115e9f80 100644 --- a/packages/peregrine/lib/talons/Region/__tests__/__snapshots__/useRegion.spec.js.snap +++ b/packages/peregrine/lib/talons/Region/__tests__/__snapshots__/useRegion.spec.js.snap @@ -28,3 +28,26 @@ Object { ], } `; + +exports[`returns formatted regions with id as the key 1`] = ` +Object { + "regions": Array [ + Object { + "disabled": true, + "hidden": true, + "label": "", + "value": "", + }, + Object { + "key": 1, + "label": "New York", + "value": 1, + }, + Object { + "key": 2, + "label": "Texas", + "value": 2, + }, + ], +} +`; diff --git a/packages/peregrine/lib/talons/Region/__tests__/useRegion.spec.js b/packages/peregrine/lib/talons/Region/__tests__/useRegion.spec.js index c143f600d9..4a2a716584 100644 --- a/packages/peregrine/lib/talons/Region/__tests__/useRegion.spec.js +++ b/packages/peregrine/lib/talons/Region/__tests__/useRegion.spec.js @@ -34,6 +34,11 @@ test('returns formatted regions', () => { expect(talonProps).toMatchSnapshot(); }); +test('returns formatted regions with id as the key', () => { + const talonProps = useRegion({ ...props, optionValueKey: 'id' }); + expect(talonProps).toMatchSnapshot(); +}); + test('returns empty array if no available regions', () => { useQuery.mockReturnValueOnce({ data: { diff --git a/packages/peregrine/lib/talons/Region/useRegion.js b/packages/peregrine/lib/talons/Region/useRegion.js index 1d106549fd..910df95c78 100644 --- a/packages/peregrine/lib/talons/Region/useRegion.js +++ b/packages/peregrine/lib/talons/Region/useRegion.js @@ -4,6 +4,7 @@ import { useFieldState } from 'informed'; export const useRegion = props => { const { countryCodeField = 'country', + optionValueKey = 'code', queries: { getRegionsQuery } } = props; @@ -22,7 +23,7 @@ export const useRegion = props => { formattedRegionsData = availableRegions.map(region => ({ key: region.id, label: region.name, - value: region.code + value: region[optionValueKey] })); formattedRegionsData.unshift({ disabled: true, diff --git a/packages/peregrine/lib/talons/SignIn/useSignIn.js b/packages/peregrine/lib/talons/SignIn/useSignIn.js index 38517aa29c..ecd6e11bd6 100644 --- a/packages/peregrine/lib/talons/SignIn/useSignIn.js +++ b/packages/peregrine/lib/talons/SignIn/useSignIn.js @@ -4,6 +4,7 @@ import { useApolloClient, useMutation } from '@apollo/react-hooks'; import { useCartContext } from '../../context/cart'; import { useAwaitQuery } from '../../hooks/useAwaitQuery'; import { clearCartDataFromCache } from '../../Apollo/clearCartDataFromCache'; +import { clearCustomerDataFromCache } from '../../Apollo/clearCustomerDataFromCache'; export const useSignIn = props => { const { @@ -62,6 +63,7 @@ export const useSignIn = props => { await removeCart(); await clearCartDataFromCache(apolloClient); + await clearCustomerDataFromCache(apolloClient); await createCart({ fetchCartId diff --git a/packages/venia-ui/lib/components/Checkbox/checkbox.css b/packages/venia-ui/lib/components/Checkbox/checkbox.css index 1a80211564..af256957f5 100644 --- a/packages/venia-ui/lib/components/Checkbox/checkbox.css +++ b/packages/venia-ui/lib/components/Checkbox/checkbox.css @@ -19,6 +19,7 @@ grid-row: 1 / span 1; height: 1.25rem; justify-content: center; + pointer-events: none; width: 1.25rem; } @@ -26,6 +27,7 @@ background: none; border: 1px solid rgb(var(--venia-text)); border-radius: 2px; + cursor: pointer; display: inline-flex; grid-column: 1 / span 1; grid-row: 1 / span 1; @@ -35,6 +37,10 @@ -webkit-appearance: none; } +.input:disabled { + cursor: default; +} + .input:focus { border-color: rgb(var(--venia-teal)); box-shadow: 0 0 0 2px rgb(var(--venia-teal-light)), diff --git a/packages/venia-ui/lib/components/CheckoutPage/AddressBook/__tests__/__snapshots__/addressBook.spec.js.snap b/packages/venia-ui/lib/components/CheckoutPage/AddressBook/__tests__/__snapshots__/addressBook.spec.js.snap new file mode 100644 index 0000000000..a419e69140 --- /dev/null +++ b/packages/venia-ui/lib/components/CheckoutPage/AddressBook/__tests__/__snapshots__/addressBook.spec.js.snap @@ -0,0 +1,145 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`render active state 1`] = ` +Array [ +
+

+ Change Shipping Information +

+
+ + +
+
+ + + + +
+
, + , +] +`; + +exports[`render hidden state with disabled buttons 1`] = ` +Array [ +
+

+ Change Shipping Information +

+
+ + +
+
+ +
+
, + , +] +`; diff --git a/packages/venia-ui/lib/components/CheckoutPage/AddressBook/__tests__/__snapshots__/addressCard.spec.js.snap b/packages/venia-ui/lib/components/CheckoutPage/AddressBook/__tests__/__snapshots__/addressCard.spec.js.snap new file mode 100644 index 0000000000..88eff42981 --- /dev/null +++ b/packages/venia-ui/lib/components/CheckoutPage/AddressBook/__tests__/__snapshots__/addressCard.spec.js.snap @@ -0,0 +1,136 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders base card state 1`] = ` +
+ + Default + + + Philip Fry + + + 3000 57th Street + + + Suite 200 + + + Manhattan, NY 10019 US + +
+`; + +exports[`renders selected card state 1`] = ` +
+ + + Philip Fry + + + 3000 57th Street + + + Suite 200 + + + Manhattan, NY 10019 US + +
+`; + +exports[`renders updated card state 1`] = ` +
+ + + Default + + + Philip Fry + + + 3000 57th Street + + + Suite 200 + + + Manhattan, NY 10019 US + +
+`; diff --git a/packages/venia-ui/lib/components/CheckoutPage/AddressBook/__tests__/addressBook.spec.js b/packages/venia-ui/lib/components/CheckoutPage/AddressBook/__tests__/addressBook.spec.js new file mode 100644 index 0000000000..bff2041a85 --- /dev/null +++ b/packages/venia-ui/lib/components/CheckoutPage/AddressBook/__tests__/addressBook.spec.js @@ -0,0 +1,56 @@ +import React from 'react'; +import { createTestInstance } from '@magento/peregrine'; +import { useAddressBook } from '@magento/peregrine/lib/talons/CheckoutPage/AddressBook/useAddressBook'; + +import AddressBook from '../addressBook'; + +jest.mock( + '@magento/peregrine/lib/talons/CheckoutPage/AddressBook/useAddressBook' +); +jest.mock('../../../../classify'); +jest.mock('../../ShippingInformation/editModal', () => 'EditModal'); +jest.mock('../addressCard', () => 'AddressCard'); + +test('render active state', () => { + useAddressBook.mockReturnValueOnce({ + activeAddress: 'activeAddress', + customerAddresses: [ + { id: 1, default_shipping: false, name: 'Philip' }, + { id: 2, default_shipping: true, name: 'Bender' }, + { id: 3, default_shipping: false, name: 'John' } + ], + handleAddAddress: jest.fn().mockName('handleAddAddress'), + handleApplyAddress: jest.fn().mockName('handleApplyAddress'), + handleCancel: jest.fn().mockName('handleCancel'), + handleEditAddress: jest.fn().mockName('handleEditAddress'), + handleSelectAddress: jest.fn().mockName('handleSelectAddress'), + isLoading: false, + selectedAddress: 3 + }); + + const tree = createTestInstance( + + ); + expect(tree.toJSON()).toMatchSnapshot(); +}); + +test('render hidden state with disabled buttons', () => { + useAddressBook.mockReturnValueOnce({ + customerAddresses: [], + handleAddAddress: jest.fn().mockName('handleAddAddress'), + handleApplyAddress: jest.fn().mockName('handleApplyAddress'), + handleCancel: jest.fn().mockName('handleCancel'), + isLoading: true + }); + + const tree = createTestInstance( + + ); + expect(tree.toJSON()).toMatchSnapshot(); +}); diff --git a/packages/venia-ui/lib/components/CheckoutPage/AddressBook/__tests__/addressCard.spec.js b/packages/venia-ui/lib/components/CheckoutPage/AddressBook/__tests__/addressCard.spec.js new file mode 100644 index 0000000000..c453d9fad5 --- /dev/null +++ b/packages/venia-ui/lib/components/CheckoutPage/AddressBook/__tests__/addressCard.spec.js @@ -0,0 +1,59 @@ +import React from 'react'; +import { createTestInstance } from '@magento/peregrine'; +import { useAddressCard } from '@magento/peregrine/lib/talons/CheckoutPage/AddressBook/useAddressCard'; + +import AddressCard from '../addressCard'; + +jest.mock( + '@magento/peregrine/lib/talons/CheckoutPage/AddressBook/useAddressCard' +); +jest.mock('../../../../classify'); + +const mockAddress = { + city: 'Manhattan', + country_code: 'US', + default_shipping: true, + firstname: 'Philip', + lastname: 'Fry', + postcode: '10019', + region: { region: 'NY' }, + street: ['3000 57th Street', 'Suite 200'], + telephone: '(123) 456-7890' +}; + +const talonProps = { + handleClick: jest.fn().mockName('handleClick'), + handleEditAddress: jest.fn().mockName('handleEditAddress'), + handleKeyPress: jest.fn().mockName('handleKeyPress'), + hasUpdate: false +}; + +test('renders base card state', () => { + useAddressCard.mockReturnValueOnce(talonProps); + + const tree = createTestInstance( + + ); + expect(tree.toJSON()).toMatchSnapshot(); +}); + +test('renders selected card state', () => { + useAddressCard.mockReturnValueOnce(talonProps); + + const tree = createTestInstance( + + ); + expect(tree.toJSON()).toMatchSnapshot(); +}); + +test('renders updated card state', () => { + useAddressCard.mockReturnValueOnce({ ...talonProps, hasUpdate: true }); + + const tree = createTestInstance( + + ); + expect(tree.toJSON()).toMatchSnapshot(); +}); diff --git a/packages/venia-ui/lib/components/CheckoutPage/AddressBook/addressBook.css b/packages/venia-ui/lib/components/CheckoutPage/AddressBook/addressBook.css new file mode 100644 index 0000000000..d3d6d0f0f2 --- /dev/null +++ b/packages/venia-ui/lib/components/CheckoutPage/AddressBook/addressBook.css @@ -0,0 +1,74 @@ +.root { + display: none; +} + +.root_active { + composes: root; + align-items: center; + display: grid; + grid-template-areas: + 'header buttons' + 'content content'; + grid-template-columns: auto auto; + grid-template-rows: 60px 1fr; + justify-content: space-between; + row-gap: 1rem; +} + +.headerText { + grid-area: header; + color: rgb(var(--venia-text-alt)); + line-height: 1.25em; +} + +.buttonContainer { + column-gap: 1rem; + display: grid; + grid-area: buttons; + grid-auto-flow: column; + justify-content: end; +} + +.content { + border-top: 1px solid rgb(var(--venia-border)); + display: grid; + gap: 1rem; + grid-area: content; + grid-auto-rows: minmax(6rem, max-content); + grid-template-columns: 1fr 1fr 1fr; + padding-top: 2rem; +} + +.addButton { + border: 1px dashed rgb(var(--venia-border)); + border-radius: 5px; + font-size: 0.875rem; + font-weight: 600; + transition: border-color 384ms var(--venia-anim-standard); +} + +.addButton:hover { + border: 1px dashed rgb(var(--venia-grey-darker)); + box-shadow: -1px 1px 1px rgb(var(--venia-grey)); +} + +@media (max-width: 960px) { + .root_active { + grid-template-areas: + 'header' + 'content' + 'buttons'; + grid-template-columns: 1fr; + grid-template-rows: 60px 1fr 60px; + } + + .buttonContainer { + justify-content: center; + } + + .content { + border-top: none; + grid-template-columns: 1fr; + padding-top: 0; + } +} diff --git a/packages/venia-ui/lib/components/CheckoutPage/AddressBook/addressBook.gql.js b/packages/venia-ui/lib/components/CheckoutPage/AddressBook/addressBook.gql.js new file mode 100644 index 0000000000..f139303263 --- /dev/null +++ b/packages/venia-ui/lib/components/CheckoutPage/AddressBook/addressBook.gql.js @@ -0,0 +1,38 @@ +import gql from 'graphql-tag'; + +import { SET_CUSTOMER_ADDRESS_ON_CART } from '../ShippingInformation/shippingInformation.gql'; +import { CustomerAddressFragment } from './addressBookFragments.gql'; +import { ShippingInformationFragment } from '../ShippingInformation/shippingInformationFragments.gql'; + +export const GET_CUSTOMER_ADDRESSES = gql` + query GetCustomerAddresses { + customer { + id + addresses { + id + ...CustomerAddressFragment + } + } + } + ${CustomerAddressFragment} +`; + +export const GET_CUSTOMER_CART_ADDRESS = gql` + query GetCustomerCartAddress { + customerCart { + id + ...ShippingInformationFragment + } + } + ${ShippingInformationFragment} +`; + +export default { + mutations: { + setCustomerAddressOnCartMutation: SET_CUSTOMER_ADDRESS_ON_CART + }, + queries: { + getCustomerAddressesQuery: GET_CUSTOMER_ADDRESSES, + getCustomerCartAddressQuery: GET_CUSTOMER_CART_ADDRESS + } +}; diff --git a/packages/venia-ui/lib/components/CheckoutPage/AddressBook/addressBook.js b/packages/venia-ui/lib/components/CheckoutPage/AddressBook/addressBook.js new file mode 100644 index 0000000000..4a0d096c63 --- /dev/null +++ b/packages/venia-ui/lib/components/CheckoutPage/AddressBook/addressBook.js @@ -0,0 +1,127 @@ +import React, { Fragment, useMemo } from 'react'; +import { useAddressBook } from '@magento/peregrine/lib/talons/CheckoutPage/AddressBook/useAddressBook'; + +import { mergeClasses } from '../../../classify'; +import Button from '../../Button'; +import defaultClasses from './addressBook.css'; +import AddressBookOperations from './addressBook.gql'; +import EditModal from '../ShippingInformation/editModal'; +import AddressCard from './addressCard'; +import { shape, string, func } from 'prop-types'; + +const AddressBook = props => { + const { activeContent, classes: propClasses, toggleActiveContent } = props; + + const talonProps = useAddressBook({ + ...AddressBookOperations, + toggleActiveContent + }); + + const { + activeAddress, + customerAddresses, + handleAddAddress, + handleApplyAddress, + handleCancel, + handleEditAddress, + handleSelectAddress, + isLoading, + selectedAddress + } = talonProps; + + const classes = mergeClasses(defaultClasses, propClasses); + + const rootClass = + activeContent === 'addressBook' ? classes.root_active : classes.root; + + const addAddressButton = ( + + ); + + const addressElements = useMemo(() => { + let defaultIndex; + const addresses = customerAddresses.map((address, index) => { + const isSelected = selectedAddress === address.id; + + if (address.default_shipping) { + defaultIndex = index; + } + + return ( + + ); + }); + + // Position the default address first in the elements list + if (defaultIndex) { + [addresses[0], addresses[defaultIndex]] = [ + addresses[defaultIndex], + addresses[0] + ]; + } + + return [...addresses, addAddressButton]; + }, [ + addAddressButton, + customerAddresses, + handleEditAddress, + handleSelectAddress, + selectedAddress + ]); + + return ( + +
+

+ Change Shipping Information +

+
+ + +
+ +
{addressElements}
+
+ +
+ ); +}; + +export default AddressBook; + +AddressBook.propTypes = { + activeContent: string.isRequired, + classes: shape({ + root: string, + root_active: string, + headerText: string, + buttonContainer: string, + content: string, + addButton: string + }), + toggleActiveContent: func.isRequired +}; diff --git a/packages/venia-ui/lib/components/CheckoutPage/AddressBook/addressBookFragments.gql.js b/packages/venia-ui/lib/components/CheckoutPage/AddressBook/addressBookFragments.gql.js new file mode 100644 index 0000000000..0d8e686c96 --- /dev/null +++ b/packages/venia-ui/lib/components/CheckoutPage/AddressBook/addressBookFragments.gql.js @@ -0,0 +1,31 @@ +import gql from 'graphql-tag'; + +export const CustomerAddressFragment = gql` + fragment CustomerAddressFragment on CustomerAddress { + id + city + country_code + default_shipping + firstname + lastname + postcode + region { + region + region_code + region_id + } + street + telephone + } +`; + +export const AddressBookFragment = gql` + fragment AddressBookFragment on Customer { + id + addresses { + id + ...CustomerAddressFragment + } + } + ${CustomerAddressFragment} +`; diff --git a/packages/venia-ui/lib/components/CheckoutPage/AddressBook/addressCard.css b/packages/venia-ui/lib/components/CheckoutPage/AddressBook/addressCard.css new file mode 100644 index 0000000000..f7fe806567 --- /dev/null +++ b/packages/venia-ui/lib/components/CheckoutPage/AddressBook/addressCard.css @@ -0,0 +1,87 @@ +.root { + align-content: flex-start; + border: 1px solid rgb(var(--venia-border)); + border-radius: 5px; + box-shadow: none; + cursor: pointer; + display: grid; + font-size: 0.875rem; + padding: 1.5rem 1rem; + position: relative; + row-gap: 0.5rem; + transition: border-color 384ms var(--venia-anim-in); + outline: none; +} + +.root_selected { + composes: root; + border-color: rgb(var(--venia-grey-darker)); + box-shadow: -1px 4px 4px rgb(var(--venia-grey-dark)), + inset 0 0 0 1px rgb(var(--venia-grey-darker)); + cursor: default; +} + +.root_updated { + composes: root_selected; + animation: flash var(--venia-anim-bounce) 640ms 2; +} + +.root:focus, +.root:hover { + box-shadow: -1px 2px 2px rgb(var(--venia-grey-dark)); +} + +.root_selected:focus, +.root_selected:hover { + box-shadow: -1px 4px 4px rgb(var(--venia-grey-dark)), + inset 0 0 0 1px rgb(var(--venia-grey-darker)); +} + +.defaultCard { + grid-area: 1 / 1; +} + +.editButton { + padding: 1rem; + position: absolute; + right: 0; + top: 0; +} + +.editButton:hover { + --fill: black; +} + +.editIcon { + fill: var(--fill, white); + transition: fill 384ms var(--venia-anim-standard); +} + +.defaultBadge { + color: rgb(var(--venia-text-hint)); + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; +} + +.name { + font-size: 1rem; + font-weight: 600; +} + +.address { + display: grid; + gap: 0.5rem; +} + +@keyframes flash { + 0% { + opacity: 1; + } + 50% { + opacity: 0.5; + } + 100% { + opacity: 1; + } +} diff --git a/packages/venia-ui/lib/components/CheckoutPage/AddressBook/addressCard.js b/packages/venia-ui/lib/components/CheckoutPage/AddressBook/addressCard.js new file mode 100644 index 0000000000..5129637a7a --- /dev/null +++ b/packages/venia-ui/lib/components/CheckoutPage/AddressBook/addressCard.js @@ -0,0 +1,110 @@ +import React from 'react'; +import { shape, string, bool, func, arrayOf } from 'prop-types'; +import { Edit2 as EditIcon } from 'react-feather'; +import { useAddressCard } from '@magento/peregrine/lib/talons/CheckoutPage/AddressBook/useAddressCard'; + +import { mergeClasses } from '../../../classify'; +import Icon from '../../Icon'; +import defaultClasses from './addressCard.css'; + +const AddressCard = props => { + const { + address, + classes: propClasses, + isSelected, + onEdit, + onSelection + } = props; + + const talonProps = useAddressCard({ address, onEdit, onSelection }); + const { + handleClick, + handleEditAddress, + handleKeyPress, + hasUpdate + } = talonProps; + + const { + city, + country_code, + default_shipping, + firstname, + lastname, + postcode, + region: { region }, + street + } = address; + + const streetRows = street.map((row, index) => { + return {row}; + }); + + const classes = mergeClasses(defaultClasses, propClasses); + + const rootClass = isSelected + ? hasUpdate + ? classes.root_updated + : classes.root_selected + : classes.root; + + const editButton = isSelected ? ( + + ) : null; + + const defaultBadge = default_shipping ? ( + {'Default'} + ) : null; + + return ( +
+ {editButton} + {defaultBadge} + {`${firstname} ${lastname}`} + {streetRows} + {`${city}, ${region} ${postcode} ${country_code}`} +
+ ); +}; + +export default AddressCard; + +AddressCard.propTypes = { + address: shape({ + city: string, + country_code: string, + default_shipping: bool, + firstname: string, + lastname: string, + postcode: string, + region: shape({ + region_code: string, + region: string + }), + street: arrayOf(string) + }).isRequired, + classes: shape({ + root: string, + root_selected: string, + root_updated: string, + editButton: string, + editIcon: string, + defaultBadge: string, + name: string, + address: string + }), + isSelected: bool.isRequired, + onEdit: func.isRequired, + onSelection: func.isRequired +}; diff --git a/packages/venia-ui/lib/components/CheckoutPage/AddressBook/index.js b/packages/venia-ui/lib/components/CheckoutPage/AddressBook/index.js new file mode 100644 index 0000000000..8d18a69b30 --- /dev/null +++ b/packages/venia-ui/lib/components/CheckoutPage/AddressBook/index.js @@ -0,0 +1 @@ +export { default } from './addressBook'; diff --git a/packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/AddressForm/__tests__/__snapshots__/addressForm.spec.js.snap b/packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/AddressForm/__tests__/__snapshots__/addressForm.spec.js.snap new file mode 100644 index 0000000000..0b326a4621 --- /dev/null +++ b/packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/AddressForm/__tests__/__snapshots__/addressForm.spec.js.snap @@ -0,0 +1,15 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders customer form 1`] = ` + +`; + +exports[`renders guest form 1`] = ` + +`; diff --git a/packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/AddressForm/__tests__/__snapshots__/customerForm.spec.js.snap b/packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/AddressForm/__tests__/__snapshots__/customerForm.spec.js.snap new file mode 100644 index 0000000000..a9cdd589c6 --- /dev/null +++ b/packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/AddressForm/__tests__/__snapshots__/customerForm.spec.js.snap @@ -0,0 +1,1294 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders loading indicator 1`] = ` +
+ + + + + + + + + + + + + + Fetching Customer Details... + +
+`; + +exports[`renders prefilled form with data with disabled buttons 1`] = ` +
+
+
+ + + + + + + + +

+

+
+
+
+ + + + + + + + +

+

+
+
+ +
+
+
+ + + + + + + + +

+

+
+
+
+ + + + + + + + +

+

+
+
+
+ + + + + + + + +

+

+
+
+ +
+
+
+ + + + + + + + +

+

+
+
+
+ + + + + + + + +

+

+
+
+ +

+

+
+ + +
+
+`; + +exports[`renders prefilled form with data with enabled buttons 1`] = ` +
+
+
+ + + + + + + + +

+

+
+
+
+ + + + + + + + +

+

+
+
+ +
+
+
+ + + + + + + + +

+

+
+
+
+ + + + + + + + +

+

+
+
+
+ + + + + + + + +

+

+
+
+ +
+
+
+ + + + + + + + +

+

+
+
+
+ + + + + + + + +

+

+
+
+ +

+

+
+ + +
+
+`; + +exports[`renders special form for initial default address entry 1`] = ` +
+
+

+ The shipping address you enter will be saved to your address book and set as your default for future purchases. +

+
+
+
+ + + + + + + + +

+

+
+
+
+ + + + + + + + +

+

+
+
+
+ + + + + + + + +

+

+
+
+ +
+
+
+ + + + + + + + +

+

+
+
+
+ + + + + + + + +

+

+
+
+
+ + + + + + + + +

+

+
+
+ +
+
+
+ + + + + + + + +

+

+
+
+
+ + + + + + + + +

+

+
+ +
+ +
+
+`; diff --git a/packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/EditForm/__tests__/__snapshots__/editForm.spec.js.snap b/packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/AddressForm/__tests__/__snapshots__/guestForm.spec.js.snap similarity index 100% rename from packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/EditForm/__tests__/__snapshots__/editForm.spec.js.snap rename to packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/AddressForm/__tests__/__snapshots__/guestForm.spec.js.snap diff --git a/packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/AddressForm/__tests__/addressForm.spec.js b/packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/AddressForm/__tests__/addressForm.spec.js new file mode 100644 index 0000000000..c086862c63 --- /dev/null +++ b/packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/AddressForm/__tests__/addressForm.spec.js @@ -0,0 +1,29 @@ +import React from 'react'; +import { useUserContext } from '@magento/peregrine/lib/context/user'; +import { createTestInstance } from '@magento/peregrine'; + +import AddressForm from '../addressForm'; + +jest.mock('@magento/peregrine/lib/context/user', () => ({ + useUserContext: jest.fn() +})); +jest.mock('../guestForm', () => 'GuestForm'); +jest.mock('../customerForm', () => 'CustomerForm'); + +test('renders guest form', () => { + useUserContext.mockReturnValueOnce([{ isSignedIn: false }]); + + const tree = createTestInstance( + + ); + expect(tree.toJSON()).toMatchSnapshot(); +}); + +test('renders customer form', () => { + useUserContext.mockReturnValueOnce([{ isSignedIn: true }]); + + const tree = createTestInstance( + + ); + expect(tree.toJSON()).toMatchSnapshot(); +}); diff --git a/packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/AddressForm/__tests__/customerForm.spec.js b/packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/AddressForm/__tests__/customerForm.spec.js new file mode 100644 index 0000000000..b47129561f --- /dev/null +++ b/packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/AddressForm/__tests__/customerForm.spec.js @@ -0,0 +1,98 @@ +import React from 'react'; +import { createTestInstance } from '@magento/peregrine'; +import { useCustomerForm } from '@magento/peregrine/lib/talons/CheckoutPage/ShippingInformation/AddressForm/useCustomerForm'; + +import CustomerForm from '../customerForm'; + +jest.mock( + '@magento/peregrine/lib/talons/CheckoutPage/ShippingInformation/AddressForm/useCustomerForm' +); +jest.mock('../../../../../classify'); +jest.mock('../../../../Country', () => 'Country'); +jest.mock('../../../../Region', () => 'Region'); + +const mockProps = { + afterSubmit: jest.fn(), + onCancel: jest.fn() +}; + +const handleCancel = jest.fn().mockName('handleCancel'); +const handleSubmit = jest.fn().mockName('handleSubmit'); + +test('renders loading indicator', () => { + useCustomerForm.mockReturnValueOnce({ + isLoading: true + }); + + const tree = createTestInstance(); + expect(tree.toJSON()).toMatchSnapshot(); +}); + +test('renders special form for initial default address entry', () => { + useCustomerForm.mockReturnValueOnce({ + handleCancel, + handleSubmit, + hasDefaultShipping: false, + initialValues: { + country: 'US', + email: 'fry@planet.express', + firstname: 'Philip', + lastname: 'Fry', + region: '' + }, + isLoading: false, + isSaving: false, + isUpdate: false + }); + + const tree = createTestInstance(); + expect(tree.toJSON()).toMatchSnapshot(); +}); + +describe('renders prefilled form with data', () => { + const initialValues = { + city: 'Manhattan', + country: 'US', + default_shipping: true, + email: 'fry@planet.express', + firstname: 'Philip', + lastname: 'Fry', + postcode: '10019', + region: 'NY', + street: ['3000 57th Street', 'Suite 200'], + telephone: '(123) 456-7890' + }; + + test('with enabled buttons', () => { + useCustomerForm.mockReturnValueOnce({ + handleCancel, + handleSubmit, + hasDefaultShipping: true, + initialValues, + isLoading: false, + isSaving: false, + isUpdate: true + }); + + const tree = createTestInstance(); + expect(tree.toJSON()).toMatchSnapshot(); + }); + + test('with disabled buttons', () => { + useCustomerForm.mockReturnValueOnce({ + handleCancel, + handleSubmit, + hasDefaultShipping: true, + initialValues: { + ...initialValues, + default_shipping: false + }, + isLoading: false, + isSaving: true, + isUpdate: true + }); + + const tree = createTestInstance(); + expect(tree.toJSON()).toMatchSnapshot(); + }); +}); diff --git a/packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/EditForm/__tests__/editForm.spec.js b/packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/AddressForm/__tests__/guestForm.spec.js similarity index 75% rename from packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/EditForm/__tests__/editForm.spec.js rename to packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/AddressForm/__tests__/guestForm.spec.js index a16e4511e4..4021e2016f 100644 --- a/packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/EditForm/__tests__/editForm.spec.js +++ b/packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/AddressForm/__tests__/guestForm.spec.js @@ -1,11 +1,11 @@ import React from 'react'; import { createTestInstance } from '@magento/peregrine'; -import { useEditForm } from '@magento/peregrine/lib/talons/CheckoutPage/ShippingInformation/EditForm/useEditForm'; +import { useGuestForm } from '@magento/peregrine/lib/talons/CheckoutPage/ShippingInformation/AddressForm/useGuestForm'; -import EditForm from '../editForm'; +import GuestForm from '../guestForm'; jest.mock( - '@magento/peregrine/lib/talons/CheckoutPage/ShippingInformation/EditForm/useEditForm' + '@magento/peregrine/lib/talons/CheckoutPage/ShippingInformation/AddressForm/useGuestForm' ); jest.mock('../../../../../classify'); jest.mock('../../../../Country', () => 'Country'); @@ -20,7 +20,7 @@ const handleCancel = jest.fn().mockName('handleCancel'); const handleSubmit = jest.fn().mockName('handleSubmit'); test('renders empty form without data', () => { - useEditForm.mockReturnValueOnce({ + useGuestForm.mockReturnValueOnce({ handleCancel, handleSubmit, initialValues: { @@ -31,7 +31,7 @@ test('renders empty form without data', () => { isUpdate: false }); - const tree = createTestInstance(); + const tree = createTestInstance(); expect(tree.toJSON()).toMatchSnapshot(); }); @@ -49,7 +49,7 @@ describe('renders prefilled form with data', () => { }; test('with enabled buttons', () => { - useEditForm.mockReturnValueOnce({ + useGuestForm.mockReturnValueOnce({ handleCancel, handleSubmit, initialValues, @@ -57,12 +57,12 @@ describe('renders prefilled form with data', () => { isUpdate: true }); - const tree = createTestInstance(); + const tree = createTestInstance(); expect(tree.toJSON()).toMatchSnapshot(); }); test('with disabled buttons', () => { - useEditForm.mockReturnValueOnce({ + useGuestForm.mockReturnValueOnce({ handleCancel, handleSubmit, initialValues, @@ -70,7 +70,7 @@ describe('renders prefilled form with data', () => { isUpdate: true }); - const tree = createTestInstance(); + const tree = createTestInstance(); expect(tree.toJSON()).toMatchSnapshot(); }); }); diff --git a/packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/AddressForm/addressForm.js b/packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/AddressForm/addressForm.js new file mode 100644 index 0000000000..0cb7afecb3 --- /dev/null +++ b/packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/AddressForm/addressForm.js @@ -0,0 +1,18 @@ +import React from 'react'; +import { useUserContext } from '@magento/peregrine/lib/context/user'; + +import CustomerForm from './customerForm'; +import GuestForm from './guestForm'; + +/** + * Simple component that acts like an AddressForm factory, giving the client + * the correct form to render based on the current signed in state. + */ +const AddressForm = props => { + const [{ isSignedIn }] = useUserContext(); + const AddressForm = isSignedIn ? CustomerForm : GuestForm; + + return ; +}; + +export default AddressForm; diff --git a/packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/AddressForm/customerForm.css b/packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/AddressForm/customerForm.css new file mode 100644 index 0000000000..e093344a46 --- /dev/null +++ b/packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/AddressForm/customerForm.css @@ -0,0 +1,62 @@ +.root { + display: grid; + gap: 0.5rem 1.5rem; + grid-template-columns: 1fr 1fr; + width: 100%; +} + +.field { + grid-column-end: span 2; +} + +.formMessage, +.email, +.country, +.street0, +.street1, +.city, +.region, +.postcode, +.telephone { + composes: field; +} + +.defaultShipping { + composes: field; + padding-top: 1rem; +} + +.firstname, +.lastname { + grid-column-end: span 1; +} + +.buttons { + composes: field; + display: grid; + gap: 1rem; + grid-auto-flow: column; + justify-self: center; + padding: 1rem; +} + +.submit { + composes: root_normalPriority from '../../../Button/button.css'; + font-size: 0.875rem; + font-weight: 600; +} + +.submit_update { + composes: submit; + composes: filled from '../../../Button/button.css'; +} + +@media (max-width: 960px) { + .firstname { + grid-column: 1 / span 2; + } + + .lastname { + grid-column: 1 / span 2; + } +} diff --git a/packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/AddressForm/customerForm.gql.js b/packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/AddressForm/customerForm.gql.js new file mode 100644 index 0000000000..575602da78 --- /dev/null +++ b/packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/AddressForm/customerForm.gql.js @@ -0,0 +1,59 @@ +import gql from 'graphql-tag'; + +import { GET_CUSTOMER_ADDRESSES } from '../../AddressBook/addressBook.gql'; +import { CustomerAddressFragment } from '../../AddressBook/addressBookFragments.gql'; +import { GET_DEFAULT_SHIPPING } from '../shippingInformation.gql'; + +export const CREATE_CUSTOMER_ADDRESS_MUTATION = gql` + mutation CreateCustomerAddress($address: CustomerAddressInput!) { + createCustomerAddress(input: $address) + @connection(key: "createCustomerAddress") { + id + ...CustomerAddressFragment + } + } + ${CustomerAddressFragment} +`; + +/** + * We would normally use the CustomerAddressFragment here for the response + * but due to GraphQL returning null region data, we return minimal data and + * rely on refetching after performing this mutation to get accurate data. + * + * Fragment will be added back after MC-33948 is resolved. + */ +export const UPDATE_CUSTOMER_ADDRESS_MUTATION = gql` + mutation UpdateCustomerAddress( + $addressId: Int! + $address: CustomerAddressInput! + ) { + updateCustomerAddress(id: $addressId, input: $address) + @connection(key: "updateCustomerAddress") { + id + } + } +`; + +export const GET_CUSTOMER_QUERY = gql` + query GetCustomer { + customer { + id + default_shipping + email + firstname + lastname + } + } +`; + +export default { + mutations: { + createCustomerAddressMutation: CREATE_CUSTOMER_ADDRESS_MUTATION, + updateCustomerAddressMutation: UPDATE_CUSTOMER_ADDRESS_MUTATION + }, + queries: { + getCustomerQuery: GET_CUSTOMER_QUERY, + getCustomerAddressesQuery: GET_CUSTOMER_ADDRESSES, + getDefaultShippingQuery: GET_DEFAULT_SHIPPING + } +}; diff --git a/packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/AddressForm/customerForm.js b/packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/AddressForm/customerForm.js new file mode 100644 index 0000000000..139dfb67bb --- /dev/null +++ b/packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/AddressForm/customerForm.js @@ -0,0 +1,227 @@ +import React from 'react'; +import { Form, Text } from 'informed'; +import { func, shape, string, arrayOf, number, bool } from 'prop-types'; +import { useCustomerForm } from '@magento/peregrine/lib/talons/CheckoutPage/ShippingInformation/AddressForm/useCustomerForm'; + +import { mergeClasses } from '../../../../classify'; +import { isRequired } from '../../../../util/formValidators'; +import Button from '../../../Button'; +import Checkbox from '../../../Checkbox'; +import Country from '../../../Country'; +import Field, { Message } from '../../../Field'; +import Region from '../../../Region'; +import TextInput from '../../../TextInput'; +import defaultClasses from './customerForm.css'; +import CustomerFormOperations from './customerForm.gql'; +import LoadingIndicator from '../../../LoadingIndicator'; + +const CustomerForm = props => { + const { afterSubmit, classes: propClasses, onCancel, shippingData } = props; + + const talonProps = useCustomerForm({ + afterSubmit, + ...CustomerFormOperations, + onCancel, + shippingData + }); + const { + handleCancel, + handleSubmit, + hasDefaultShipping, + initialValues, + isLoading, + isSaving, + isUpdate + } = talonProps; + + if (isLoading) { + return ( + Fetching Customer Details... + ); + } + + const classes = mergeClasses(defaultClasses, propClasses); + + const emailRow = !hasDefaultShipping ? ( +
+ + + +
+ ) : null; + + const formMessageRow = !hasDefaultShipping ? ( +
+ + { + 'The shipping address you enter will be saved to your address book and set as your default for future purchases.' + } + +
+ ) : null; + + const cancelButton = isUpdate ? ( + + ) : null; + + const submitButtonText = !hasDefaultShipping + ? 'Save and Continue' + : isUpdate + ? 'Update' + : 'Add'; + + const submitButtonProps = { + classes: { + root_normalPriority: classes.submit, + root_highPriority: classes.submit_update + }, + disabled: isSaving, + priority: isUpdate ? 'high' : 'normal', + type: 'submit' + }; + + const defaultShippingElement = hasDefaultShipping ? ( +
+ +
+ ) : ( + + ); + + return ( +
+ {formMessageRow} + {emailRow} +
+ + + +
+
+ + + +
+
+ +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ +
+
+ + + +
+
+ + + +
+ {defaultShippingElement} +
+ {cancelButton} + +
+
+ ); +}; + +export default CustomerForm; + +CustomerForm.defaultProps = { + shippingData: { + country: { + code: 'US' + }, + region: { + id: null + } + } +}; + +CustomerForm.propTypes = { + afterSubmit: func, + classes: shape({ + root: string, + field: string, + email: string, + firstname: string, + lastname: string, + country: string, + street0: string, + street1: string, + city: string, + region: string, + postcode: string, + telephone: string, + buttons: string, + submit: string, + submit_update: string, + formMessage: string, + defaultShipping: string + }), + onCancel: func, + shippingData: shape({ + city: string, + country: shape({ + code: string.isRequired + }).isRequired, + default_shipping: bool, + email: string, + firstname: string, + id: number, + lastname: string, + postcode: string, + region: shape({ + id: number + }).isRequired, + street: arrayOf(string), + telephone: string + }) +}; diff --git a/packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/EditForm/editForm.css b/packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/AddressForm/guestForm.css similarity index 100% rename from packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/EditForm/editForm.css rename to packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/AddressForm/guestForm.css diff --git a/packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/EditForm/editForm.gql.js b/packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/AddressForm/guestForm.gql.js similarity index 80% rename from packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/EditForm/editForm.gql.js rename to packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/AddressForm/guestForm.gql.js index 79c5183202..d40877576b 100644 --- a/packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/EditForm/editForm.gql.js +++ b/packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/AddressForm/guestForm.gql.js @@ -3,14 +3,14 @@ import gql from 'graphql-tag'; import { ShippingInformationFragment } from '../shippingInformationFragments.gql'; import { ShippingMethodsCheckoutFragment } from '../../ShippingMethod/shippingMethodFragments.gql'; -export const SET_SHIPPING_INFORMATION_MUTATION = gql` - mutation SetShippingInformation( +export const SET_GUEST_SHIPPING_MUTATION = gql` + mutation SetGuestShipping( $cartId: String! $email: String! $address: CartAddressInput! ) { setGuestEmailOnCart(input: { cart_id: $cartId, email: $email }) - @connection(key: setGuestEmailOnCart) { + @connection(key: "setGuestEmailOnCart") { cart { id } @@ -35,6 +35,7 @@ export const SET_SHIPPING_INFORMATION_MUTATION = gql` export default { mutations: { - setShippingInformationMutation: SET_SHIPPING_INFORMATION_MUTATION - } + setGuestShippingMutation: SET_GUEST_SHIPPING_MUTATION + }, + queries: {} }; diff --git a/packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/EditForm/editForm.js b/packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/AddressForm/guestForm.js similarity index 82% rename from packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/EditForm/editForm.js rename to packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/AddressForm/guestForm.js index 3ca0673cac..e3b0163277 100644 --- a/packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/EditForm/editForm.js +++ b/packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/AddressForm/guestForm.js @@ -1,7 +1,7 @@ import React from 'react'; import { Form } from 'informed'; import { func, shape, string, arrayOf } from 'prop-types'; -import { useEditForm } from '@magento/peregrine/lib/talons/CheckoutPage/ShippingInformation/EditForm/useEditForm'; +import { useGuestForm } from '@magento/peregrine/lib/talons/CheckoutPage/ShippingInformation/AddressForm/useGuestForm'; import { mergeClasses } from '../../../../classify'; import { isRequired } from '../../../../util/formValidators'; @@ -10,15 +10,15 @@ import Country from '../../../Country'; import Field, { Message } from '../../../Field'; import Region from '../../../Region'; import TextInput from '../../../TextInput'; -import defaultClasses from './editForm.css'; -import EditFormOperations from './editForm.gql'; +import defaultClasses from './guestForm.css'; +import GuestFormOperations from './guestForm.gql'; -const EditForm = props => { +const GuestForm = props => { const { afterSubmit, classes: propClasses, onCancel, shippingData } = props; - const talonProps = useEditForm({ + const talonProps = useGuestForm({ afterSubmit, - ...EditFormOperations, + ...GuestFormOperations, onCancel, shippingData }); @@ -32,7 +32,7 @@ const EditForm = props => { const classes = mergeClasses(defaultClasses, propClasses); - const messageElement = !isUpdate ? ( + const guestEmailMessage = !isUpdate ? ( { 'Set a password at the end of guest checkout to create an account in one easy step.' @@ -53,19 +53,19 @@ const EditForm = props => { ) : null; - const submitButton = ( - - ); + const submitButtonText = isUpdate + ? 'Update' + : 'Continue to Shipping Method'; + + const submitButtonProps = { + classes: { + root_normalPriority: classes.submit, + root_highPriority: classes.submit_update + }, + disabled: isSaving, + priority: isUpdate ? 'high' : 'normal', + type: 'submit' + }; return (
{
- {messageElement} + {guestEmailMessage}
@@ -122,15 +122,15 @@ const EditForm = props => {
{cancelButton} - {submitButton} +
); }; -export default EditForm; +export default GuestForm; -EditForm.defaultProps = { +GuestForm.defaultProps = { shippingData: { country: { code: 'US' @@ -141,7 +141,7 @@ EditForm.defaultProps = { } }; -EditForm.propTypes = { +GuestForm.propTypes = { afterSubmit: func, classes: shape({ root: string, diff --git a/packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/AddressForm/index.js b/packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/AddressForm/index.js new file mode 100644 index 0000000000..c50616a40c --- /dev/null +++ b/packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/AddressForm/index.js @@ -0,0 +1,3 @@ +export { default } from './addressForm'; +export { default as GuestForm } from './guestForm'; +export { default as CustomerForm } from './customerForm'; diff --git a/packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/EditForm/index.js b/packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/EditForm/index.js deleted file mode 100644 index f49cf662ea..0000000000 --- a/packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/EditForm/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './editForm'; diff --git a/packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/__tests__/__snapshots__/editModal.spec.js.snap b/packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/__tests__/__snapshots__/editModal.spec.js.snap index 9e0b059540..69c1154821 100644 --- a/packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/__tests__/__snapshots__/editModal.spec.js.snap +++ b/packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/__tests__/__snapshots__/editModal.spec.js.snap @@ -106,7 +106,7 @@ exports[`renders open modal 1`] = `
- +
+
+ Shipping Information +
+ +
+ +
+`; + +exports[`renders card state with guest data 1`] = `
@@ -63,7 +111,7 @@ exports[`renders form state without data 1`] = `
-
diff --git a/packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/__tests__/editModal.spec.js b/packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/__tests__/editModal.spec.js index 2056248f26..0ac5de8be3 100644 --- a/packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/__tests__/editModal.spec.js +++ b/packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/__tests__/editModal.spec.js @@ -11,7 +11,7 @@ jest.mock('../../../../classify'); jest.mock('../../../Modal', () => ({ Modal: props => {props.children} })); -jest.mock('../EditForm', () => 'EditForm'); +jest.mock('../AddressForm', () => 'AddressForm'); const handleClose = jest.fn().mockName('handleClose'); diff --git a/packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/__tests__/shippingInformation.spec.js b/packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/__tests__/shippingInformation.spec.js index 029aa1d69f..e0e846ccaf 100644 --- a/packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/__tests__/shippingInformation.spec.js +++ b/packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/__tests__/shippingInformation.spec.js @@ -11,24 +11,37 @@ jest.mock('../../../../classify'); jest.mock('../../../LoadingIndicator', () => 'LoadingIndicator'); jest.mock('../card', () => 'Card'); -jest.mock('../EditForm', () => 'EditForm'); +jest.mock('../AddressForm', () => 'AddressForm'); jest.mock('../editModal', () => 'EditModal'); test('renders loading element', () => { useShippingInformation.mockReturnValueOnce({ doneEditing: false, - loading: true + isLoading: true }); const tree = createTestInstance(); expect(tree.toJSON()).toMatchSnapshot(); }); -test('renders card state with data', () => { +test('renders card state with guest data', () => { useShippingInformation.mockReturnValueOnce({ doneEditing: true, handleEditShipping: jest.fn().mockName('handleEditShipping'), - loading: false, + isLoading: false, + shippingData: 'Shipping Data' + }); + + const tree = createTestInstance(); + expect(tree.toJSON()).toMatchSnapshot(); +}); + +test('renders card state with customer data', () => { + useShippingInformation.mockReturnValueOnce({ + doneEditing: true, + handleEditShipping: jest.fn().mockName('handleEditShipping'), + isLoading: false, + isSignedIn: true, shippingData: 'Shipping Data' }); @@ -39,7 +52,7 @@ test('renders card state with data', () => { test('renders form state without data', () => { useShippingInformation.mockReturnValueOnce({ doneEditing: false, - loading: false, + isLoading: false, shippingData: 'Shipping Data' }); diff --git a/packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/editModal.js b/packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/editModal.js index 07922b4da2..65fbe73df6 100644 --- a/packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/editModal.js +++ b/packages/venia-ui/lib/components/CheckoutPage/ShippingInformation/editModal.js @@ -6,7 +6,7 @@ import { useEditModal } from '@magento/peregrine/lib/talons/CheckoutPage/Shippin import { mergeClasses } from '../../../classify'; import Icon from '../../Icon'; import { Modal } from '../../Modal'; -import EditForm from './EditForm'; +import AddressForm from './AddressForm'; import defaultClasses from './editModal.css'; const EditModal = props => { @@ -19,7 +19,7 @@ const EditModal = props => { // Unmount the form to force a reset back to original values on close const bodyElement = isOpen ? ( - { - const { classes: propClasses, onSave } = props; + const { classes: propClasses, onSave, toggleActiveContent } = props; const talonProps = useShippingInformation({ onSave, + toggleActiveContent, ...ShippingInformationOperations }); const { doneEditing, handleEditShipping, - loading, + hasUpdate, + isSignedIn, + isLoading, shippingData } = talonProps; const classes = mergeClasses(defaultClasses, propClasses); - const rootClassName = doneEditing ? classes.root : classes.root_editMode; + const rootClassName = !doneEditing + ? classes.root_editMode + : hasUpdate + ? classes.root_updated + : classes.root; - if (loading) { + if (isLoading) { return ( Fetching Shipping Information... @@ -37,6 +44,10 @@ const ShippingInformation = props => { ); } + const editModal = !isSignedIn ? ( + + ) : null; + const shippingInformation = doneEditing ? (
@@ -51,16 +62,17 @@ const ShippingInformation = props => {
- + {editModal}
) : (

{'1. Shipping Information'}

- +
); + return
{shippingInformation}
; }; @@ -75,5 +87,6 @@ ShippingInformation.propTypes = { editWrapper: string, editTitle: string }), - onSave: func.isRequired + onSave: func.isRequired, + toggleActiveContent: func.isRequired }; diff --git a/packages/venia-ui/lib/components/CheckoutPage/__tests__/__snapshots__/checkoutPage.spec.js.snap b/packages/venia-ui/lib/components/CheckoutPage/__tests__/__snapshots__/checkoutPage.spec.js.snap new file mode 100644 index 0000000000..a3ebb20b74 --- /dev/null +++ b/packages/venia-ui/lib/components/CheckoutPage/__tests__/__snapshots__/checkoutPage.spec.js.snap @@ -0,0 +1,321 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CheckoutPage renders address book for customer 1`] = ` +
+ Title +
+
+

+ Review and Place Order +

+
+
+ +
+
+

+ 2. Shipping Method +

+
+
+

+ 3. Payment Information +

+
+
+ +
+
+ +
+`; + +exports[`CheckoutPage renders checkout content for customer - default address 1`] = ` +
+ Title +
+
+

+ Review and Place Order +

+
+
+ +
+
+

+ 2. Shipping Method +

+
+
+

+ 3. Payment Information +

+
+
+ +
+
+ +
+`; + +exports[`CheckoutPage renders checkout content for customer - no default address 1`] = ` +
+ Title +
+
+

+ Welcome Eloise! +

+
+
+ +
+
+

+ 2. Shipping Method +

+
+
+

+ 3. Payment Information +

+
+
+ +
+
+ +
+`; + +exports[`CheckoutPage renders checkout content for guest 1`] = ` +
+ Title +
+
+ +
+
+

+ Guest Checkout +

+
+
+ +
+
+

+ 2. Shipping Method +

+
+
+

+ 3. Payment Information +

+
+
+ +
+
+
+`; + +exports[`CheckoutPage renders loading indicator 1`] = ` +
+ + + + + + + + + + + + + + Fetching Data... + +
+`; diff --git a/packages/venia-ui/lib/components/CheckoutPage/__tests__/checkoutPage.spec.js b/packages/venia-ui/lib/components/CheckoutPage/__tests__/checkoutPage.spec.js index d5f7c7ba83..084c6f56bc 100644 --- a/packages/venia-ui/lib/components/CheckoutPage/__tests__/checkoutPage.spec.js +++ b/packages/venia-ui/lib/components/CheckoutPage/__tests__/checkoutPage.spec.js @@ -33,20 +33,25 @@ jest.mock('@magento/peregrine/lib/talons/CheckoutPage/useCheckoutPage', () => { }; }); +jest.mock('../../../classify'); + jest.mock('../../../components/Head', () => ({ Title: () => 'Title' })); jest.mock('../ItemsReview', () => 'ItemsReview'); jest.mock('../OrderSummary', () => 'OrderSummary'); jest.mock('../OrderConfirmationPage', () => 'OrderConfirmationPage'); jest.mock('../ShippingInformation', () => 'ShippingInformation'); jest.mock('../ShippingMethod', () => 'ShippingMethod'); -jest.mock('../PaymentInformation', () => 'Payment Information'); -jest.mock('../PriceAdjustments', () => 'Price Adjustments'); +jest.mock('../PaymentInformation', () => 'PaymentInformation'); +jest.mock('../PriceAdjustments', () => 'PriceAdjustments'); +jest.mock('../AddressBook', () => 'AddressBook'); const defaultTalonProps = { + activeContent: 'checkout', checkoutStep: 1, + customer: null, error: false, - handleSignIn: jest.fn(), - handlePlaceOrder: jest.fn(), + handleSignIn: jest.fn().mockName('handleSignIn'), + handlePlaceOrder: jest.fn().mockName('handlePlaceOrder'), hasError: false, isCartEmpty: false, isGuestCheckout: true, @@ -56,10 +61,13 @@ const defaultTalonProps = { orderDetailsLoading: false, orderNumber: 1, placeOrderLoading: false, - setIsUpdating: jest.fn(), - setShippingInformationDone: jest.fn(), - setShippingMethodDone: jest.fn(), - setPaymentInformationDone: jest.fn() + setIsUpdating: jest.fn().mockName('setIsUpdating'), + setShippingInformationDone: jest + .fn() + .mockName('setShippingInformationDone'), + setShippingMethodDone: jest.fn().mockName('setShippingMethodDone'), + setPaymentInformationDone: jest.fn().mockName('setPaymentInformationDone'), + toggleActiveContent: jest.fn().mockName('toggleActiveContent') }; describe('CheckoutPage', () => { test('throws a toast if there is an error', () => { @@ -102,4 +110,54 @@ describe('CheckoutPage', () => { expect(button).toBeTruthy(); expect(button.props.disabled).toBe(true); }); + + test('renders loading indicator', () => { + useCheckoutPage.mockReturnValueOnce({ + isLoading: true + }); + + const tree = createTestInstance(); + expect(tree.toJSON()).toMatchSnapshot(); + }); + + test('renders checkout content for guest', () => { + useCheckoutPage.mockReturnValueOnce(defaultTalonProps); + + const tree = createTestInstance(); + expect(tree.toJSON()).toMatchSnapshot(); + }); + + test('renders checkout content for customer - no default address', () => { + useCheckoutPage.mockReturnValueOnce({ + ...defaultTalonProps, + customer: { default_shipping: null, firstname: 'Eloise' }, + isGuestCheckout: false + }); + + const tree = createTestInstance(); + expect(tree.toJSON()).toMatchSnapshot(); + }); + + test('renders checkout content for customer - default address', () => { + useCheckoutPage.mockReturnValueOnce({ + ...defaultTalonProps, + customer: { default_shipping: '1' }, + isGuestCheckout: false + }); + + const tree = createTestInstance(); + expect(tree.toJSON()).toMatchSnapshot(); + }); + + test('renders address book for customer', () => { + useCheckoutPage.mockReturnValueOnce({ + ...defaultTalonProps, + activeContent: 'addressBook', + customer: { default_shipping: '1' }, + isGuestCheckout: false + }); + + const tree = createTestInstance(); + expect(tree.toJSON()).toMatchSnapshot(); + }); }); diff --git a/packages/venia-ui/lib/components/CheckoutPage/checkoutPage.css b/packages/venia-ui/lib/components/CheckoutPage/checkoutPage.css index 650a134b99..810dd976f2 100644 --- a/packages/venia-ui/lib/components/CheckoutPage/checkoutPage.css +++ b/packages/venia-ui/lib/components/CheckoutPage/checkoutPage.css @@ -2,11 +2,19 @@ padding: 2.5rem 3rem; max-width: 1080px; margin: 0 auto; +} + +.checkoutContent { display: grid; gap: 2rem; grid-template-columns: 2fr 1fr; } +.checkoutContent_hidden { + composes: checkoutContent; + display: none; +} + .heading_container { display: flex; align-items: baseline; @@ -99,6 +107,9 @@ .root { padding-left: 1.5rem; padding-right: 1.5rem; + } + + .checkoutContent { /* Only one column in mobile view. */ grid-template-columns: 1fr; gap: 1rem; diff --git a/packages/venia-ui/lib/components/CheckoutPage/checkoutPage.gql.js b/packages/venia-ui/lib/components/CheckoutPage/checkoutPage.gql.js index 83f1886840..11d8ecb75c 100644 --- a/packages/venia-ui/lib/components/CheckoutPage/checkoutPage.gql.js +++ b/packages/venia-ui/lib/components/CheckoutPage/checkoutPage.gql.js @@ -42,6 +42,16 @@ export const GET_CHECKOUT_DETAILS = gql` ${CheckoutPageFragment} `; +export const GET_CUSTOMER = gql` + query GetCustomer { + customer { + id + default_shipping + firstname + } + } +`; + export default { mutations: { createCartMutation: CREATE_CART, @@ -49,6 +59,7 @@ export default { }, queries: { getCheckoutDetailsQuery: GET_CHECKOUT_DETAILS, + getCustomerQuery: GET_CUSTOMER, getOrderDetailsQuery: GET_ORDER_DETAILS } }; diff --git a/packages/venia-ui/lib/components/CheckoutPage/checkoutPage.js b/packages/venia-ui/lib/components/CheckoutPage/checkoutPage.js index 1da21f78cc..a44cf88723 100644 --- a/packages/venia-ui/lib/components/CheckoutPage/checkoutPage.js +++ b/packages/venia-ui/lib/components/CheckoutPage/checkoutPage.js @@ -1,4 +1,4 @@ -import React, { Fragment, useEffect } from 'react'; +import React, { useEffect } from 'react'; import { AlertCircle as AlertCircleIcon } from 'react-feather'; import { useWindowSize, useToasts } from '@magento/peregrine'; @@ -11,6 +11,7 @@ import { Title } from '../../components/Head'; import Button from '../Button'; import Icon from '../Icon'; import { fullPageLoadingIndicator } from '../LoadingIndicator'; +import AddressBook from './AddressBook'; import OrderSummary from './OrderSummary'; import PaymentInformation from './PaymentInformation'; import PriceAdjustments from './PriceAdjustments'; @@ -38,7 +39,9 @@ const CheckoutPage = props => { * Enum, one of: * SHIPPING_ADDRESS, SHIPPING_METHOD, PAYMENT, REVIEW */ + activeContent, checkoutStep, + customer, error, handleSignIn, handlePlaceOrder, @@ -58,7 +61,8 @@ const CheckoutPage = props => { setPaymentInformationDone, resetReviewOrderButtonClicked, handleReviewOrder, - reviewOrderButtonClicked + reviewOrderButtonClicked, + toggleActiveContent } = talonProps; const [, { addToast }] = useToasts(); @@ -85,7 +89,7 @@ const CheckoutPage = props => { const windowSize = useWindowSize(); const isMobile = windowSize.innerWidth <= 960; - let content; + let checkoutContent; if (isLoading) { return fullPageLoadingIndicator; } @@ -98,7 +102,7 @@ const CheckoutPage = props => { /> ); } else if (isCartEmpty) { - content = ( + checkoutContent = (

@@ -201,10 +205,17 @@ const CheckoutPage = props => { const guestCheckoutHeaderText = isGuestCheckout ? 'Guest Checkout' - : 'Review and Place Order'; + : customer.default_shipping + ? 'Review and Place Order' + : `Welcome ${customer.firstname}!`; - content = ( - + const checkoutContentClass = + activeContent === 'checkout' + ? classes.checkoutContent + : classes.checkoutContent_hidden; + + checkoutContent = ( +
{loginButton}

@@ -212,7 +223,10 @@ const CheckoutPage = props => {

- +
{shippingMethodSection} @@ -225,14 +239,22 @@ const CheckoutPage = props => { {itemsReview} {orderSummary} {placeOrderButton} - +
); } + const addressBookElement = !isGuestCheckout ? ( + + ) : null; + return (
{`Checkout - ${STORE_NAME}`} - {content} + {checkoutContent} + {addressBookElement}
); }; diff --git a/packages/venia-ui/lib/components/Region/region.js b/packages/venia-ui/lib/components/Region/region.js index 3f94464558..89c3ca79fb 100644 --- a/packages/venia-ui/lib/components/Region/region.js +++ b/packages/venia-ui/lib/components/Region/region.js @@ -9,20 +9,28 @@ import TextInput from '../TextInput'; import defaultClasses from './region.css'; import { GET_REGIONS_QUERY } from './region.gql'; +/** + * Form component for Region that is seeded with backend data. + * + * @param {string} props.optionValueKey - Key to use for returned option values. In a future release, this will be removed and hard-coded to use "id" once GraphQL has resolved MC-30886. + */ const Region = props => { - const talonProps = useRegion({ - queries: { getRegionsQuery: GET_REGIONS_QUERY } - }); - const { regions } = talonProps; const { classes: propClasses, field, label, validate, initialValue, + optionValueKey, ...inputProps } = props; + const talonProps = useRegion({ + optionValueKey, + queries: { getRegionsQuery: GET_REGIONS_QUERY } + }); + const { regions } = talonProps; + const classes = mergeClasses(defaultClasses, propClasses); const regionProps = { field, @@ -49,7 +57,8 @@ export default Region; Region.defaultProps = { field: 'region', - label: 'State' + label: 'State', + optionValueKey: 'code' }; Region.propTypes = { @@ -58,6 +67,7 @@ Region.propTypes = { }), field: string, label: string, + optionValueKey: string, validate: func, initialValue: string }; diff --git a/packages/venia-ui/lib/components/TextInput/textInput.css b/packages/venia-ui/lib/components/TextInput/textInput.css index 1d9f685126..3968880594 100644 --- a/packages/venia-ui/lib/components/TextInput/textInput.css +++ b/packages/venia-ui/lib/components/TextInput/textInput.css @@ -1,3 +1,7 @@ .input { composes: input from '../Field/field.css'; } + +.input:disabled { + color: rgb(var(--venia-grey-darker)); +} diff --git a/packages/venia-ui/lib/util/apolloCache.js b/packages/venia-ui/lib/util/apolloCache.js index f4059f2230..fe751ea57b 100644 --- a/packages/venia-ui/lib/util/apolloCache.js +++ b/packages/venia-ui/lib/util/apolloCache.js @@ -7,6 +7,7 @@ export const MagentoGraphQLTypes = { BundleProduct: 'BundleProduct', Cart: 'Cart', ConfigurableProduct: 'ConfigurableProduct', + Customer: 'Customer', DownloadableProduct: 'DownloadableProduct', GiftCardProduct: 'GiftCardProduct', GroupedProduct: 'GroupedProduct', @@ -53,7 +54,10 @@ export const cacheKeyFromType = object => { : null; // Only maintain a single cart entry case MagentoGraphQLTypes.Cart: - return 'Cart'; + return MagentoGraphQLTypes.Cart; + // Only maintain single customer entry + case MagentoGraphQLTypes.Customer: + return MagentoGraphQLTypes.Customer; // Fallback to default handling. default: return defaultDataIdFromObject(object);