Initial reimplementation in VueJS of address or report suggestion

This commit is contained in:
Eli Ribble 2026-04-25 00:17:35 +00:00
parent c783ab7942
commit 8a05ba2faf
No known key found for this signature in database
3 changed files with 371 additions and 4 deletions

View file

@ -0,0 +1,223 @@
<style scoped>
.detail-label {
font-size: 0.8rem;
text-transform: uppercase;
color: #6c757d;
margin-bottom: 2px;
font-weight: 600;
}
.detail-value {
font-weight: 500;
}
.main-address {
font-weight: 500;
}
.place-info {
font-size: 0.85rem;
color: #6c757d;
margin-top: 2px;
}
.suggestions-container {
position: absolute;
width: 100%;
max-height: 300px;
overflow-y: auto;
z-index: 1000;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
top: 48px;
}
.suggestion-item {
cursor: pointer;
padding: 10px 12px;
border-bottom: 1px solid #f0f0f0;
}
.suggestion-item:hover {
background-color: #f8f9fa;
}
.report-id {
font-weight: 500;
}
.report-type {
font-size: 0.85rem;
color: #6c757d;
margin-top: 2px;
}
</style>
<template>
<div class="position-relative">
<div class="input-group">
<span class="input-group-text">
<i class="bi bi-search"></i>
</span>
<input
ref="inputRef"
type="text"
class="form-control form-control-lg"
:placeholder="placeholder"
:value="modelValue"
maxlength="200"
@input="handleInput"
/>
<div v-if="showSuggestions" class="suggestions-container list-group">
<!-- Report Suggestions -->
<div
v-for="(report, index) in suggestions.reports"
:key="`report-${report.id}`"
class="suggestion-item list-group-item"
@click="handleReportClick(report, index)"
>
<div class="report-id">{{ formatReportID(report.id) }}</div>
<div class="report-type">{{ formatReportType(report.type) }}</div>
</div>
<!-- Address Suggestions -->
<div
v-for="address in suggestions.addresses"
:key="`address-${address.properties.gid}`"
class="suggestion-item list-group-item"
@click="handleAddressClick(address)"
>
<div class="main-address">{{ address.properties.name }}</div>
<div class="place-info">{{ address.properties.coarse_location }}</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onBeforeUnmount } from "vue";
import { useStoreSuggestion } from "@/rmo/store/address-or-report-suggestion";
const props = defineProps({
modelValue: {
type: String,
default: "",
},
placeholder: {
type: String,
default: "Enter address",
},
apiKey: {
type: String,
default: "",
},
});
const emit = defineEmits(["update:modelValue", "suggestion-selected"]);
const inputRef = ref(null);
const debounceTimer = ref(null);
const store = useStoreSuggestion();
const suggestions = computed(() => ({
addresses: store.addresses,
reports: store.reports,
}));
const showSuggestions = computed(() => {
return (
props.modelValue.length >= 3 &&
(suggestions.value.addresses.length > 0 ||
suggestions.value.reports.length > 0)
);
});
const handleInput = (event) => {
const searchText = event.target.value;
emit("update:modelValue", searchText);
// Clear previous timer
clearTimeout(debounceTimer.value);
// Clear suggestions if input is less than 3 characters
if (searchText.trim().length < 3) {
store.clearSuggestions();
return;
}
// Debounce API calls (wait 300ms after typing stops)
debounceTimer.value = setTimeout(() => {
store.fetchSuggestions(searchText.trim());
}, 300);
};
const handleReportClick = (report, index) => {
const formattedId = formatReportID(report.id);
emit("update:modelValue", formattedId);
store.clearSuggestions();
emit("suggestion-selected", {
content: report,
type: "report",
});
};
const handleAddressClick = async (address) => {
try {
const detailedAddress = await store.fetchAddressDetails(
address.properties.gid,
);
if (detailedAddress) {
const formattedAddress =
detailedAddress.properties.formatted_address_line;
emit("update:modelValue", formattedAddress);
store.clearSuggestions();
emit("suggestion-selected", {
content: detailedAddress,
type: "address",
});
}
} catch (error) {
console.error("Error handling address click:", error);
}
};
const formatReportID = (id) => {
if (id.length === 12) {
return `${id.substring(0, 4)}-${id.substring(4, 8)}-${id.substring(8)}`;
}
return id;
};
const formatReportType = (type) => {
const types = {
nuisance: "Mosquito Nuisance Report",
water: "Standing Water Report",
};
return types[type] || "Unknown Report Type";
};
const clear = () => {
emit("update:modelValue", "");
store.clearSuggestions();
};
const setValue = (suggestion) => {
if (suggestion?.properties?.formatted_address_line) {
emit("update:modelValue", suggestion.properties.formatted_address_line);
store.clearSuggestions();
}
};
// Expose public methods
defineExpose({
clear,
setValue,
});
// Cleanup on unmount
onBeforeUnmount(() => {
clearTimeout(debounceTimer.value);
});
</script>

