diff --git a/src/app/address/AddressSelect.spec.tsx b/src/app/address/AddressSelect.spec.tsx new file mode 100644 index 0000000000..094b8e72a7 --- /dev/null +++ b/src/app/address/AddressSelect.spec.tsx @@ -0,0 +1,130 @@ +import { createCheckoutService, CheckoutService } from '@bigcommerce/checkout-sdk'; +import { mount, ReactWrapper } from 'enzyme'; +import { noop } from 'lodash'; +import React from 'react'; + +import { CheckoutProvider } from '../checkout'; +import { getCheckout } from '../checkout/checkouts.mock'; +import { getStoreConfig } from '../config/config.mock'; +import { getCustomer } from '../customer/customers.mock'; +import { createLocaleContext, LocaleContext, LocaleContextType } from '../locale'; + +import { getAddress } from './address.mock'; +import StaticAddress from './staticAddress/StaticAddress'; +import AddressSelect from './AddressSelect'; + +describe('AddressSelect Component', () => { + let checkoutService: CheckoutService; + let localeContext: LocaleContextType; + let component: ReactWrapper; + + beforeEach(() => { + checkoutService = createCheckoutService(); + localeContext = createLocaleContext(getStoreConfig()); + + jest.spyOn(checkoutService.getState().data, 'getCheckout').mockReturnValue(getCheckout()); + jest.spyOn(checkoutService.getState().data, 'getConfig').mockReturnValue(getStoreConfig()); + }); + + it('renders `Enter Address` when there is no selected address ', () => { + component = mount( + + + + + + ); + + expect(component.find('#addressToggle').text()).toEqual('Enter a new address'); + }); + + it('renders static address when there is a selected address', () => { + component = mount( + + + + + + ); + + expect(component.find(StaticAddress).prop('address')).toEqual(getAddress()); + }); + + it('renders addresses menu when select component is clicked', () => { + component = mount( + + + + + + ); + + component.find('#addressToggle').simulate('click'); + const options = component.find('#addressDropdown li'); + + expect(options.first().text()).toEqual('Enter a new address'); + expect(options.find(StaticAddress).prop('address')).toEqual(getCustomer().addresses[0]); + }); + + it('triggers appropriate callbacks when an item is selected', () => { + const onSelectAddress = jest.fn(); + const onUseNewAddress = jest.fn(); + + component = mount( + + + + + + ); + + component.find('#addressToggle').simulate('click'); + component.find('#addressDropdown li:first-child a').simulate('click'); + + expect(onUseNewAddress).toHaveBeenCalled(); + + component.find('#addressDropdown li:last-child a').simulate('click'); + + expect(onSelectAddress).toHaveBeenCalledWith(getCustomer().addresses[0]); + }); + + it('doest not trigger onSelectAddress callback if same address is selected', () => { + const onSelectAddress = jest.fn(); + + component = mount( + + + + + + ); + + component.find('#addressToggle').simulate('click'); + component.find('#addressDropdown li:last-child a').simulate('click'); + + expect(onSelectAddress).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/address/AddressSelect.tsx b/src/app/address/AddressSelect.tsx new file mode 100644 index 0000000000..c93d476586 --- /dev/null +++ b/src/app/address/AddressSelect.tsx @@ -0,0 +1,107 @@ +import { Address, CustomerAddress } from '@bigcommerce/checkout-sdk'; +import { some } from 'lodash'; +import React, { Component, FunctionComponent, ReactNode } from 'react'; + +import { preventDefault } from '../common/dom'; +import { TranslatedString } from '../language'; +import { DropdownTrigger } from '../ui/dropdown'; + +import StaticAddress from './staticAddress/StaticAddress'; +import isEqualAddress from './util/isEqualAddress'; + +export interface AddressSelectProps { + addresses: CustomerAddress[]; + selectedAddress?: Address; + onSelectAddress(address: Address): void; + onUseNewAddress(currentAddress?: Address): void; +} + +class AddressSelect extends Component { + render(): ReactNode { + const { + addresses, + selectedAddress, + onUseNewAddress, + } = this.props; + + return ( +
+
+ onUseNewAddress(selectedAddress) } + selectedAddress={ selectedAddress } + /> + }> + + +
+
+ ); + } + + private onSelectAddress: (newAddress: Address) => void = (newAddress: Address) => { + const { + onSelectAddress, + selectedAddress, + } = this.props; + + if (!isEqualAddress(selectedAddress, newAddress)) { + onSelectAddress(newAddress); + } + }; +} + +const AddressSelectMenu: FunctionComponent = ({ + addresses, + onSelectAddress, + onUseNewAddress, + selectedAddress, +}) => ( + +); + +type AddressSelectButtonProps = Pick; + +const AddressSelectButton: FunctionComponent = ({ + selectedAddress, + addresses, +}) => ( + + { selectedAddress ? + : + + } + +); + +export default AddressSelect; diff --git a/src/app/address/CheckboxGroupFormField.tsx b/src/app/address/CheckboxGroupFormField.tsx new file mode 100644 index 0000000000..62d90f0940 --- /dev/null +++ b/src/app/address/CheckboxGroupFormField.tsx @@ -0,0 +1,75 @@ +import { FormFieldItem } from '@bigcommerce/checkout-sdk'; +import { getIn, FieldArray } from 'formik'; +import { difference, kebabCase, noop } from 'lodash'; +import React, { FunctionComponent, ReactNode } from 'react'; + +import { FormFieldContainer, FormFieldError } from '../ui/form'; + +import { DynamicFormFieldType } from './DynamicFormField'; +import DynamicInput from './DynamicInput'; +import MultiCheckboxControl from './MultiCheckboxControl'; + +export interface CheckboxGroupFormFieldProps { + name: string; + id: string; + label: ReactNode; + options: FormFieldItem[]; + onChange?(values: string[]): void; +} + +const CheckboxGroupFormField: FunctionComponent = ({ + label, + name, + id, + options, + onChange = noop, +}) => ( + ( + + { label } + { + const checkedValues: string[] = getIn(values, name) || []; + difference(options.map(({ value }) => value), checkedValues) + .forEach(val => push(val)); + + onChange(getIn(values, name)); + }} + onSelectedNone={ () => { + const checkedValues: string[] = getIn(values, name) || []; + checkedValues.forEach(() => pop()); + onChange(getIn(values, name)); + }} + /> + { + const checkedValues: string[] = getIn(values, name) || []; + const { value, checked } = e.target; + + if (checked) { + push(value); + } else { + remove(checkedValues.indexOf(value)); + } + + onChange(getIn(values, name)); + } } + fieldType={ DynamicFormFieldType.checkbox } + options={ options } + id={ id } + /> + + + )} + /> +); + +export default CheckboxGroupFormField; diff --git a/src/app/address/DynamicFormField.spec.tsx b/src/app/address/DynamicFormField.spec.tsx new file mode 100644 index 0000000000..82dc9bacdf --- /dev/null +++ b/src/app/address/DynamicFormField.spec.tsx @@ -0,0 +1,106 @@ +import { FormField as FormFieldType } from '@bigcommerce/checkout-sdk'; +import { mount, shallow } from 'enzyme'; +import { Formik } from 'formik'; +import React from 'react'; + +import { TranslatedString } from '../language'; +import { FormField } from '../ui/form'; + +import { getFormFields } from './formField.mock'; +import CheckboxGroupFormField from './CheckboxGroupFormField'; +import DynamicFormField, { DynamicFormFieldType } from './DynamicFormField'; +import DynamicInput from './DynamicInput'; + +describe('DynamicFormField Component', () => { + const formFields = getFormFields(); + const onChange = jest.fn(); + + it('renders legacy class name', () => { + const component = shallow( + name === 'address1') as FormFieldType } + /> + ); + + expect(component.find('.dynamic-form-field').prop('className')) + .toContain('dynamic-form-field--addressLine1'); + }); + + it('renders FormField with expected props', () => { + const component = mount( + + name === 'address1') as FormFieldType } + /> + + ); + + expect(component.find(FormField).props()) + .toEqual(expect.objectContaining({ + onChange, + name: 'address1', + })); + }); + + it('renders DynamicInput with expected props', () => { + const component = mount( + + name === 'address1') as FormFieldType } + /> + + ); + + expect(component.find('.dynamic-form-field').prop('className')) + .toContain('dynamic-form-field--addressLine1'); + + expect(component.find(DynamicInput).props()) + .toEqual(expect.objectContaining({ + autoComplete: 'address-line1', + id: 'addressLine1Input', + })); + }); + + it('renders CheckboxGroupFormField if fieldType is checkbox', () => { + const component = mount( + + name === 'field_27') as FormFieldType } + fieldType={ DynamicFormFieldType.checkbox } + /> + + ); + + expect(component.find(CheckboxGroupFormField).length).toEqual(1); + }); + + it('renders label', () => { + const component = mount( + + name === 'address1') as FormFieldType } + /> + + ); + + expect(component.find(TranslatedString).prop('id')) + .toEqual('address.address_line_1_label'); + + expect(component.find('.optimizedCheckout-contentSecondary').length).toEqual(0); + }); + + it('renders `optional` label when field is not required', () => { + const component = mount( + + name === 'address2') as FormFieldType } + /> + + ); + + expect(component.find('.optimizedCheckout-contentSecondary').find(TranslatedString).prop('id')) + .toEqual('common.optional_text'); + }); +}); diff --git a/src/app/address/DynamicFormField.tsx b/src/app/address/DynamicFormField.tsx new file mode 100644 index 0000000000..29c6b144d4 --- /dev/null +++ b/src/app/address/DynamicFormField.tsx @@ -0,0 +1,138 @@ +import { FormField as FormFieldType } from '@bigcommerce/checkout-sdk'; +import React, { FunctionComponent } from 'react'; + +import { TranslatedString } from '../language'; +import { FormField, Label } from '../ui/form'; + +import { getFormFieldInputId, getFormFieldLegacyName } from './util/getFormFieldInputId'; +import CheckboxGroupFormField from './CheckboxGroupFormField'; +import DynamicInput from './DynamicInput'; + +export enum DynamicFormFieldType { + telephone = 'tel', + dropdown = 'dropdown', + number = 'number', + password = 'password', + checkbox = 'checkbox', + multiline = 'multiline', + date = 'date', + radio = 'radio', + text = 'text', +} + +export interface AddressKeyMap { + [fieldName: string]: T; +} + +const LABEL: AddressKeyMap = { + address1: 'address.address_line_1_label', + address2: 'address.address_line_2_label', + city: 'address.city_label', + company: 'address.company_name_label', + countryCode: 'address.country_label', + firstName: 'address.first_name_label', + lastName: 'address.last_name_label', + phone: 'address.phone_number_label', + postalCode: 'address.postal_code_label', + stateOrProvince: 'address.state_label', + stateOrProvinceCode: 'address.state_label', +}; + +const AUTOCOMPLETE: AddressKeyMap = { + address1: 'address-line1', + address2: 'address-line2', + city: 'address-level2', + company: 'organization', + countryCode: 'country', + firstName: 'given-name', + lastName: 'family-name', + phone: 'tel', + postalCode: 'postal-code', + stateOrProvince: 'address-level1', + stateOrProvinceCode: 'address-level1', +}; + +export interface DynamicFormFieldOption { + code: string; + name: string; +} + +export interface DynamicFormFieldProps { + field: FormFieldType; + parentFieldName?: string; + placeholder?: string; + fieldType?: DynamicFormFieldType; + onChange?(value: string | string[]): void; +} + +const DynamicFormField: FunctionComponent = ({ + field: { + name, + label: fieldLabel, + custom, + required, + options, + max, + min, + maxLength, + }, + fieldType, + parentFieldName, + onChange, + placeholder, +}) => { + const addressFieldName = name; + const fieldInputId = getFormFieldInputId(addressFieldName); + const fieldName = parentFieldName ? `${parentFieldName}.${name}` : name; + const translatedLabelString = LABEL[name]; + const label = ( + + ); + + return ( +
+ { fieldType === DynamicFormFieldType.checkbox ? + : + label } + input={ props => + + } + /> + } +
+ ); +}; + +export default DynamicFormField; diff --git a/src/app/address/DynamicInput.spec.tsx b/src/app/address/DynamicInput.spec.tsx new file mode 100644 index 0000000000..bb441b0dad --- /dev/null +++ b/src/app/address/DynamicInput.spec.tsx @@ -0,0 +1,137 @@ +import { shallow } from 'enzyme'; +import React from 'react'; +import ReactDatePicker from 'react-datepicker'; + +import { CheckboxInput, RadioInput, TextArea, TextInput } from '../ui/form'; + +import { DynamicFormFieldType } from './DynamicFormField'; +import DynamicInput from './DynamicInput'; + +describe('DynamicInput', () => { + it('renders text input for default input with passed props', () => { + expect(shallow().html()) + .toMatchSnapshot(); + }); + + it('renders textarea for multiline type', () => { + expect(shallow( + ) + .find(TextArea) + .prop('rows')) + .toEqual(4); + }); + + it('renders date picker for date type', () => { + expect(shallow( + ) + .find(ReactDatePicker) + .length).toEqual(1); + }); + + it('renders checkbox input for checbox type', () => { + const component = shallow( + ); + + expect(component.find(CheckboxInput).at(0).props()) + .toEqual(expect.objectContaining({ + id: 'id-x', + checked: false, + })); + + expect(component.find(CheckboxInput).at(1).props()) + .toEqual(expect.objectContaining({ + id: 'id-y', + checked: true, + })); + }); + + it('renders radio type input for radio type', () => { + const component = shallow( + ); + + expect(component.find(RadioInput).at(0).props()) + .toEqual(expect.objectContaining({ + id: 'id-x', + checked: false, + })); + + expect(component.find(RadioInput).at(1).props()) + .toEqual(expect.objectContaining({ + id: 'id-y', + checked: true, + })); + }); + + it('renders number type input for number type', () => { + expect(shallow( + ) + .find(TextInput) + .prop('type')) + .toEqual('number'); + }); + + it('renders password type input for password type', () => { + expect(shallow( + ) + .find(TextInput) + .prop('type')) + .toEqual('password'); + }); + + it('renders tel type input for phone type', () => { + expect(shallow( + ) + .find(TextInput) + .prop('type')) + .toEqual('tel'); + }); + + it('renders select input with passed props', () => { + expect(shallow( + ).html()) + .toMatchSnapshot(); + }); +}); diff --git a/src/app/address/DynamicInput.tsx b/src/app/address/DynamicInput.tsx new file mode 100644 index 0000000000..8642c1acdb --- /dev/null +++ b/src/app/address/DynamicInput.tsx @@ -0,0 +1,140 @@ +import { FormFieldItem } from '@bigcommerce/checkout-sdk'; +import { isDate } from 'lodash'; +import React, { FunctionComponent } from 'react'; +import ReactDatePicker from 'react-datepicker'; + +import { CheckboxInput, InputProps, RadioInput, TextArea, TextInput } from '../ui/form'; + +import { DynamicFormFieldType } from './DynamicFormField'; + +export interface DynamicInputProps extends InputProps { + id: string; + additionalClassName?: string; + value?: string | string[]; + rows?: number; + fieldType?: DynamicFormFieldType; + options?: FormFieldItem[]; +} + +const DynamicInput: FunctionComponent = ({ + additionalClassName, + fieldType, + options, + placeholder, + value, + id, + ...rest +}) => { + switch (fieldType) { + case DynamicFormFieldType.dropdown: + return ( + + ); + + case DynamicFormFieldType.radio: + if (!options || !options.length) { + return null; + } + + return <>{ options.map(({ label, value: optionValue }) => + ) }; + + case DynamicFormFieldType.checkbox: + if (!options || !options.length) { + return null; + } + + return <>{ options.map(({ label, value: optionValue }) => + ) }; + + case DynamicFormFieldType.date: + return ( + rest.onChange && rest.onChange({ + ...event, + target: { + name: rest.name, + value: date, + }, + } as any) + } + autoComplete="off" + placeholderText="MM/DD/YYYY" + minDate={ rest.min ? new Date(rest.min) : undefined } + maxDate={ rest.max ? new Date(rest.max) : undefined } + className="form-input optimizedCheckout-form-input" + popperClassName="optimizedCheckout-contentPrimary" + calendarClassName="optimizedCheckout-contentPrimary" + selected={ isDate(value) ? value : undefined } + /> + ); + + case DynamicFormFieldType.multiline: + return ( +