diff --git a/src/app/core/services/edit/edit.service.spec.ts b/src/app/core/services/edit/edit.service.spec.ts
index 5b83814fe..e211e9146 100644
--- a/src/app/core/services/edit/edit.service.spec.ts
+++ b/src/app/core/services/edit/edit.service.spec.ts
@@ -16,6 +16,7 @@ import { DeviceService } from '@shared/services/device/device.service';
import { DialogCdkService } from '@root/app/dialog-cdk/dialog-cdk.service';
import { SharingComponent } from '@fileBrowser/components/sharing/sharing.component';
import { SharingDialogComponent } from '@fileBrowser/components/sharing-dialog/sharing-dialog.component';
+import { CoordinatePickerComponent } from '@fileBrowser/components/coordinate-picker/coordinate-picker.component';
import { LocationPickerComponent } from '@fileBrowser/components/location-picker/location-picker.component';
import { UncertainLocationPickerComponent } from '@fileBrowser/components/uncertain-location-picker/uncertain-location-picker.component';
import { FeatureFlagService } from '@root/app/feature-flag/services/feature-flag.service';
@@ -578,6 +579,24 @@ describe('EditService', () => {
});
});
+ describe('openCoordinateDialog', () => {
+ it('should open CoordinatePickerComponent for the item', () => {
+ const record = new RecordVO({ recordId: 123 });
+
+ service.openCoordinateDialog(record);
+
+ expect(dialogService.open).toHaveBeenCalledOnceWith(
+ CoordinatePickerComponent,
+ {
+ data: { item: record },
+ panelClass: 'dialog',
+ height: 'auto',
+ width: '640px',
+ },
+ );
+ });
+ });
+
describe('openShareDialog', () => {
const mockShareLink: ShareLink = {
id: 'link1',
diff --git a/src/app/core/services/edit/edit.service.ts b/src/app/core/services/edit/edit.service.ts
index 5bc8a2659..cd70035d0 100644
--- a/src/app/core/services/edit/edit.service.ts
+++ b/src/app/core/services/edit/edit.service.ts
@@ -42,6 +42,7 @@ import { PublishComponent } from '@fileBrowser/components/publish/publish.compon
import { EditTagsComponent } from '@fileBrowser/components/edit-tags/edit-tags.component';
import { LocationPickerComponent } from '@fileBrowser/components/location-picker/location-picker.component';
import { UncertainLocationPickerComponent } from '@fileBrowser/components/uncertain-location-picker/uncertain-location-picker.component';
+import { CoordinatePickerComponent } from '@fileBrowser/components/coordinate-picker/coordinate-picker.component';
import { FeatureFlagService } from '@root/app/feature-flag/services/feature-flag.service';
import { SharingDialogComponent } from '@fileBrowser/components/sharing-dialog/sharing-dialog.component';
import { FolderPickerService } from '../folder-picker/folder-picker.service';
@@ -647,6 +648,20 @@ export class EditService {
});
}
+ /**
+ * The coordinate half of a location, which the uncertain address modal
+ * leaves alone. Only reachable behind `uncertain-locations`, which is what
+ * splits a location into an address and a pair in the first place.
+ */
+ openCoordinateDialog(item: ItemVO): void {
+ this.dialog.open(CoordinatePickerComponent, {
+ data: { item },
+ panelClass: 'dialog',
+ height: 'auto',
+ width: '640px',
+ });
+ }
+
public async openFolderPicker(
items: ItemVO[],
operation: FolderPickerOperations,
diff --git a/src/app/file-browser/components/coordinate-picker/coordinate-picker.component.html b/src/app/file-browser/components/coordinate-picker/coordinate-picker.component.html
new file mode 100644
index 000000000..f7fbe4f51
--- /dev/null
+++ b/src/app/file-browser/components/coordinate-picker/coordinate-picker.component.html
@@ -0,0 +1,12 @@
+
+
+
diff --git a/src/app/file-browser/components/coordinate-picker/coordinate-picker.component.scss b/src/app/file-browser/components/coordinate-picker/coordinate-picker.component.scss
new file mode 100644
index 000000000..d0a69bc1e
--- /dev/null
+++ b/src/app/file-browser/components/coordinate-picker/coordinate-picker.component.scss
@@ -0,0 +1,3 @@
+:host {
+ display: block;
+}
diff --git a/src/app/file-browser/components/coordinate-picker/coordinate-picker.component.spec.ts b/src/app/file-browser/components/coordinate-picker/coordinate-picker.component.spec.ts
new file mode 100644
index 000000000..add750f26
--- /dev/null
+++ b/src/app/file-browser/components/coordinate-picker/coordinate-picker.component.spec.ts
@@ -0,0 +1,170 @@
+import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog';
+import { GoogleMapsModule } from '@angular/google-maps';
+import { RecordVO } from '@models';
+import { ProfileItemVOData } from '@models/profile-item-vo';
+import { CoordinateMapInputComponent } from '@shared/components/coordinate-map-input/coordinate-map-input.component';
+import {
+ CoordinatePickerComponent,
+ CoordinatePickerData,
+} from './coordinate-picker.component';
+
+const LISBON = { latitude: 38.70786, longitude: -9.400139 };
+
+describe('CoordinatePickerComponent', () => {
+ let fixture: ComponentFixture;
+ let component: CoordinatePickerComponent;
+ let dialogRef: jasmine.SpyObj;
+
+ const setUp = async (dialogData: CoordinatePickerData): Promise => {
+ dialogRef = jasmine.createSpyObj('DialogRef', ['close']);
+
+ await TestBed.configureTestingModule({
+ imports: [CoordinatePickerComponent],
+ providers: [
+ { provide: DIALOG_DATA, useValue: dialogData },
+ { provide: DialogRef, useValue: dialogRef },
+ ],
+ })
+ .overrideComponent(CoordinateMapInputComponent, {
+ remove: { imports: [GoogleMapsModule] },
+ add: { schemas: [CUSTOM_ELEMENTS_SCHEMA] },
+ })
+ .compileComponents();
+
+ fixture = TestBed.createComponent(CoordinatePickerComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ };
+
+ const query = (selector: string): T =>
+ fixture.nativeElement.querySelector(selector);
+
+ describe('with an item that has no location', () => {
+ const item = new RecordVO({ recordId: 123 });
+
+ beforeEach(async () => {
+ await setUp({ item });
+ });
+
+ it('should read the item off the dialog data', () => {
+ expect(component.item).toBe(item);
+ });
+
+ it('should render a titled dialog', () => {
+ expect(query('.pr-dialog-header h2').textContent.trim()).toBe(
+ 'Choose GPS Coordinates',
+ );
+ });
+
+ it('should hand the map input nothing to start from', () => {
+ expect(component.coordinates()).toBeNull();
+ });
+ });
+
+ describe('when the item already has coordinates', () => {
+ beforeEach(async () => {
+ await setUp({
+ item: new RecordVO({
+ recordId: 123,
+ LocnVO: { ...LISBON, city: 'Lisbon' },
+ }),
+ });
+ });
+
+ it('should hand the stored pair to the map input', () => {
+ expect(component.coordinates()).toEqual(LISBON);
+ });
+
+ it('should keep the address it was given when saving', () => {
+ component.save();
+
+ expect(dialogRef.close).toHaveBeenCalledWith({
+ location: { ...LISBON, city: 'Lisbon' },
+ });
+ });
+
+ it('should clear the stored pair when the map input reports none', () => {
+ component.onCoordinatesChange(null);
+ component.save();
+
+ expect(dialogRef.close).toHaveBeenCalledWith({
+ location: { latitude: null, longitude: null, city: 'Lisbon' },
+ });
+ });
+ });
+
+ describe('when the item has an address but no coordinates', () => {
+ beforeEach(async () => {
+ await setUp({
+ item: new RecordVO({ recordId: 123, LocnVO: { city: 'Lisbon' } }),
+ });
+ });
+
+ it('should still carry the address through a save', () => {
+ component.save();
+
+ expect(dialogRef.close).toHaveBeenCalledWith({
+ location: { city: 'Lisbon', latitude: null, longitude: null },
+ });
+ });
+ });
+
+ describe('when a profile item carries the location', () => {
+ it('should read the first of its locations', async () => {
+ await setUp({
+ profileItem: { LocnVOs: [{ ...LISBON }] } as ProfileItemVOData,
+ });
+
+ expect(component.coordinates()).toEqual(LISBON);
+ });
+ });
+
+ describe('when the map input reports unreadable text', () => {
+ beforeEach(async () => {
+ await setUp({
+ item: new RecordVO({ recordId: 123, LocnVO: { ...LISBON } }),
+ });
+ });
+
+ it('should disable the confirm button', () => {
+ component.onValidityChange(false);
+ fixture.detectChanges();
+
+ expect(query('.pr-btn-confirm').disabled).toBeTrue();
+ });
+
+ it('should refuse to save the pair the field is no longer showing', () => {
+ component.onValidityChange(false);
+ component.save();
+
+ expect(dialogRef.close).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('leaving the dialog', () => {
+ beforeEach(async () => {
+ await setUp({ item: new RecordVO({ recordId: 123 }) });
+ });
+
+ it('should close with nothing when cancelled', () => {
+ query('.pr-btn-cancel').click();
+
+ expect(dialogRef.close).toHaveBeenCalledWith();
+ });
+
+ it('should close with nothing when dismissed from the header', () => {
+ query('.pr-close-button').click();
+
+ expect(dialogRef.close).toHaveBeenCalledWith();
+ });
+
+ it('should close with the pair when saved', () => {
+ component.onCoordinatesChange(LISBON);
+ query('.pr-btn-confirm').click();
+
+ expect(dialogRef.close).toHaveBeenCalledWith({ location: LISBON });
+ });
+ });
+});
diff --git a/src/app/file-browser/components/coordinate-picker/coordinate-picker.component.ts b/src/app/file-browser/components/coordinate-picker/coordinate-picker.component.ts
new file mode 100644
index 000000000..63ea98239
--- /dev/null
+++ b/src/app/file-browser/components/coordinate-picker/coordinate-picker.component.ts
@@ -0,0 +1,84 @@
+import { Component, Inject, OnInit, Optional, signal } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog';
+import { ItemVO, LocnVOData } from '@models';
+import { ProfileItemVOData } from '@models/profile-item-vo';
+import { CoordinateMapInputComponent } from '@shared/components/coordinate-map-input/coordinate-map-input.component';
+import { DialogFrameComponent } from '@shared/components/dialog-frame/dialog-frame.component';
+import {
+ Coordinates,
+ coordinatesFromLocation,
+} from '@shared/utilities/coordinates';
+
+export interface CoordinatePickerData {
+ item?: ItemVO;
+ profileItem?: ProfileItemVOData;
+}
+
+export interface CoordinatePickerResult {
+ location: LocnVOData;
+}
+
+@Component({
+ selector: 'pr-coordinate-picker',
+ standalone: true,
+ imports: [CommonModule, CoordinateMapInputComponent, DialogFrameComponent],
+ templateUrl: './coordinate-picker.component.html',
+ styleUrls: ['./coordinate-picker.component.scss'],
+})
+export class CoordinatePickerComponent implements OnInit {
+ public item?: ItemVO;
+ public profileItem?: ProfileItemVOData;
+
+ coordinates = signal(null);
+ isValid = signal(true);
+
+ private locationBeingEdited: LocnVOData = {};
+
+ constructor(
+ @Optional()
+ @Inject(DIALOG_DATA)
+ public dialogData?: CoordinatePickerData,
+ @Optional() private dialogRef?: DialogRef,
+ ) {
+ if (this.dialogData) {
+ this.item = this.dialogData.item;
+ this.profileItem = this.dialogData.profileItem;
+ }
+ }
+
+ ngOnInit(): void {
+ const existing = this.item?.LocnVO ?? this.profileItem?.LocnVOs?.[0];
+ if (!existing) {
+ return;
+ }
+ this.locationBeingEdited = { ...existing };
+ this.coordinates.set(coordinatesFromLocation(existing));
+ }
+
+ public onCoordinatesChange(coordinates: Coordinates | null): void {
+ this.coordinates.set(coordinates);
+ }
+
+ public onValidityChange(isValid: boolean): void {
+ this.isValid.set(isValid);
+ }
+
+ public cancel(): void {
+ this.dialogRef?.close();
+ }
+
+ public save(): void {
+ if (!this.isValid()) {
+ return;
+ }
+ const coordinates = this.coordinates();
+ this.dialogRef?.close({
+ location: {
+ ...this.locationBeingEdited,
+ latitude: coordinates?.latitude ?? null,
+ longitude: coordinates?.longitude ?? null,
+ },
+ });
+ }
+}
diff --git a/src/app/file-browser/components/sidebar/sidebar.component.html b/src/app/file-browser/components/sidebar/sidebar.component.html
index 584ee8a9e..b4441e581 100644
--- a/src/app/file-browser/components/sidebar/sidebar.component.html
+++ b/src/app/file-browser/components/sidebar/sidebar.component.html
@@ -148,32 +148,71 @@
}