From 0b8f5ffa9810b90d400bf05af9694a6001d1b3b4 Mon Sep 17 00:00:00 2001 From: Martin Ledoux <32564108+ledouxm@users.noreply.github.com> Date: Tue, 17 Dec 2024 11:34:04 +0100 Subject: [PATCH] Feat/address autocomplete (#44) * feat: add address autocompletion * feat: add loading badge and fix some styles * fix(address): allow user to use a custom value --- .../src/components/SmartAddressInput.tsx | 139 ++++++++++++++++++ packages/frontend/src/features/InfoForm.tsx | 6 +- .../src/features/ServiceInstructeurSelect.tsx | 2 +- packages/frontend/src/features/address.tsx | 48 ++++++ .../frontend/src/features/testPowersync.tsx | 10 -- packages/frontend/src/main.tsx | 3 - 6 files changed, 192 insertions(+), 16 deletions(-) create mode 100644 packages/frontend/src/components/SmartAddressInput.tsx create mode 100644 packages/frontend/src/features/address.tsx delete mode 100644 packages/frontend/src/features/testPowersync.tsx diff --git a/packages/frontend/src/components/SmartAddressInput.tsx b/packages/frontend/src/components/SmartAddressInput.tsx new file mode 100644 index 0000000..a2cf726 --- /dev/null +++ b/packages/frontend/src/components/SmartAddressInput.tsx @@ -0,0 +1,139 @@ +import { css } from "#styled-system/css"; +import { Flex, Stack, styled } from "#styled-system/jsx"; +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 { useFormContext, useWatch } from "react-hook-form"; +import { useDebounce } from "react-use"; +import { Report } from "../db/AppSchema"; +import { AddressResult, searchAddress } from "../features/address"; +import { useIsFormDisabled } from "../features/DisabledContext"; +import { Combobox } from "./Combobox"; + +export const SmartAddressInput = () => { + const form = useFormContext(); + const isFormDisabled = useIsFormDisabled(); + + const [isFrozen, setIsFrozen] = useState(true); + + 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, + }); + + const isLoading = addressQuery.isLoading && isEnabled; + const suggestions = addressQuery.data; + + 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); + } + }} + 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} + + + + + {isLoading ? ( + + + + ) : null} + + ); +}; + +const ProxyInput = ({ disabled, isLoading, ...props }: any) => { + return ( + + Adresse (numéro, voie) + {isLoading ? ( + + + + ) : null} + + } + disabled={disabled} + nativeInputProps={{ ...props, autoComplete: "new-password" }} + /> + ); +}; + +const LoadingBadge = () => { + return ( + + + Recherche en cours + + ); +}; diff --git a/packages/frontend/src/features/InfoForm.tsx b/packages/frontend/src/features/InfoForm.tsx index c1940c1..918eeb6 100644 --- a/packages/frontend/src/features/InfoForm.tsx +++ b/packages/frontend/src/features/InfoForm.tsx @@ -14,6 +14,7 @@ import { Report } from "../db/AppSchema"; import { db, useDbQuery } from "../db/db"; import { useIsFormDisabled } from "./DisabledContext"; import { ServiceInstructeurSelect } from "./ServiceInstructeurSelect"; +import { SmartAddressInput } from "#components/SmartAddressInput.tsx"; export const InfoForm = () => { const form = useFormContext(); @@ -150,12 +151,13 @@ export const InfoForm = () => { nativeTextAreaProps={{ ...form.register("projectDescription"), rows: 5 }} /> - + {/* + /> */} - + {items?.length ? ( items.map((item) => ( diff --git a/packages/frontend/src/features/address.tsx b/packages/frontend/src/features/address.tsx new file mode 100644 index 0000000..39771cc --- /dev/null +++ b/packages/frontend/src/features/address.tsx @@ -0,0 +1,48 @@ +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) => ({ + index, + address: feature.properties.name, + zipCode: feature.properties.postcode, + city: feature.properties.city, + label: feature.properties.label, + })); +}; + +export type AddressResult = { + address: string; + zipCode: string; + city: string; + label: string; + index: number; +}; + +export interface AddressFeature { + type: string; + geometry: Geometry; + properties: Properties; +} + +export interface Properties { + label: string; + score: number; + type: string; + importance: number; + id: string; + banId: string; + name: string; + postcode: string; + citycode: string; + x: number; + y: number; + city: string; + context: string; + locality: string; +} + +export interface Geometry { + type: string; + coordinates: number[]; +} diff --git a/packages/frontend/src/features/testPowersync.tsx b/packages/frontend/src/features/testPowersync.tsx deleted file mode 100644 index 237ab56..0000000 --- a/packages/frontend/src/features/testPowersync.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { useQuery, useStatus } from "@powersync/react"; - -export const TestPowersync = () => { - return null; - const a = useQuery("SELECT * FROM report"); - const status = useStatus(); - console.log(status.connected); - console.log(a); - return
{(a.data || []).map((r) => r.id).join(", ")}
; -}; diff --git a/packages/frontend/src/main.tsx b/packages/frontend/src/main.tsx index f51457f..1089457 100644 --- a/packages/frontend/src/main.tsx +++ b/packages/frontend/src/main.tsx @@ -11,7 +11,6 @@ import { registerSW } from "virtual:pwa-register"; import { initFonts } from "@cr-vif/pdf"; import { powerSyncDb, setupPowersync } from "./db/db"; import { PowerSyncContext } from "@powersync/react"; -import { TestPowersync } from "./features/testPowersync"; if ("serviceWorker" in navigator) { registerSW({}); @@ -41,11 +40,9 @@ ReactDOM.createRoot(document.getElementById("root")!).render( Une erreur s'est produite}> - {/* */} -