Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -156,7 +156,7 @@ export const ResourceAddressFormFields =
</FieldRow>

<FieldRow columns="1">
<SelectCountry namePrefix={namePrefix} />
<SelectCountry countryCodeName={`${namePrefix}country_code`} />
</FieldRow>

<FieldRow columns="1">
Expand All @@ -168,7 +168,10 @@ export const ResourceAddressFormFields =

<FieldRow columns="1">
<div className="grid grid-cols-[2fr_1fr] gap-4">
<SelectStates namePrefix={namePrefix} />
<SelectStates
stateCodeName={`${namePrefix}state_code`}
countryCodeName={`${namePrefix}country_code`}
/>
<HookedInput
name={`${namePrefix}zip_code`}
label={t("resources.addresses.attributes.zip_code")}
Expand Down Expand Up @@ -225,7 +228,12 @@ const FieldRow = ({
return <Grid columns={columns}>{children}</Grid>
}

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()

Expand All @@ -239,15 +247,15 @@ const SelectCountry: React.FC<{ namePrefix: string }> = ({ namePrefix }) => {
if (forceTextInput) {
return (
<HookedInput
name={`${namePrefix}country_code`}
name={countryCodeName}
label={t("resources.addresses.attributes.country_code")}
/>
)
}

return (
<HookedInputSelect
name={`${namePrefix}country_code`}
name={countryCodeName}
label={t("resources.addresses.attributes.country_code")}
key={countries?.length}
initialValues={countries ?? []}
Expand All @@ -257,17 +265,20 @@ const SelectCountry: React.FC<{ namePrefix: string }> = ({ namePrefix }) => {
)
}

const SelectStates: React.FC<{ namePrefix: string }> = ({ namePrefix }) => {
const SelectStates: React.FC<{
stateCodeName: string
countryCodeName: string
}> = ({ stateCodeName, countryCodeName }) => {
const [states, setStates] = useState<InputSelectValue[] | undefined>()
const { watch, setValue } =
useFormContext<
z.infer<ReturnType<typeof getResourceAddressFormFieldsSchema>>
>()
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)) {
Expand All @@ -277,9 +288,15 @@ const SelectStates: React.FC<{ namePrefix: string }> = ({ namePrefix }) => {
.then<InputSelectValue[]>(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(() => {
Expand All @@ -290,25 +307,37 @@ const SelectStates: React.FC<{ namePrefix: string }> = ({ namePrefix }) => {
}, [countryCode])

if (
countryCode == null ||
!countryWithStates.includes(countryCode) ||
states?.length === 0 ||
forceTextInput
) {
return (
<HookedInput
name={`${namePrefix}state_code`}
name={stateCodeName}
label={t("resources.addresses.attributes.state_code")}
/>
)
}

// 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 (
<HookedInputSelect
name={`${namePrefix}state_code`}
name={stateCodeName}
label={t("resources.addresses.attributes.state_code")}
key={`${countryCode}_${states?.length}`}
initialValues={states ?? []}
initialValues={options}
pathToValue="value"
isCreatable
isLoading={states == null}
/>
)
Expand Down
21 changes: 21 additions & 0 deletions packages/docs/src/stories/resources/ResourceAddress.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Stack>
<ResourceAddress
address={presetAddresses.withCustomState}
title="Shipping address"
editable
onUpdate={(updatedAddress) => {
console.log("updated address", updatedAddress)
}}
/>
</Stack>
)
}

export const HookedModal: StoryFn = () => {
const [address, setAddress] = useState(presetAddresses.withName)

Expand Down
Loading