From ebc3ffab418cc9aacfda06b241a33c3f45b65277 Mon Sep 17 00:00:00 2001 From: ledouxm Date: Tue, 17 Dec 2024 15:00:08 +0100 Subject: [PATCH] refactor: address autocomplete using xstate --- packages/frontend/package.json | 2 + .../src/components/SmartAddressInput.tsx | 341 +++++++++++++----- packages/frontend/src/features/address.tsx | 4 +- pnpm-lock.yaml | 48 ++- 4 files changed, 305 insertions(+), 90 deletions(-) diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 82b4857..e4bfe6e 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -48,6 +48,7 @@ "@tiptap/react": "^2.4.0", "@tiptap/starter-kit": "^2.4.0", "@ungap/with-resolvers": "^0.1.0", + "@xstate/react": "^5.0.0", "@xstate/store": "^0.0.5", "bowser": "^2.11.0", "browser-image-compression": "^2.0.2", @@ -71,6 +72,7 @@ "uuid": "^9.0.1", "vite-plugin-wasm": "^3.3.0", "wa-sqlite": "github:rhashimoto/wa-sqlite", + "xstate": "^5.19.0", "zod": "^3.22.4" }, "imports": { diff --git a/packages/frontend/src/components/SmartAddressInput.tsx b/packages/frontend/src/components/SmartAddressInput.tsx index a2cf726..9ebe76c 100644 --- a/packages/frontend/src/components/SmartAddressInput.tsx +++ b/packages/frontend/src/components/SmartAddressInput.tsx @@ -4,88 +4,99 @@ import { fr } from "@codegouvfr/react-dsfr"; import Badge from "@codegouvfr/react-dsfr/Badge"; import Input from "@codegouvfr/react-dsfr/Input"; import { useQuery } from "@tanstack/react-query"; -import { useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useFormContext, useWatch } from "react-hook-form"; -import { useDebounce } from "react-use"; +import { useClickAway, useDebounce } from "react-use"; import { Report } from "../db/AppSchema"; -import { AddressResult, searchAddress } from "../features/address"; +import { AddressResult, AddressSuggestion, searchAddress } from "../features/address"; import { useIsFormDisabled } from "../features/DisabledContext"; import { Combobox } from "./Combobox"; +import { fromPromise, setup } from "xstate"; +import { useMachine } from "@xstate/react"; export const SmartAddressInput = () => { const form = useFormContext(); const isFormDisabled = useIsFormDisabled(); + const wrapperRef = useRef(null); - const [isFrozen, setIsFrozen] = useState(true); + const [state, send] = useMachine(addressMachine, {}); - const applicantAddress = useWatch({ control: form.control, name: "applicantAddress" }); - const prevValueRef = useRef(applicantAddress); - - const [debouncedAddress, setDebouncedAddress] = useState(applicantAddress); - - useDebounce(() => setDebouncedAddress(applicantAddress), 500, [applicantAddress]); - - const isEnabled = !isFormDisabled && debouncedAddress && debouncedAddress.length > 4 && !isFrozen; - - const addressQuery = useQuery({ - queryKey: ["address", debouncedAddress], - queryFn: () => searchAddress(debouncedAddress!), - enabled: !!isEnabled, + useClickAway(wrapperRef, () => { + send({ type: "BLUR" }); }); - const isLoading = addressQuery.isLoading && isEnabled; - const suggestions = addressQuery.data; + const isOpen = state.matches("suggesting") || state.matches("error"); + const isLoading = state.matches("fetching"); + const suggestions = state.context.suggestions; + + const inputProps = form.register("applicantAddress"); return ( - (item as AddressResult).address ?? ""} - itemToValue={(item) => (item as AddressResult).label ?? ""} - items={suggestions ?? []} - value={applicantAddress ? [applicantAddress.toString()] : undefined} - inputValue={applicantAddress ?? ""} - onBlur={() => { - if (prevValueRef.current) { - form.setValue("applicantAddress", prevValueRef.current); + + + Adresse (numéro, voie) + {isLoading ? ( + + + + ) : null} + } - }} - onInputValueChange={(e) => { - prevValueRef.current = applicantAddress; - form.setValue("applicantAddress", e.value); - setIsFrozen(false); - }} - onValueChange={(e) => { - if (e.items?.length === 0) return; - prevValueRef.current = null; - form.setValue("applicantAddress", (e.items?.[0] as AddressResult)?.address ?? ""); - form.setValue("zipCode", (e.items?.[0] as AddressResult)?.zipCode ?? ""); - form.setValue("city", (e.items?.[0] as AddressResult)?.city ?? ""); - setIsFrozen(true); - }} - > - - - - - {/* - - */} - - - - - {suggestions?.length - ? suggestions.map((item: AddressResult) => ( - - {item.label} - - )) - : null} - - - - + disabled={isFormDisabled} + nativeInputProps={{ + autoComplete: "new-password", + ...inputProps, + onChange: (e) => { + inputProps.onChange(e); + send({ type: "TYPE", value: e.target.value }); + }, + onFocus: () => send({ type: "FOCUS" }), + }} + /> + + {isOpen ? ( + + {isLoading ? null : suggestions.length === 0 ? ( + "Aucun résultat" + ) : ( + + {suggestions.map((item) => ( + { + form.setValue("applicantAddress", item?.address ?? ""); + form.setValue("zipCode", item?.zipCode ?? ""); + form.setValue("city", item?.city ?? ""); + + send({ type: "SELECT", address: item }); + }} + p="8px" + cursor="pointer" + _hover={{ bg: "white" }} + > + {item.label} + + ))} + + )} + + ) : ( + + )} + {isLoading ? ( @@ -95,25 +106,6 @@ export const SmartAddressInput = () => { ); }; -const ProxyInput = ({ disabled, isLoading, ...props }: any) => { - return ( - - Adresse (numéro, voie) - {isLoading ? ( - - - - ) : null} - - } - disabled={disabled} - nativeInputProps={{ ...props, autoComplete: "new-password" }} - /> - ); -}; - const LoadingBadge = () => { return ( { ); }; + +const addressMachine = setup({ + types: { + context: {} as { + query: string; + suggestions: AddressSuggestion[]; + error?: string; + selectedAddress?: AddressSuggestion; + }, + events: {} as + | { type: "TYPE"; value: string } + | { type: "CLEAR" } + | { type: "SELECT"; address: string } + | { type: "FETCH.SUCCESS"; suggestions: string[] } + | { type: "FETCH.ERROR"; error: string } + | { type: "BLUR" } + | { type: "FOCUS" }, + }, + guards: { + hasMinLength: ({ context }) => context.query.length >= 3, + }, + actions: { + updateQuery: ({ context, event }) => { + if (event.type === "TYPE") { + context.query = event.value; + } + }, + clearQuery: ({ context }) => { + context.query = ""; + context.suggestions = []; + context.selectedAddress = undefined; + }, + updateSuggestions: ({ context, event }) => { + // @ts-ignore + context.suggestions = event.output; + }, + selectAddress: ({ context, event }) => { + if (event.type === "SELECT") { + context.selectedAddress = event.address; + context.query = event.address; + } + }, + setError: ({ context, event }) => { + if (event.type === "FETCH.ERROR") { + context.error = event.error; + } + }, + }, + actors: { + fetchSuggestions: fromPromise(async ({ input }: { input: { query: string } }) => { + const suggestions = await searchAddress(input.query); + return suggestions; + }), + }, +}).createMachine({ + /** @xstate-layout N4IgpgJg5mDOIC5QEMIQE51gQQK4BcB7AY0IFsAHAGzHzADoBLCGgYgBUBNABQFEBtAAwBdRKAqFYjfI0IA7MSAAeiAIwBmDfQAcqgEwBWTQE5jAdm3qALHoA0IAJ6IAbFef0rq587Nn1fg31VAF9g+1QMLDwiUkoaOiYWMFYAMQB5AGEAVQBlIVEkEAkpGXlFFQQNLV1DE3NLG3snBEt3PWMvMz1BI1UrG1DwtExYHAIScmpaBgAzElxYSA4eARFFYulZBUKK1QMzY3pBCz3tQStjQUMmxFb6A09vIzMDZ17BkAiRsZjJ+Nn5osIKwAEIAGSyACV8utJJsyjs1OofPRnIZBJpnKozrobggLAZ6AdOpjLm4rB8vlFxrEpglIJs5FBlnwYYUNqVtqBdgcrPcTt5nJc9KozHjtM5BPR1AZtOZJa9BKLjJThtTfnFpvQGTImawMmDeNhoWt2XDOeU1FjDuiMVZ-KoMWYrHjjOp1ESOj4MSKzBjtKrIqNohNNfSIIzmeCoWzxOatpbKt4pQ8hX0McqjHjHZojo8XnofC8rAZA98Q7T-tqI7rmbGivGEdy1PtDsddLLzsKDHjDKp6LbVMYziK9P0A2FPmrgzS-lqdYw9fxVAU4yUE4ik1cdPoHuo9C9tAZro5EAcU-nh25nqXJ1SZxq6QwIGAAEaEXByYiL5lcVmmtd4S5ZRED0fcBxqC5JSPHxh3FfsDEEJDjjRQRnE0QIy3VUMn3oF930-b89WjE1VwbdcmxAhAwL0CD9CgwQYIObQ8WRQ4XiQ3dBW0PQxywh8cKrfCPy-H9WCUWB8GQBJkBmOh0AACkQwQAEpWHvH5BK1YTCJ-esOQ3ZtqPAvRIOMaDXmY10sSOSxND9LErD8Zx+M0ystRmWhiAACzEiB5AYRcADdCAAawYDSKznBJPPwHyfwQYKSGkrZ8n0xtgIqGi6LHczGMsuDT0qJDtHuOzHgeHEXLvac3Oi2YvN8vUwHQdBCHQehqGkuZ0DIehItnMMGripqoESuQQuIFL5DSgDyKAxMng8ZxtDMIUHl9QJXUCeh6NMP1TOPXjXKiob6Fi+K9T-VYyIMyiKiWtxVvWmxRS2oqNCPWz3WPN1TIPE7Btwi7RtBCFSNhCjMpcN5lue4wNre1Q8QeftkKVRjjH+jpAcfKtYFwKAYEksTrvSqHE2y0y9os2CWI+nwPXdH6LAsVbfFxrSEgJom4FrVgcl4Q0MnYcmFs3KmzNpqyPsdWi7P3DEOkMbQKRqoM6rOnnif5kixYtCWTKl-K6bxGwpUHPpEIsJVqqGDXTtw7W+bEg0jQhs0KcN2jqdy6XCuaM5CVWxD9DOc9rE59zubAGhiDoYEybmu7oYQUxSvM9m5WeEtnGzX6B0YmUHh6I8+PV8sgfx2OwHjpY3eNfXDKo9P6Ez3xs-8XPs0EN1C4abx2jedoo-q+hFjjhOwZjZOMsTVv24sBGu9ebMhylEtLECfZ9n9Uezpatr0BZG7IfFoyF5Wjvl+dVePrHKUEa384DGHJUzH33DD-a-VDUb2evYX2HG3K+S8c532aN4fsHEMbuisKtXQoRJxyEIC+eAhQBp42mGfA2RkAC0ecioEM-lWZgNAcHN12KKfsNReg0U8C6IqK1CRolDu6N4R43AkI8oCSAFD7pqDcO4N4aJVYHjeFcRhzReTShgehLw6hzLcPDJGfhqdsR7COMhGw-QVqeGsrRaCw5HRXDQhKZRz43wiSIlANRiZGiyzMG3dEOYvCWD8BY86jUfx2M3Padw+5aFYnUEec46htpSnRnsUyvdTITntpXLB3NCY6x8Z7c+VF4EeDQmOV+-hCwiggYge0fJHjJntFjJCehPET1rgnXxRkJQ2mUqZXw2N6bNFUPodwfp3FGE8McdQnjv7oAaVRJp0on5YjMF4C41hXR+A8KYN0iEZQSnOEg4IQA */ + id: "addressAutocomplete", + initial: "idle", + context: () => ({ + query: "", + suggestions: [], + }), + states: { + idle: { + on: { + TYPE: { + target: "editing", + actions: "updateQuery", + }, + FOCUS: "focused", + }, + }, + focused: { + on: { + TYPE: { + target: "editing", + actions: "updateQuery", + }, + BLUR: "idle", + }, + }, + editing: { + always: [ + { + target: "debouncing", + guard: "hasMinLength", + }, + { + target: "focused", + }, + ], + on: { + TYPE: { + actions: "updateQuery", + }, + CLEAR: { + target: "idle", + actions: "clearQuery", + }, + BLUR: "idle", + }, + }, + debouncing: { + after: { + 500: "fetching", + }, + on: { + TYPE: { + target: "editing", + actions: "updateQuery", + }, + BLUR: "idle", + }, + }, + fetching: { + invoke: { + src: "fetchSuggestions", + input: ({ context }) => ({ query: context.query }), + onDone: { + target: "suggesting", + actions: "updateSuggestions", + }, + onError: { + target: "error", + actions: "setError", + }, + }, + on: { + TYPE: { + target: "editing", + actions: "updateQuery", + }, + BLUR: "idle", + }, + }, + suggesting: { + on: { + TYPE: { + target: "editing", + actions: "updateQuery", + }, + SELECT: { + target: "selected", + actions: "selectAddress", + }, + BLUR: "idle", + CLEAR: { + target: "idle", + actions: "clearQuery", + }, + }, + }, + selected: { + on: { + TYPE: { + target: "editing", + actions: "updateQuery", + }, + CLEAR: { + target: "idle", + actions: "clearQuery", + }, + BLUR: "idle", + }, + }, + error: { + on: { + TYPE: { + target: "editing", + actions: "updateQuery", + }, + CLEAR: { + target: "idle", + actions: "clearQuery", + }, + }, + }, + }, +}); diff --git a/packages/frontend/src/features/address.tsx b/packages/frontend/src/features/address.tsx index 39771cc..5c0f3ec 100644 --- a/packages/frontend/src/features/address.tsx +++ b/packages/frontend/src/features/address.tsx @@ -2,7 +2,7 @@ export const searchAddress = async (query: string) => { const response = await fetch(`https://api-adresse.data.gouv.fr/search/?q=${query}&limit=10`); const data = await response.json(); - return data.features.map((feature: AddressFeature, index: number) => ({ + return (data.features as any).map((feature: AddressFeature, index: number) => ({ index, address: feature.properties.name, zipCode: feature.properties.postcode, @@ -11,6 +11,8 @@ export const searchAddress = async (query: string) => { })); }; +export type AddressSuggestion = Awaited>; + export type AddressResult = { address: string; zipCode: string; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b898559..86d61e8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -135,7 +135,7 @@ importers: version: 6.9.13 pastable: specifier: ^2.2.1 - version: 2.2.1(react@18.2.0) + version: 2.2.1(react@18.2.0)(xstate@5.19.0) pg: specifier: ^8.13.1 version: 8.13.1 @@ -305,6 +305,9 @@ importers: '@ungap/with-resolvers': specifier: ^0.1.0 version: 0.1.0 + '@xstate/react': + specifier: ^5.0.0 + version: 5.0.0(@types/react@18.2.74)(react@18.2.0)(xstate@5.19.0) '@xstate/store': specifier: ^0.0.5 version: 0.0.5(react@18.2.0) @@ -340,7 +343,7 @@ importers: version: 1.3.4 pastable: specifier: ^2.2.1 - version: 2.2.1(react@18.2.0) + version: 2.2.1(react@18.2.0)(xstate@5.19.0) pdfjs-dist: specifier: ^4.3.136 version: 4.3.136 @@ -374,6 +377,9 @@ importers: wa-sqlite: specifier: github:rhashimoto/wa-sqlite version: github.com/rhashimoto/wa-sqlite/ca2084cdd188c56532ba3e272696d0b9e21a23bf + xstate: + specifier: ^5.19.0 + version: 5.19.0 zod: specifier: ^3.22.4 version: 3.22.4 @@ -7332,6 +7338,23 @@ packages: resolution: {integrity: sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ==} dev: false + /@xstate/react@5.0.0(@types/react@18.2.74)(react@18.2.0)(xstate@5.19.0): + resolution: {integrity: sha512-MkYMpmqqCdK43wSl/V/jSpsvumzV4RSG2ZOUEAIrg/w36BJpyufMrsR0rz7POX5ICF5s3xzP9q7Hd5TyM5SSyQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-0 + xstate: ^5.19.0 + peerDependenciesMeta: + xstate: + optional: true + dependencies: + react: 18.2.0 + use-isomorphic-layout-effect: 1.2.0(@types/react@18.2.74)(react@18.2.0) + use-sync-external-store: 1.2.0(react@18.2.0) + xstate: 5.19.0 + transitivePeerDependencies: + - '@types/react' + dev: false + /@xstate/store@0.0.5(react@18.2.0): resolution: {integrity: sha512-WliXJxUtz6bs9o+fN2MBnf1PLJPQEEKEiwCbjDnJ0M23NoeJ2ghRxqPVxsPSgZXhGcEzB9QQfb1qwZ34TC9A3w==} peerDependencies: @@ -12798,7 +12821,7 @@ packages: resolution: {integrity: sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==} dev: false - /pastable@2.2.1(react@18.2.0): + /pastable@2.2.1(react@18.2.0)(xstate@5.19.0): resolution: {integrity: sha512-K4ClMxRKpgN4sXj6VIPPrvor/TMp2yPNCGtfhvV106C73SwefQ3FuegURsH7AQHpqu0WwbvKXRl1HQxF6qax9w==} engines: {node: '>=14.x'} peerDependencies: @@ -12814,6 +12837,7 @@ packages: react: 18.2.0 ts-toolbelt: 9.6.0 type-fest: 3.13.1 + xstate: 5.19.0 transitivePeerDependencies: - supports-color @@ -15095,7 +15119,7 @@ packages: arktype: 1.0.18-alpha cac: 6.7.14 openapi3-ts: 4.3.1 - pastable: 2.2.1(react@18.2.0) + pastable: 2.2.1(react@18.2.0)(xstate@5.19.0) pathe: 1.1.2 prettier: 2.8.4 ts-pattern: 5.1.0 @@ -15231,6 +15255,19 @@ packages: dependencies: punycode: 2.3.1 + /use-isomorphic-layout-effect@1.2.0(@types/react@18.2.74)(react@18.2.0): + resolution: {integrity: sha512-q6ayo8DWoPZT0VdG4u3D3uxcgONP3Mevx2i2b0434cwWBoL+aelL1DzkXI6w3PhTZzUeR2kaVlZn70iCiseP6w==} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 18.2.74 + react: 18.2.0 + dev: false + /use-sync-external-store@1.2.0(react@18.2.0): resolution: {integrity: sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==} peerDependencies: @@ -15827,6 +15864,9 @@ packages: optional: true dev: false + /xstate@5.19.0: + resolution: {integrity: sha512-Juh1MjeRaVWr1IRxXYvQMMRFMrei6vq6+AfP6Zk9D9YV0ZuvubN0aM6s2ITwUrq+uWtP1NTO8kOZmsM/IqeOiQ==} + /xtend@2.1.2: resolution: {integrity: sha512-vMNKzr2rHP9Dp/e1NQFnLQlwlhp9L/LfvnsVdHxN1f+uggyVI3i08uD14GPvCToPkdsRfyPqIyYGmIk58V98ZQ==} engines: {node: '>=0.4'}