Implement dropdown for province/country area selection based on allowedFields from the API (#2833)

* Dropdown for province / country area

* Remove unused ref

* Implementation for settings and warehouses

* Disable autocomplete
This commit is contained in:
Patryk Andrzejewski 2022-12-21 09:54:45 +01:00 committed by GitHub
parent 3669f45061
commit 6db5dfa94a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 312 additions and 63 deletions

View file

@ -13,6 +13,7 @@ import FormSpacer from "../FormSpacer";
import SingleAutocompleteSelectField, {
SingleAutocompleteChoiceType,
} from "../SingleAutocompleteSelectField";
import { useAddressValidation } from "./useAddressValidation";
const useStyles = makeStyles(
theme => ({
@ -46,6 +47,24 @@ function getErrorMessage(
return getOrderErrorMessage(err, intl);
}
const PossibleFormFields = {
CITY: "city",
CITY_AREA: "cityArea",
COUNTRY: "country",
COUNTRY_AREA: "countryArea",
FIRST_NAME: "firstName",
LAST_NAME: "lastName",
COMPANY_NAME: "companyName",
PHONE: "phone",
POSTAL_CODE: "postalCode",
STREET_ADDRESS_1: "streetAddress1",
STREET_ADDRESS_2: "streetAddress2",
} as const;
const formFields: Array<keyof AddressTypeInput> = Object.values(
PossibleFormFields,
);
const AddressEdit: React.FC<AddressEditProps> = props => {
const {
countries,
@ -56,23 +75,9 @@ const AddressEdit: React.FC<AddressEditProps> = props => {
onChange,
onCountryChange,
} = props;
const classes = useStyles(props);
const intl = useIntl();
const formFields: Array<keyof AddressTypeInput> = [
"city",
"cityArea",
"country",
"countryArea",
"firstName",
"lastName",
"companyName",
"phone",
"postalCode",
"streetAddress1",
"streetAddress2",
];
const { areas, isFieldAllowed } = useAddressValidation(data.country);
const formErrors = getFormErrors<
keyof AddressTypeInput,
@ -219,7 +224,6 @@ const AddressEdit: React.FC<AddressEditProps> = props => {
<TextField
disabled={disabled}
error={!!formErrors.postalCode}
helperText={getErrorMessage(formErrors.postalCode, intl)}
label={intl.formatMessage({
id: "oYGfnY",
defaultMessage: "ZIP / Postal code",
@ -241,6 +245,7 @@ const AddressEdit: React.FC<AddressEditProps> = props => {
<div>
<SingleAutocompleteSelectField
disabled={disabled}
autocomplete="new-password"
data-test-id="address-edit-country-select-field"
displayValue={countryDisplayValue}
error={!!formErrors.country}
@ -259,23 +264,27 @@ const AddressEdit: React.FC<AddressEditProps> = props => {
/>
</div>
<div>
<TextField
disabled={disabled}
error={!!formErrors.countryArea}
helperText={getErrorMessage(formErrors.countryArea, intl)}
label={intl.formatMessage({
id: "AuwpCm",
defaultMessage: "Country area",
})}
name="countryArea"
onChange={onChange}
value={data.countryArea}
fullWidth
InputProps={{
autoComplete: "new-password",
spellCheck: false,
}}
/>
{isFieldAllowed(PossibleFormFields.COUNTRY_AREA) && (
<SingleAutocompleteSelectField
disabled={disabled}
autocomplete="new-password"
data-test-id="address-edit-country-area-field"
displayValue={data.countryArea}
error={!!formErrors.countryArea}
helperText={getErrorMessage(formErrors.countryArea, intl)}
label={intl.formatMessage({
id: "AuwpCm",
defaultMessage: "Country area",
})}
name="countryArea"
onChange={onChange}
value={data.countryArea}
choices={areas}
InputProps={{
spellCheck: false,
}}
/>
)}
</div>
</div>
</>

View file

@ -0,0 +1,20 @@
import { ChangeEvent } from "react";
import { createCountryHandler } from "./createCountryHandler";
describe("createCountryHandler", () => {
it("calls original country handler and restets the country area field", () => {
// Arrange
const originalCountryHandler = jest.fn();
const setFn = jest.fn();
const exampleEvent = { target: "some event" } as ChangeEvent<any>;
const newHandler = createCountryHandler(originalCountryHandler, setFn);
// Act
newHandler(exampleEvent);
// Assert
expect(originalCountryHandler).toBeCalledWith(exampleEvent);
expect(setFn).toBeCalledWith({ countryArea: "" });
});
});

View file

@ -0,0 +1,11 @@
import { AddressTypeInput } from "@saleor/customers/types";
import { FormChange } from "@saleor/hooks/useForm";
import { ChangeEvent } from "react";
export const createCountryHandler = (
currentHandler: FormChange,
set: (dataSet: Partial<AddressTypeInput>) => void,
) => (event: ChangeEvent<any>) => {
currentHandler(event);
set({ countryArea: "" });
};

View file

@ -0,0 +1,13 @@
import { gql } from "@apollo/client";
export const addressValidationQuery = gql`
query addressValidationRules($countryCode: CountryCode!) {
addressValidationRules(countryCode: $countryCode) {
countryAreaChoices {
raw
verbose
}
allowedFields
}
}
`;

View file

@ -0,0 +1,65 @@
import { useAddressValidationRulesQuery } from "@saleor/graphql";
import { renderHook } from "@testing-library/react-hooks";
import { useAddressValidation } from "./useAddressValidation";
jest.mock("@saleor/graphql", () => ({
CountryCode: jest.requireActual("@saleor/graphql").CountryCode,
useAddressValidationRulesQuery: jest.fn(),
}));
describe("useAddressValidation", () => {
it("skips loading validation rules when country is not provided", () => {
// Arrange
(useAddressValidationRulesQuery as jest.Mock).mockReturnValue({
data: null,
loading: false,
});
// Act
const {
result: { current },
} = renderHook(() => useAddressValidation());
// Assert
expect(current.areas).toEqual([]);
expect(current.loading).toBeFalsy();
expect(useAddressValidationRulesQuery).toBeCalledWith({
skip: true,
variables: { countryCode: undefined },
});
expect(current.isFieldAllowed("country")).toBeFalsy();
});
it("loads validation rules when country is provided", () => {
// Arrange
(useAddressValidationRulesQuery as jest.Mock).mockReturnValue({
data: {
addressValidationRules: {
countryAreaChoices: [
{ raw: "AL", verbose: "Alabama" },
{ raw: "AN", verbose: "Ancona" },
],
allowedFields: ["country"],
},
},
loading: false,
});
// Act
const {
result: { current },
} = renderHook(() => useAddressValidation("US"));
// Assert
expect(current.areas).toEqual([
{ label: "Alabama", value: "Alabama" },
{ label: "Ancona", value: "Ancona" },
]);
expect(current.loading).toBeFalsy();
expect(useAddressValidationRulesQuery).toBeCalledWith({
skip: false,
variables: { countryCode: "US" },
});
expect(current.isFieldAllowed("country")).toBeTruthy();
expect(current.isFieldAllowed("countryArea")).toBeFalsy();
});
});

View file

@ -0,0 +1,58 @@
import {
AddressValidationRulesQuery,
CountryCode,
useAddressValidationRulesQuery,
} from "@saleor/graphql";
import { ChoiceValue } from "@saleor/sdk/dist/apollo/types";
const prepareChoices = (values: ChoiceValue[]) =>
values.map(v => ({
label: v.verbose,
value: v.verbose,
}));
const selectRules = (data: AddressValidationRulesQuery) =>
data
? data.addressValidationRules
: { countryAreaChoices: [], allowedFields: [] };
const useValidationRules = (country?: string) => {
const countryCode = CountryCode[country];
const { data, loading } = useAddressValidationRulesQuery({
variables: { countryCode },
skip: !countryCode,
});
return { data, loading };
};
const useAreas = (data: AddressValidationRulesQuery) => {
const rawChoices = selectRules(data).countryAreaChoices;
const choices = prepareChoices(rawChoices);
return choices;
};
const useAllowedFields = (data: AddressValidationRulesQuery) => {
const isAllowed = (fieldName: string) => {
if (!data) {
return false;
}
return selectRules(data).allowedFields.includes(fieldName);
};
return { isAllowed };
};
export const useAddressValidation = (country?: string) => {
const { data, loading } = useValidationRules(country);
const areas = useAreas(data);
const { isAllowed } = useAllowedFields(data);
return {
areas,
isFieldAllowed: isAllowed,
loading,
};
};

View file

@ -19,6 +19,8 @@ import getWarehouseErrorMessage from "@saleor/utils/errors/warehouse";
import React from "react";
import { IntlShape, useIntl } from "react-intl";
import { useAddressValidation } from "../AddressEdit/useAddressValidation";
export interface CompanyAddressFormProps {
countries: SingleAutocompleteChoiceType[];
data: AddressTypeInput;
@ -62,7 +64,7 @@ const CompanyAddressForm: React.FC<CompanyAddressFormProps> = props => {
onChange,
onCountryChange,
} = props;
const { areas, isFieldAllowed } = useAddressValidation(data.country);
const classes = useStyles(props);
const intl = useIntl();
@ -176,6 +178,7 @@ const CompanyAddressForm: React.FC<CompanyAddressFormProps> = props => {
<SingleAutocompleteSelectField
data-test-id="address-edit-country-select-field"
disabled={disabled}
autocomplete="new-password"
displayValue={displayCountry}
error={!!formErrors.country}
helperText={getErrorMessage(formErrors.country, intl)}
@ -189,25 +192,30 @@ const CompanyAddressForm: React.FC<CompanyAddressFormProps> = props => {
choices={countries}
InputProps={{
spellCheck: false,
autoComplete: "new-password",
}}
/>
<TextField
disabled={disabled}
error={!!formErrors.countryArea}
helperText={getErrorMessage(formErrors.countryArea, intl)}
label={intl.formatMessage({
id: "AuwpCm",
defaultMessage: "Country area",
})}
name={"countryArea" as keyof AddressTypeInput}
onChange={onChange}
value={data.countryArea}
fullWidth
InputProps={{
autoComplete: "address-level1",
spellCheck: false,
}}
/>
{isFieldAllowed("countryArea") && (
<SingleAutocompleteSelectField
disabled={disabled}
autocomplete="new-password"
data-test-id="address-edit-country-area-field"
displayValue={data.countryArea}
error={!!formErrors.countryArea}
helperText={getErrorMessage(formErrors.countryArea, intl)}
label={intl.formatMessage({
id: "AuwpCm",
defaultMessage: "Country area",
})}
name="countryArea"
onChange={onChange}
value={data.countryArea}
choices={areas}
InputProps={{
spellCheck: false,
}}
/>
)}
</Grid>
<FormSpacer />
<TextField

View file

@ -37,6 +37,7 @@ export interface SingleAutocompleteSelectFieldProps
helperText?: string;
label?: string;
InputProps?: InputProps;
autocomplete?: string,
fetchChoices?: (value: string) => void;
onChange: (event: React.ChangeEvent<any>) => void;
fetchOnFocus?: boolean;
@ -65,6 +66,7 @@ const SingleAutocompleteSelectFieldComponent: React.FC<SingleAutocompleteSelectF
label,
loading,
name,
autocomplete,
placeholder,
value,
InputProps,
@ -229,6 +231,7 @@ const SingleAutocompleteSelectFieldComponent: React.FC<SingleAutocompleteSelectF
inputProps={{
...(getInputProps({
placeholder,
autocomplete,
onClick: handleToggleMenu,
}) as OutlinedInputProps["inputProps"]),
}}

View file

@ -5,6 +5,7 @@ import {
DialogTitle,
} from "@material-ui/core";
import AddressEdit from "@saleor/components/AddressEdit";
import { createCountryHandler } from "@saleor/components/AddressEdit/createCountryHandler";
import BackButton from "@saleor/components/BackButton";
import ConfirmButton from "@saleor/components/ConfirmButton";
import Form from "@saleor/components/Form";
@ -94,13 +95,15 @@ const CustomerAddressDialog: React.FC<CustomerAddressDialogProps> = ({
maxWidth="sm"
>
<Form initial={initialForm} onSubmit={handleSubmit}>
{({ change, data }) => {
const handleCountrySelect = createSingleAutocompleteSelectHandler(
{({ change, set, data }) => {
const countrySelect = createSingleAutocompleteSelectHandler(
change,
setCountryDisplayName,
countryChoices,
);
const handleCountrySelect = createCountryHandler(countrySelect, set);
return (
<>
<DialogTitle>

View file

@ -1,3 +1,4 @@
import { createCountryHandler } from "@saleor/components/AddressEdit/createCountryHandler";
import { Backlink } from "@saleor/components/Backlink";
import { CardSpacer } from "@saleor/components/CardSpacer";
import Container from "@saleor/components/Container";
@ -143,13 +144,15 @@ const CustomerCreatePage: React.FC<CustomerCreatePageProps> = ({
onSubmit={handleSubmit}
disabled={disabled}
>
{({ change, data, isSaveDisabled, submit }) => {
const handleCountrySelect = createSingleAutocompleteSelectHandler(
{({ change, set, data, isSaveDisabled, submit }) => {
const countrySelect = createSingleAutocompleteSelectHandler(
change,
setCountryDisplayName,
countryChoices,
);
const handleCountrySelect = createCountryHandler(countrySelect, set);
return (
<Container>
<Backlink href={customerListUrl()}>

View file

@ -5039,6 +5039,45 @@ export function useCollectionDetailsLazyQuery(baseOptions?: ApolloReactHooks.Laz
export type CollectionDetailsQueryHookResult = ReturnType<typeof useCollectionDetailsQuery>;
export type CollectionDetailsLazyQueryHookResult = ReturnType<typeof useCollectionDetailsLazyQuery>;
export type CollectionDetailsQueryResult = Apollo.QueryResult<Types.CollectionDetailsQuery, Types.CollectionDetailsQueryVariables>;
export const AddressValidationRulesDocument = gql`
query addressValidationRules($countryCode: CountryCode!) {
addressValidationRules(countryCode: $countryCode) {
countryAreaChoices {
raw
verbose
}
allowedFields
}
}
`;
/**
* __useAddressValidationRulesQuery__
*
* To run a query within a React component, call `useAddressValidationRulesQuery` and pass it any options that fit your needs.
* When your component renders, `useAddressValidationRulesQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useAddressValidationRulesQuery({
* variables: {
* countryCode: // value for 'countryCode'
* },
* });
*/
export function useAddressValidationRulesQuery(baseOptions: ApolloReactHooks.QueryHookOptions<Types.AddressValidationRulesQuery, Types.AddressValidationRulesQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return ApolloReactHooks.useQuery<Types.AddressValidationRulesQuery, Types.AddressValidationRulesQueryVariables>(AddressValidationRulesDocument, options);
}
export function useAddressValidationRulesLazyQuery(baseOptions?: ApolloReactHooks.LazyQueryHookOptions<Types.AddressValidationRulesQuery, Types.AddressValidationRulesQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return ApolloReactHooks.useLazyQuery<Types.AddressValidationRulesQuery, Types.AddressValidationRulesQueryVariables>(AddressValidationRulesDocument, options);
}
export type AddressValidationRulesQueryHookResult = ReturnType<typeof useAddressValidationRulesQuery>;
export type AddressValidationRulesLazyQueryHookResult = ReturnType<typeof useAddressValidationRulesLazyQuery>;
export type AddressValidationRulesQueryResult = Apollo.QueryResult<Types.AddressValidationRulesQuery, Types.AddressValidationRulesQueryVariables>;
export const CheckIfOrderExistsDocument = gql`
query CheckIfOrderExists($id: ID!) {
order(id: $id) {

View file

@ -6670,6 +6670,13 @@ export type CollectionDetailsQueryVariables = Exact<{
export type CollectionDetailsQuery = { __typename: 'Query', collection: { __typename: 'Collection', slug: string, description: any | null, seoDescription: string | null, seoTitle: string | null, id: string, name: string, products: { __typename: 'ProductCountableConnection', edges: Array<{ __typename: 'ProductCountableEdge', node: { __typename: 'Product', id: string, name: string, productType: { __typename: 'ProductType', id: string, name: string }, thumbnail: { __typename: 'Image', url: string } | null, channelListings: Array<{ __typename: 'ProductChannelListing', isPublished: boolean, publicationDate: any | null, isAvailableForPurchase: boolean | null, availableForPurchase: any | null, visibleInListings: boolean, channel: { __typename: 'Channel', id: string, name: string, currencyCode: string } }> | null } }>, pageInfo: { __typename: 'PageInfo', endCursor: string | null, hasNextPage: boolean, hasPreviousPage: boolean, startCursor: string | null } } | null, backgroundImage: { __typename: 'Image', alt: string | null, url: string } | null, channelListings: Array<{ __typename: 'CollectionChannelListing', isPublished: boolean, publicationDate: any | null, channel: { __typename: 'Channel', id: string, name: string } }> | null, metadata: Array<{ __typename: 'MetadataItem', key: string, value: string }>, privateMetadata: Array<{ __typename: 'MetadataItem', key: string, value: string }> } | null };
export type AddressValidationRulesQueryVariables = Exact<{
countryCode: CountryCode;
}>;
export type AddressValidationRulesQuery = { __typename: 'Query', addressValidationRules: { __typename: 'AddressValidationData', allowedFields: Array<string>, countryAreaChoices: Array<{ __typename: 'ChoiceValue', raw: string | null, verbose: string | null }> } | null };
export type CheckIfOrderExistsQueryVariables = Exact<{
id: Scalars['ID'];
}>;

View file

@ -145,6 +145,7 @@ function useOrderCustomerAddressesEditForm(
value: {
...formData.shippingAddress,
[event.target.name]: event.target.value,
countryArea: "",
},
},
}),
@ -159,6 +160,7 @@ function useOrderCustomerAddressesEditForm(
value: {
...formData.billingAddress,
[event.target.name]: event.target.value,
countryArea: "",
},
},
}),

View file

@ -1,3 +1,4 @@
import { createCountryHandler } from "@saleor/components/AddressEdit/createCountryHandler";
import { Backlink } from "@saleor/components/Backlink";
import CompanyAddressInput from "@saleor/components/CompanyAddressInput";
import Container from "@saleor/components/Container";
@ -126,14 +127,16 @@ const SiteSettingsPage: React.FC<SiteSettingsPageProps> = props => {
confirmLeave
disabled={disabled}
>
{({ change, data, isSaveDisabled, submit }) => {
{({ change, data, set, isSaveDisabled, submit }) => {
const countryChoices = mapCountriesToChoices(shop?.countries || []);
const handleCountryChange = createSingleAutocompleteSelectHandler(
const countrySelect = createSingleAutocompleteSelectHandler(
change,
setDisplayCountry,
countryChoices,
);
const handleCountrySelect = createCountryHandler(countrySelect, set);
return (
<Container>
<Backlink href={configurationMenuUrl}>
@ -175,7 +178,7 @@ const SiteSettingsPage: React.FC<SiteSettingsPageProps> = props => {
description: "section header",
})}
onChange={change}
onCountryChange={handleCountryChange}
onCountryChange={handleCountrySelect}
/>
</Grid>
<Savebar

View file

@ -1,3 +1,4 @@
import { createCountryHandler } from "@saleor/components/AddressEdit/createCountryHandler";
import { Backlink } from "@saleor/components/Backlink";
import CardSpacer from "@saleor/components/CardSpacer";
import CompanyAddressInput from "@saleor/components/CompanyAddressInput";
@ -67,14 +68,16 @@ const WarehouseCreatePage: React.FC<WarehouseCreatePageProps> = ({
return (
<Form confirmLeave initial={initialForm} onSubmit={handleSubmit}>
{({ change, data, submit }) => {
{({ change, data, set, submit }) => {
const countryChoices = mapCountriesToChoices(countries);
const handleCountryChange = createSingleAutocompleteSelectHandler(
const countrySelect = createSingleAutocompleteSelectHandler(
change,
setDisplayCountry,
countryChoices,
);
const handleCountrySelect = createCountryHandler(countrySelect, set);
return (
<Container>
<Backlink href={warehouseListUrl()}>
@ -108,7 +111,7 @@ const WarehouseCreatePage: React.FC<WarehouseCreatePageProps> = ({
description: "warehouse",
})}
onChange={change}
onCountryChange={handleCountryChange}
onCountryChange={handleCountrySelect}
/>
</div>
</Grid>

View file

@ -1,3 +1,4 @@
import { createCountryHandler } from "@saleor/components/AddressEdit/createCountryHandler";
import { Backlink } from "@saleor/components/Backlink";
import CardSpacer from "@saleor/components/CardSpacer";
import CompanyAddressInput from "@saleor/components/CompanyAddressInput";
@ -93,11 +94,12 @@ const WarehouseDetailsPage: React.FC<WarehouseDetailsPageProps> = ({
>
{({ change, data, isSaveDisabled, submit, set }) => {
const countryChoices = mapCountriesToChoices(countries);
const handleCountryChange = createSingleAutocompleteSelectHandler(
const countrySelect = createSingleAutocompleteSelectHandler(
change,
setDisplayCountry,
countryChoices,
);
const handleCountrySelect = createCountryHandler(countrySelect, set);
return (
<Container>
@ -126,7 +128,7 @@ const WarehouseDetailsPage: React.FC<WarehouseDetailsPageProps> = ({
description: "warehouse",
})}
onChange={change}
onCountryChange={handleCountryChange}
onCountryChange={handleCountrySelect}
/>
</div>
<div>