src/app/features/location/address-edit/address-edit.component.ts
Edit a GeoLocation / Address, including options to search via API and customize the string location being saved.
| changeDetection | ChangeDetectionStrategy.OnPush |
| selector | app-address-edit |
| standalone | true |
| imports |
MatFormField
MatLabel
MatInput
MatTooltip
MatExpansionModule
|
| styleUrls | ./address-edit.component.scss |
| templateUrl | ./address-edit.component.html |
| styleUrl | ./address-edit.component.scss |
AddressSearchComponent
MatFormField
MatLabel
MatInput
MatTooltip
MatExpansionModule
AddressGpsLocationComponent
Properties |
|
Methods |
Inputs |
Outputs |
| disabled | |
Type : boolean
|
|
Default value : false
|
|
|
Whether the search box is enabled and visible. |
|
| selectedLocation | |
Type : GeoLocation
|
|
|
Whenever the user selects an actual looked up location, it is emitted here. |
|
| selectedLocation | |
Type : GeoLocation
|
|
|
Whenever the user selects an actual looked up location, it is emitted here. |
|
| clearLocation |
clearLocation()
|
|
Returns :
void
|
| focusManualAddressInput |
focusManualAddressInput()
|
|
Returns :
void
|
| onGpsLocationSelected | ||||||
onGpsLocationSelected(geoResult: GeoResult)
|
||||||
|
Parameters :
Returns :
void
|
| Async updateAddressPart | |||||||||
updateAddressPart(key: "road" | "house_number" | "postcode" | "city" | "country", value: string)
|
|||||||||
|
Parameters :
Returns :
any
|
| Async updateFromAddressSearch | ||||||
updateFromAddressSearch(event: { location: GeoLocation; userInput: string })
|
||||||
|
Parameters :
Returns :
any
|
| updateLocation | ||||||
updateLocation(selected: GeoLocation | undefined)
|
||||||
|
Parameters :
Returns :
void
|
| updateLocationString | ||||||
updateLocationString(value: string)
|
||||||
|
Parameters :
Returns :
void
|
import {
Component,
computed,
ElementRef,
input,
model,
viewChild,
ChangeDetectionStrategy,
inject,
} from "@angular/core";
import { AddressSearchComponent } from "../address-search/address-search.component";
import { GeoResult, GeoService } from "../geo.service";
import { GeoLocation } from "../geo-location";
import { MatFormField, MatLabel } from "@angular/material/form-field";
import { MatInput } from "@angular/material/input";
import { MatTooltip } from "@angular/material/tooltip";
import { MatExpansionModule } from "@angular/material/expansion";
import { AddressGpsLocationComponent } from "../address-gps-location/address-gps-location.component";
import { ConfirmationDialogService } from "../../../core/common-components/confirmation-dialog/confirmation-dialog.service";
/**
* Edit a GeoLocation / Address, including options to search via API and customize the string location being saved.
*/
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: "app-address-edit",
imports: [
AddressSearchComponent,
MatFormField,
MatLabel,
MatInput,
MatTooltip,
MatExpansionModule,
AddressGpsLocationComponent,
],
templateUrl: "./address-edit.component.html",
styleUrl: "./address-edit.component.scss",
})
export class AddressEditComponent {
private readonly manualAddressInput =
viewChild<ElementRef<HTMLTextAreaElement>>("manualAddressInput");
/**
* Whenever the user selects an actual looked up location, it is emitted here.
*/
selectedLocation = model<GeoLocation>();
/**
* Whether the search box is enabled and visible.
*/
disabled = input<boolean>(false);
private readonly geoService = inject(GeoService);
private readonly confirmationDialog = inject(ConfirmationDialogService);
focusManualAddressInput() {
// switch focus only after the panel's content has rendered
setTimeout(() => this.manualAddressInput()?.nativeElement.focus(), 0);
}
updateLocation(selected: GeoLocation | undefined) {
this.selectedLocation.set(this.geoService.enrichGeoLocation(selected));
}
clearLocation() {
this.updateLocation(undefined);
}
/**
* Whether the address text was manually customized by the user, i.e. it no
* longer matches what we would derive from the structured parts or the mapped
* location. Inferred (no flag needed): a meaningful manual edit always makes
* the text diverge from both derivations, so this is self-detecting.
*/
private isTextManuallyOverwritten(
location: GeoLocation | undefined,
): boolean {
const text = location?.locationString?.trim();
if (!text) {
return false;
}
const composedParts = this.geoService
.composeAddressFromParts(location)
.trim();
const displayName = location?.geoLookup?.display_name?.trim();
return text !== composedParts && text !== displayName;
}
/** Drives the in-panel hint that text and structured details are diverging. */
readonly hasDivergingText = computed(() =>
this.isTextManuallyOverwritten(this.selectedLocation()),
);
async updateAddressPart(
key: "road" | "house_number" | "postcode" | "city" | "country",
value: string,
) {
const current = this.selectedLocation();
// Decide BEFORE applying the change: compare the current text against the
// OLD parts. Comparing against the new parts would always differ and would
// falsely ask on every edit.
const textOverwritten = this.isTextManuallyOverwritten(current);
const updated: GeoLocation = { ...current, [key]: value };
const updatedText = this.geoService.composeAddressFromParts(updated);
if (!textOverwritten) {
// Text was auto-derived → keep it in sync automatically.
updated.locationString = updatedText;
this.updateLocation(updated);
return;
}
// Text was manually customized → ask before overwriting it.
const result = await this.confirmationDialog.getConfirmation(
$localize`Update address text?`,
$localize`You changed the address details, so they no longer match the customized address text. Which should be saved?\n\n**Current text:**\n${current?.locationString ?? ""}\n\n**Updated text:**\n${updatedText}`,
[
{
text: $localize`Keep current text`,
dialogResult: "keep",
click: () => {},
},
{
text: $localize`Update to match details`,
dialogResult: "update",
click: () => {},
},
],
);
if (result === "update") {
updated.locationString = updatedText;
}
// "keep" or dismissed: apply the part change but leave the custom text.
this.updateLocation(updated);
}
updateLocationString(value: string) {
const manualAddress: string = value ?? "";
if (manualAddress === "" && this.selectedLocation()?.geoLookup) {
this.clearLocation();
// possible alternative UX: ask user if they want to remove the mapped location also? or update the location with the display_location?
return;
}
this.updateLocation({
...this.selectedLocation(),
locationString: manualAddress,
});
}
/**
* Extracts extra details from the user's input that are not present in the suggestion.
* Handles abbreviations, punctuation, and house numbers, generically.
*/
private extractExtraLine(
userInput: string,
selectedSuggestion: string,
): string {
// Normalize and split into words
const normalize = (str: string) =>
str.replace(/[.,]/g, "").replace(/\s+/g, " ").trim().toLowerCase();
const inputClean = normalize(userInput);
const suggestionClean = normalize(selectedSuggestion);
// Split into word sets for comparison
const inputWords = new Set(inputClean.split(" "));
const suggestionWords = new Set(suggestionClean.split(" "));
// Find words in input that are not in suggestion
const unmatchedWords = Array.from(inputWords).filter(
(word) => word && !suggestionWords.has(word),
);
// Heuristic: Only keep words that look like house numbers, apartments, or short extras
const likelyExtras = unmatchedWords.filter(
(word) =>
/^[0-9]+[a-zA-Z]?$/i.test(word) || // 17a, 12, 5b
/^[a-zA-Z]+[0-9]+$/i.test(word) || // Apt5, Haus7
word.length <= 6, // short extras like "EG", "OG", "Süd"
);
// If nothing matches, fallback to all unmatched words
const resultWords = likelyExtras.length > 0 ? likelyExtras : unmatchedWords;
// Join and capitalize
let result = resultWords.join(" ").trim();
if (result.length > 0) {
result = result.charAt(0).toUpperCase() + result.slice(1);
}
return result;
}
async updateFromAddressSearch(event: {
location: GeoLocation;
userInput: string;
}) {
const value = event.location;
const userInput = event.userInput;
if (
value?.geoLookup === this.selectedLocation()?.geoLookup &&
value?.locationString === this.selectedLocation()?.locationString
) {
// nothing changed, skip
return;
}
let manualAddress: string;
if (userInput && value?.geoLookup?.display_name) {
// Extract only unmatched details from user input
const extra = this.extractExtraLine(
userInput,
value.geoLookup.display_name,
);
if (extra) {
manualAddress = value.geoLookup.display_name + "\n" + extra;
} else {
manualAddress = value.geoLookup.display_name;
}
} else {
manualAddress =
value?.locationString ?? value?.geoLookup?.display_name ?? "";
}
this.updateLocation({
locationString: manualAddress,
geoLookup: value?.geoLookup,
});
}
onGpsLocationSelected(geoResult: GeoResult) {
const newLocation: GeoLocation = {
locationString: geoResult.display_name,
geoLookup: geoResult,
};
// For GPS, we don't have user input, so just use the display name
this.updateLocation(newLocation);
}
}
<div class="address-edit-container full-width">
@if (!disabled()) {
<div class="flex-row gap-small align-center search-row">
<app-address-search
(locationSelected)="updateFromAddressSearch($event)"
class="address-input"
></app-address-search>
<app-address-gps-location
(locationSelected)="onGpsLocationSelected($event)"
></app-address-gps-location>
</div>
}
<div class="selected-location-display">
<span class="location-label hint-text" i18n>Selected Location</span>
<div class="location-value">
@if (
selectedLocation()?.locationString ||
selectedLocation()?.geoLookup?.display_name
) {
{{
selectedLocation()?.locationString ??
selectedLocation()?.geoLookup?.display_name
}}
} @else {
<span class="placeholder" i18n>no location selected</span>
}
</div>
@if (
selectedLocation()?.geoLookup?.display_name !==
selectedLocation()?.locationString
) {
<div class="location-hint hint-text">
@if (selectedLocation()?.geoLookup) {
<span
matTooltip="You have manually edited the address above. This is the actual mapped location. To change the location on the map, use the address search field or click on the map."
i18n-matTooltip
>
{{ selectedLocation()?.geoLookup?.display_name }}
</span>
} @else {
<span
matTooltip="You have manually entered the address above without locating it on the map. To change the location on the map, use the address search field or click on the map."
i18n-matTooltip
i18n
>
No location marked on map
</span>
}
</div>
}
</div>
@if (selectedLocation()) {
<mat-expansion-panel
class="address-details-panel"
(opened)="focusManualAddressInput()"
>
<mat-expansion-panel-header>
<mat-panel-title i18n>Address details</mat-panel-title>
<mat-panel-description i18n
>Road · House no. · Postcode · City · Country</mat-panel-description
>
</mat-expansion-panel-header>
<mat-form-field floatLabel="always" class="full-width">
<mat-label i18n>Full address text</mat-label>
<textarea
#manualAddressInput
matInput
matTooltip="Manually overwrite the address text (e.g. to add details that are not available from the automatic address lookup). The mapped location will be unaffected by this."
i18n-matTooltip
[value]="
selectedLocation()?.locationString ??
selectedLocation()?.geoLookup?.display_name ??
''
"
(change)="updateLocationString(manualAddressInput.value)"
[disabled]="disabled()"
rows="2"
></textarea>
</mat-form-field>
@if (hasDivergingText()) {
<p class="parts-mismatch-hint hint-text" i18n>
This address text has been customized — it no longer matches the
address details below or the mapped location.
</p>
}
<div class="address-parts-grid">
<mat-form-field class="road-field">
<mat-label i18n>Road</mat-label>
<input
matInput
#roadInput
[value]="selectedLocation()?.road ?? ''"
(change)="updateAddressPart('road', roadInput.value)"
[disabled]="disabled()"
/>
</mat-form-field>
<mat-form-field class="house-number-field">
<mat-label i18n>House no.</mat-label>
<input
matInput
#houseNumberInput
[value]="selectedLocation()?.house_number ?? ''"
(change)="updateAddressPart('house_number', houseNumberInput.value)"
[disabled]="disabled()"
/>
</mat-form-field>
<mat-form-field class="postcode-field">
<mat-label i18n>Postcode</mat-label>
<input
matInput
#postcodeInput
[value]="selectedLocation()?.postcode ?? ''"
(change)="updateAddressPart('postcode', postcodeInput.value)"
[disabled]="disabled()"
/>
</mat-form-field>
<mat-form-field class="city-field">
<mat-label i18n>City</mat-label>
<input
matInput
#cityInput
[value]="selectedLocation()?.city ?? ''"
(change)="updateAddressPart('city', cityInput.value)"
[disabled]="disabled()"
/>
</mat-form-field>
<mat-form-field class="full-row">
<mat-label i18n>Country</mat-label>
<input
matInput
#countryInput
[value]="selectedLocation()?.country ?? ''"
(change)="updateAddressPart('country', countryInput.value)"
[disabled]="disabled()"
/>
</mat-form-field>
</div>
@if (!disabled()) {
<div class="text-align-end">
<a
href=""
(click)="clearLocation(); $event.preventDefault()"
matTooltip="Delete the selected location."
i18n-matTooltip
i18n
>Clear location</a
>
</div>
}
</mat-expansion-panel>
}
</div>
./address-edit.component.scss
@use "variables/breakpoints";
@use "variables/colors";
.address-edit-container {
display: flex;
gap: 16px;
align-items: baseline;
flex-wrap: wrap;
@media (max-width: breakpoints.$md) {
gap: 8px;
}
}
.search-row {
flex: 1 1 250px;
min-width: 0; // Allow flex items to shrink below content size
@media (max-width: breakpoints.$md) {
flex: 1 1 100%;
}
}
.address-input {
flex: 1;
min-width: 0;
}
.selected-location-display {
flex: 1 1 100%;
.location-label {
display: block;
}
.location-value {
white-space: pre-line;
.placeholder {
color: colors.$muted;
}
}
}
.address-details-panel {
flex: 1 1 100%;
}
.address-parts-grid {
display: grid;
// 3-column base: Road gets 2/3, House no. 1/3; Postcode 1/3, City 2/3
grid-template-columns: 2fr 1fr 2fr;
gap: 0 16px;
@media (max-width: breakpoints.$md) {
grid-template-columns: 1fr;
}
.road-field {
grid-column: 1 / 3;
}
.house-number-field {
grid-column: 3 / 4;
}
.postcode-field {
grid-column: 1 / 2;
}
.city-field {
grid-column: 2 / 4;
}
.full-row {
grid-column: 1 / -1;
}
}
.parts-mismatch-hint {
color: colors.$warn;
}