View file

@ -73,12 +73,10 @@
<div class="card-body">
<form class="row g-3 align-items-center" action="#" id="lookup-form">
<div class="col-md-9">
<!--
<address-or-report-input
<AddressOrReportSuggestionInput
name="address-or-report"
placeholder="Enter a report ID, address, neighborhood, or zip code"
></address-or-report-input>
-->
/>
</div>
<div class="col-md-3">
<span
@ -194,6 +192,7 @@
import { computed, onMounted, ref } from "vue";
import TableReport, { Report } from "@/rmo/components/TableReport.vue";
import AddressOrReportSuggestionInput from "@/components/AddressOrReportSuggestionInput.vue";
import { apiClient } from "@/client";
import Map from "@/map/Map.vue";
import Layer, { Feature, MouseEvent } from "@/map/Layer.vue";

View file

@ -0,0 +1,145 @@
import { defineStore } from "pinia";
// Type definitions
interface AddressProperties {
gid: string;
name: string;
coarse_location: string;
formatted_address_line?: string;
[key: string]: any; // Allow other properties from the API
}
interface Address {
properties: AddressProperties;
geometry?: any;
type?: string;
}
interface Report {
id: string;
type: "nuisance" | "water" | string;
[key: string]: any; // Allow other properties from the API
}
interface SuggestionsState {
addresses: Address[];
reports: Report[];
loading: boolean;
error: string | null;
}
interface GeocodeResponse {
features?: Address[];
[key: string]: any;
}
interface ReportResponse {
reports?: Report[];
[key: string]: any;
}
interface PlaceDetailsResponse {
features?: Address[];
[key: string]: any;
}
export const useStoreSuggestion = defineStore("suggestions", {
state: (): SuggestionsState => ({
addresses: [],
reports: [],
loading: false,
error: null,
}),
actions: {
async fetchSuggestions(searchText: string): Promise<void> {
this.loading = true;
this.error = null;
try {
await Promise.all([
this.fetchAddressSuggestions(searchText),
this.fetchReportSuggestions(searchText),
]);
} catch (error) {
this.error =
error instanceof Error ? error.message : "Unknown error occurred";
console.error("Error fetching suggestions:", error);
} finally {
this.loading = false;
}
},
async fetchAddressSuggestions(text: string): Promise<void> {
try {
const url = `https://api.stadiamaps.com/geocoding/v2/autocomplete?text=${encodeURIComponent(text)}&focus.point.lat=35&focus.point.lon=-115`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Address API error: ${response.status}`);
}
const data: GeocodeResponse = await response.json();
this.addresses = data.features || [];
} catch (error) {
console.error("Error fetching geocoding suggestions:", error);
this.addresses = [];
throw error;
}
},
async fetchReportSuggestions(text: string): Promise<void> {
try {
const url = `/report/suggest?r=${encodeURIComponent(text)}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Report API error: ${response.status}`);
}
const data: ReportResponse = await response.json();
this.reports = data.reports || [];
} catch (error) {
console.error("Error fetching report suggestions:", error);
this.reports = [];
throw error;
}
},
async fetchAddressDetails(gid: string): Promise<Address | null> {
try {
const url = `https://api.stadiamaps.com/geocoding/v2/place_details?ids=${gid}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Address details API error: ${response.status}`);
}
const data: PlaceDetailsResponse = await response.json();
return data.features?.[0] || null;
} catch (error) {
console.error("Error fetching address details:", error);
throw error;
}
},
clearSuggestions(): void {
this.addresses = [];
this.reports = [];
this.error = null;
},
},
getters: {
hasAddresses: (state): boolean => state.addresses.length > 0,
hasReports: (state): boolean => state.reports.length > 0,
hasSuggestions: (state): boolean =>
state.addresses.length > 0 || state.reports.length > 0,
totalSuggestions: (state): number =>
state.addresses.length + state.reports.length,
},
});
// Export types for use in components
export type { Address, Report, AddressProperties, SuggestionsState };