mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 07:08:18 +00:00
changes
This commit is contained in:
@@ -17,6 +17,7 @@
|
|||||||
"@edr/ui-common": "workspace:*",
|
"@edr/ui-common": "workspace:*",
|
||||||
"@hello-pangea/dnd": "^18.0.1",
|
"@hello-pangea/dnd": "^18.0.1",
|
||||||
"@mantine/core": "^9.3.0",
|
"@mantine/core": "^9.3.0",
|
||||||
|
"@mantine/dates": "^9.3.0",
|
||||||
"@mantine/hooks": "^9.3.0",
|
"@mantine/hooks": "^9.3.0",
|
||||||
"@tabler/icons-react": "^3.44.0",
|
"@tabler/icons-react": "^3.44.0",
|
||||||
"@tanstack/react-query": "^5.100.11",
|
"@tanstack/react-query": "^5.100.11",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client";
|
|||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from "react-router-dom";
|
||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
import "@mantine/core/styles.css";
|
import "@mantine/core/styles.css";
|
||||||
|
import "@mantine/dates/styles.css";
|
||||||
import "@edr/ui-common/styles.css";
|
import "@edr/ui-common/styles.css";
|
||||||
import "../index.css";
|
import "../index.css";
|
||||||
import "@edr/ui-common/theme.css";
|
import "@edr/ui-common/theme.css";
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
|
Autocomplete,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Combobox,
|
|
||||||
Group,
|
Group,
|
||||||
InputBase,
|
|
||||||
Loader,
|
Loader,
|
||||||
Modal,
|
Modal,
|
||||||
Text,
|
Text,
|
||||||
useCombobox,
|
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { Check, MapPin, Search } from "lucide-react";
|
import { Check, MapPin, Search } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
@@ -265,7 +263,7 @@ export interface LocationPickerProps {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Address + map location picker backed by Google Maps:
|
* Address + map location picker backed by Google Maps:
|
||||||
* - type to search (Geocoding API forward geocoding, debounced),
|
* - type to search (Places Autocomplete, debounced),
|
||||||
* - or click anywhere on the map to drop a pin (reverse geocoding).
|
* - or click anywhere on the map to drop a pin (reverse geocoding).
|
||||||
* Reports the resolved address and coordinates up via `onChange`.
|
* Reports the resolved address and coordinates up via `onChange`.
|
||||||
*/
|
*/
|
||||||
@@ -386,7 +384,6 @@ function LocationPickerInline({
|
|||||||
mapHeight = 260,
|
mapHeight = 260,
|
||||||
withinPortal = true,
|
withinPortal = true,
|
||||||
}: LocationPickerProps & { mapHeight?: number; withinPortal?: boolean }) {
|
}: LocationPickerProps & { mapHeight?: number; withinPortal?: boolean }) {
|
||||||
const combobox = useCombobox();
|
|
||||||
const geocoder = useGeocoder();
|
const geocoder = useGeocoder();
|
||||||
const places = usePlacesSearch();
|
const places = usePlacesSearch();
|
||||||
const placesLib = useMapsLibrary("places");
|
const placesLib = useMapsLibrary("places");
|
||||||
@@ -394,7 +391,6 @@ function LocationPickerInline({
|
|||||||
const [results, setResults] = useState<PlacePrediction[]>([]);
|
const [results, setResults] = useState<PlacePrediction[]>([]);
|
||||||
const [searching, setSearching] = useState(false);
|
const [searching, setSearching] = useState(false);
|
||||||
const [resolving, setResolving] = useState(false);
|
const [resolving, setResolving] = useState(false);
|
||||||
const searchStaleRef = useRef<{ stale: boolean } | null>(null);
|
|
||||||
const reverseStaleRef = useRef<{ stale: boolean } | null>(null);
|
const reverseStaleRef = useRef<{ stale: boolean } | null>(null);
|
||||||
// One Autocomplete session groups every keystroke of a search with the final
|
// One Autocomplete session groups every keystroke of a search with the final
|
||||||
// Details fetch into a single billable unit. Reset after each pick.
|
// Details fetch into a single billable unit. Reset after each pick.
|
||||||
@@ -409,9 +405,8 @@ function LocationPickerInline({
|
|||||||
|
|
||||||
// Debounced forward search — fires only after the user stops typing
|
// Debounced forward search — fires only after the user stops typing
|
||||||
// (SEARCH_DEBOUNCE_MS of silence), so we make one request per pause rather
|
// (SEARCH_DEBOUNCE_MS of silence), so we make one request per pause rather
|
||||||
// than one per keystroke. The dropdown is kept open the whole time so the
|
// than one per keystroke. While it runs, the input shows a spinner; the
|
||||||
// user sees the "Searching…" state and then the live results for what they
|
// dropdown itself only appears once there are predictions to show.
|
||||||
// typed.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const q = query.trim();
|
const q = query.trim();
|
||||||
if (q.length < MIN_QUERY_LEN) {
|
if (q.length < MIN_QUERY_LEN) {
|
||||||
@@ -420,12 +415,10 @@ function LocationPickerInline({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSearching(true);
|
setSearching(true);
|
||||||
combobox.openDropdown();
|
|
||||||
if (!places) return; // re-runs once the places library loads
|
if (!places) return; // re-runs once the places library loads
|
||||||
// Autocomplete has no abort support, so a token marks superseded requests
|
// Autocomplete has no abort support, so a token marks superseded requests
|
||||||
// and their responses are dropped instead of overwriting newer results.
|
// and their responses are dropped instead of overwriting newer results.
|
||||||
const token = { stale: false };
|
const token = { stale: false };
|
||||||
searchStaleRef.current = token;
|
|
||||||
const handle = setTimeout(async () => {
|
const handle = setTimeout(async () => {
|
||||||
const found = await searchPlaces(
|
const found = await searchPlaces(
|
||||||
places.autocomplete,
|
places.autocomplete,
|
||||||
@@ -435,13 +428,12 @@ function LocationPickerInline({
|
|||||||
if (token.stale) return;
|
if (token.stale) return;
|
||||||
setResults(found);
|
setResults(found);
|
||||||
setSearching(false);
|
setSearching(false);
|
||||||
combobox.openDropdown();
|
|
||||||
}, SEARCH_DEBOUNCE_MS);
|
}, SEARCH_DEBOUNCE_MS);
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(handle);
|
clearTimeout(handle);
|
||||||
token.stale = true;
|
token.stale = true;
|
||||||
};
|
};
|
||||||
}, [query, places, combobox]);
|
}, [query, places]);
|
||||||
|
|
||||||
// Drop any in-flight reverse lookup when the picker unmounts.
|
// Drop any in-flight reverse lookup when the picker unmounts.
|
||||||
useEffect(
|
useEffect(
|
||||||
@@ -453,7 +445,10 @@ function LocationPickerInline({
|
|||||||
|
|
||||||
const selectResult = useCallback(
|
const selectResult = useCallback(
|
||||||
async (prediction: PlacePrediction) => {
|
async (prediction: PlacePrediction) => {
|
||||||
combobox.closeDropdown();
|
// Clear the query/results immediately so the pending debounce can't fire
|
||||||
|
// a search for the picked address and pop the dropdown back open.
|
||||||
|
setQuery("");
|
||||||
|
setResults([]);
|
||||||
// Predictions carry no coordinates — resolve them now via Place Details.
|
// Predictions carry no coordinates — resolve them now via Place Details.
|
||||||
if (!places) return;
|
if (!places) return;
|
||||||
setResolving(true);
|
setResolving(true);
|
||||||
@@ -474,10 +469,8 @@ function LocationPickerInline({
|
|||||||
lat: resolved.lat,
|
lat: resolved.lat,
|
||||||
lng: resolved.lng,
|
lng: resolved.lng,
|
||||||
});
|
});
|
||||||
setQuery("");
|
|
||||||
setResults([]);
|
|
||||||
},
|
},
|
||||||
[onChange, combobox, places, placesLib],
|
[onChange, places, placesLib],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePin = useCallback(
|
const handlePin = useCallback(
|
||||||
@@ -515,60 +508,44 @@ function LocationPickerInline({
|
|||||||
? { lat: value.lat as number, lng: value.lng as number }
|
? { lat: value.lat as number, lng: value.lng as number }
|
||||||
: DEFAULT_CENTER;
|
: DEFAULT_CENTER;
|
||||||
|
|
||||||
|
// Mantine Autocomplete requires unique option values; predictions are keyed
|
||||||
|
// by their display text, so de-duplicate the rare identical descriptions.
|
||||||
|
const optionsByName = useMemo(() => {
|
||||||
|
const byName = new Map<string, PlacePrediction>();
|
||||||
|
for (const r of results) {
|
||||||
|
if (!byName.has(r.displayName)) byName.set(r.displayName, r);
|
||||||
|
}
|
||||||
|
return byName;
|
||||||
|
}, [results]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Combobox
|
<Autocomplete
|
||||||
store={combobox}
|
label={label || undefined}
|
||||||
withinPortal={withinPortal}
|
placeholder={placeholder}
|
||||||
shadow="md"
|
value={inputValue}
|
||||||
radius="md"
|
error={error}
|
||||||
>
|
radius={10}
|
||||||
<Combobox.Target>
|
styles={fieldStyles}
|
||||||
<InputBase
|
leftSection={<Search size={16} />}
|
||||||
label={label || undefined}
|
rightSection={searching || resolving ? <Loader size={14} /> : null}
|
||||||
placeholder={placeholder}
|
data={[...optionsByName.keys()]}
|
||||||
value={inputValue}
|
// Predictions are already ranked by the Places API for the typed
|
||||||
error={error}
|
// query; Mantine's default substring filter would hide most of them.
|
||||||
radius={10}
|
filter={({ options }) => options}
|
||||||
styles={fieldStyles}
|
maxDropdownHeight={240}
|
||||||
leftSection={<Search size={16} />}
|
comboboxProps={{ withinPortal, shadow: "md", radius: "md" }}
|
||||||
rightSection={searching || resolving ? <Loader size={14} /> : null}
|
onChange={setQuery}
|
||||||
onChange={(e) => {
|
onOptionSubmit={(name) => {
|
||||||
setQuery(e.currentTarget.value);
|
const prediction = optionsByName.get(name);
|
||||||
combobox.openDropdown();
|
if (prediction) void selectResult(prediction);
|
||||||
}}
|
}}
|
||||||
onFocus={() => {
|
renderOption={({ option }) => (
|
||||||
if (query.trim().length >= MIN_QUERY_LEN) combobox.openDropdown();
|
<Text fz={13} lineClamp={2}>
|
||||||
}}
|
{option.value}
|
||||||
/>
|
</Text>
|
||||||
</Combobox.Target>
|
)}
|
||||||
|
/>
|
||||||
<Combobox.Dropdown>
|
|
||||||
<Combobox.Options mah={240} style={{ overflowY: "auto" }}>
|
|
||||||
{searching ? (
|
|
||||||
<Combobox.Empty>Searching “{query.trim()}”…</Combobox.Empty>
|
|
||||||
) : results.length === 0 ? (
|
|
||||||
<Combobox.Empty>
|
|
||||||
{query.trim().length < MIN_QUERY_LEN
|
|
||||||
? `Type at least ${MIN_QUERY_LEN} characters`
|
|
||||||
: "No matching places"}
|
|
||||||
</Combobox.Empty>
|
|
||||||
) : (
|
|
||||||
results.map((r, i) => (
|
|
||||||
<Combobox.Option
|
|
||||||
key={`${r.placeId}-${i}`}
|
|
||||||
value={String(i)}
|
|
||||||
onClick={() => void selectResult(r)}
|
|
||||||
>
|
|
||||||
<Text fz={13} lineClamp={2}>
|
|
||||||
{r.displayName}
|
|
||||||
</Text>
|
|
||||||
</Combobox.Option>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</Combobox.Options>
|
|
||||||
</Combobox.Dropdown>
|
|
||||||
</Combobox>
|
|
||||||
|
|
||||||
<Box
|
<Box
|
||||||
mt={10}
|
mt={10}
|
||||||
|
|||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -217,6 +217,9 @@ importers:
|
|||||||
'@mantine/core':
|
'@mantine/core':
|
||||||
specifier: ^9.3.0
|
specifier: ^9.3.0
|
||||||
version: 9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
version: 9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
|
'@mantine/dates':
|
||||||
|
specifier: ^9.3.0
|
||||||
|
version: 9.3.2(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
'@mantine/hooks':
|
'@mantine/hooks':
|
||||||
specifier: ^9.3.0
|
specifier: ^9.3.0
|
||||||
version: 9.3.0(react@19.2.6)
|
version: 9.3.0(react@19.2.6)
|
||||||
|
|||||||
Reference in New Issue
Block a user