diff --git a/packages/app-elements/src/ui/resources/ResourceAddress/ResourceAddress.mocks.ts b/packages/app-elements/src/ui/resources/ResourceAddress/ResourceAddress.mocks.ts index 717593783..02ca0fca0 100644 --- a/packages/app-elements/src/ui/resources/ResourceAddress/ResourceAddress.mocks.ts +++ b/packages/app-elements/src/ui/resources/ResourceAddress/ResourceAddress.mocks.ts @@ -60,6 +60,28 @@ export const presetAddresses = { updated_at: "", notes: "Kindly leave the package to my neighbor, Adam Sandler.", }, + withCustomState: { + type: "addresses", + id: "eeZYuDJVXW", + business: false, + company: "", + first_name: "Obi-Wan", + last_name: "Kenobi", + full_name: "Obi-Wan Kenobi", + email: "kenobi@rebellion.com", + line_1: "Via Polis Massa, 42", + line_2: "", + city: "Cogorno", + country_code: "IT", + // not a province code from the IT list: a value typed by hand that the form + // has to keep showing rather than drop on the floor + state_code: "Genova", + zip_code: "16030", + phone: "+39 055 1234567890", + billing_info: "ABCDEFGHIJKLMNOPQRSTUVWYXZ", + created_at: "", + updated_at: "", + }, withErrors: { type: "addresses", id: "ddZYuDJVXW", diff --git a/packages/app-elements/src/ui/resources/ResourceAddress/ResourceAddressFormFields.tsx b/packages/app-elements/src/ui/resources/ResourceAddress/ResourceAddressFormFields.tsx index 756c61182..2e9118dd0 100644 --- a/packages/app-elements/src/ui/resources/ResourceAddress/ResourceAddressFormFields.tsx +++ b/packages/app-elements/src/ui/resources/ResourceAddress/ResourceAddressFormFields.tsx @@ -1,4 +1,4 @@ -import React, { type JSX, useEffect, useState } from "react" +import React, { type JSX, useEffect, useRef, useState } from "react" import { useFormContext } from "react-hook-form" import { z } from "zod" import { t } from "#providers/I18NProvider" @@ -156,7 +156,7 @@ export const ResourceAddressFormFields = - + @@ -168,7 +168,10 @@ export const ResourceAddressFormFields =
- + {children} } -const SelectCountry: React.FC<{ namePrefix: string }> = ({ namePrefix }) => { +// The two selects take their field paths already resolved, rather than a prefix +// to reapply: a path is then built in exactly one place, and no string inside +// these components can quietly miss the namespace the way `state_code` once did. +const SelectCountry: React.FC<{ countryCodeName: string }> = ({ + countryCodeName, +}) => { const [forceTextInput, setForceTextInput] = useState(false) const { countries, isLoading, error } = useCountryList() @@ -239,7 +247,7 @@ const SelectCountry: React.FC<{ namePrefix: string }> = ({ namePrefix }) => { if (forceTextInput) { return ( ) @@ -247,7 +255,7 @@ const SelectCountry: React.FC<{ namePrefix: string }> = ({ namePrefix }) => { return ( = ({ namePrefix }) => { ) } -const SelectStates: React.FC<{ namePrefix: string }> = ({ namePrefix }) => { +const SelectStates: React.FC<{ + stateCodeName: string + countryCodeName: string +}> = ({ stateCodeName, countryCodeName }) => { const [states, setStates] = useState() - const { watch, setValue } = - useFormContext< - z.infer> - >() + const { watch, setValue, getValues } = useFormContext() const [forceTextInput, setForceTextInput] = useState(false) - const countryCode = watch("country_code") - const stateCode = watch("state_code") + const countryCode: string | undefined = watch(countryCodeName) + const stateCode: string | undefined = watch(stateCodeName) const countryWithStates = ["US", "IT"] + // The country the form opened on: a state that isn't in its list is a custom + // one the address already carries, not a leftover to be wiped. + const initialCountryCode = useRef(countryCode) useEffect(() => { if (countryCode != null && countryWithStates.includes(countryCode)) { @@ -277,9 +288,15 @@ const SelectStates: React.FC<{ namePrefix: string }> = ({ namePrefix }) => { .then(async (res) => await res.json()) .then((data) => { setStates(data) - if (data.find(({ value }) => value === stateCode) == null) { - // reset state_code if not found in the list - setValue("state_code", "") + // read at response time: the effect's closure predates whatever the + // user may have typed while the list was in flight + const currentStateCode: string | undefined = getValues(stateCodeName) + if ( + countryCode !== initialCountryCode.current && + data.find(({ value }) => value === currentStateCode) == null + ) { + // the country changed under it, so the previous state no longer applies + setValue(stateCodeName, "") } }) .catch(() => { @@ -290,25 +307,37 @@ const SelectStates: React.FC<{ namePrefix: string }> = ({ namePrefix }) => { }, [countryCode]) if ( + countryCode == null || !countryWithStates.includes(countryCode) || states?.length === 0 || forceTextInput ) { return ( ) } + // A custom state keeps its place in the list, so it shows as the selected + // value and stays selectable after the user browses the other options. + const options = + states != null && + stateCode != null && + stateCode !== "" && + states.find(({ value }) => value === stateCode) == null + ? [{ value: stateCode, label: stateCode }, ...states] + : (states ?? []) + return ( ) diff --git a/packages/docs/src/stories/resources/ResourceAddress.stories.tsx b/packages/docs/src/stories/resources/ResourceAddress.stories.tsx index 8cbaabb3d..b6603aa55 100644 --- a/packages/docs/src/stories/resources/ResourceAddress.stories.tsx +++ b/packages/docs/src/stories/resources/ResourceAddress.stories.tsx @@ -141,6 +141,27 @@ export const ApiError: StoryFn = () => { ) } +/** + * An address can carry a state the official list doesn't know — one typed by + * hand, or imported from elsewhere. Editing it keeps that value as the selected + * one, and the select stays creatable: pick a province from the list, or type + * another custom value and keep it. + */ +export const CustomState: StoryFn = () => { + return ( + + { + console.log("updated address", updatedAddress) + }} + /> + + ) +} + export const HookedModal: StoryFn = () => { const [address, setAddress] = useState(presetAddresses.withName)