Initial reimplementation in VueJS of address or report suggestion
This commit is contained in:
parent
c783ab7942
commit
8a05ba2faf
3 changed files with 371 additions and 4 deletions
223
ts/components/AddressOrReportSuggestionInput.vue
Normal file
223
ts/components/AddressOrReportSuggestionInput.vue
Normal 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>
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
145
ts/rmo/store/address-or-report-suggestion.ts
Normal file
145
ts/rmo/store/address-or-report-suggestion.ts
Normal 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 };
|
||||
Loading…
Add table
Add a link
Reference in a new issue