From 950641d918f5643dac7c77b402fc48f1fdd65da8 Mon Sep 17 00:00:00 2001 From: Daniel Schultz Date: Mon, 27 Apr 2026 12:14:28 -0400 Subject: [PATCH 1/2] Render new location metadata fields The backend is changing which location metadata fields to store and we need to use those fields. Issue #1001 Use new location metadata fields --- .../location-picker.component.spec.ts | 187 +++++++++++++++--- .../location-picker.component.ts | 24 +-- src/app/models/locn-vo.ts | 7 + src/app/shared/pipes/pr-location.pipe.spec.ts | 54 +++++ src/app/shared/pipes/pr-location.pipe.ts | 15 +- .../shared/services/api/record.repo.spec.ts | 60 +++++- src/app/shared/services/api/record.repo.ts | 42 ++-- 7 files changed, 327 insertions(+), 62 deletions(-) create mode 100644 src/app/shared/pipes/pr-location.pipe.spec.ts diff --git a/src/app/file-browser/components/location-picker/location-picker.component.spec.ts b/src/app/file-browser/components/location-picker/location-picker.component.spec.ts index 612d35d3a..e3fb9197a 100644 --- a/src/app/file-browser/components/location-picker/location-picker.component.spec.ts +++ b/src/app/file-browser/components/location-picker/location-picker.component.spec.ts @@ -1,25 +1,162 @@ -// import { async, ComponentFixture, TestBed } from '@angular/core/testing'; - -// import { LocationPickerComponent } from './location-picker.component'; - -// describe('LocationPickerComponent', () => { -// let component: LocationPickerComponent; -// let fixture: ComponentFixture; - -// beforeEach(async(() => { -// TestBed.configureTestingModule({ -// declarations: [ LocationPickerComponent ] -// }) -// .compileComponents(); -// })); - -// beforeEach(() => { -// fixture = TestBed.createComponent(LocationPickerComponent); -// component = fixture.componentInstance; -// fixture.detectChanges(); -// }); - -// it('should create', () => { -// expect(component).toBeTruthy(); -// }); -// }); +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { ApiService } from '@shared/services/api/api.service'; +import { MessageService } from '@shared/services/message/message.service'; +import { EditService } from '@core/services/edit/edit.service'; +import { ProfileService } from '@shared/services/profile/profile.service'; +import { PrLocationPipe } from '@shared/pipes/pr-location.pipe'; +import { LocationPickerComponent } from './location-picker.component'; + +const fakeGoogleMaps = { + LatLng: class { + constructor(public input: unknown) {} + }, + places: { + Autocomplete: class { + setFields(): void {} + addListener(): void {} + getPlace(): unknown { + return null; + } + }, + }, +}; + +const buildAddressComponents = ( + overrides: Partial< + Record + > = {}, +): google.maps.GeocoderAddressComponent[] => { + const defaults: Record = { + street_number: { long_name: '55', short_name: '55' }, + route: { long_name: 'Rue Plumet', short_name: 'Rue Plumet' }, + locality: { long_name: 'Paris', short_name: 'Paris' }, + postal_code: { long_name: '75007', short_name: '75007' }, + administrative_area_level_1: { + long_name: 'Ile-de-France', + short_name: 'IDF', + }, + country: { long_name: 'France', short_name: 'FR' }, + }; + const merged = { ...defaults, ...overrides }; + return Object.entries(merged) + .filter(([, value]) => value !== undefined) + .map(([type, value]) => ({ + long_name: value.long_name, + short_name: value.short_name, + types: [type], + })) as google.maps.GeocoderAddressComponent[]; +}; + +const buildPlace = ( + overrides: Partial = {}, + addressOverrides: + | Parameters[0] + | undefined = undefined, +): google.maps.places.PlaceResult => + ({ + name: "Jean Valjean's House", + address_components: buildAddressComponents(addressOverrides), + geometry: { + location: { + lat: () => 48.83, + lng: () => 2.3, + }, + }, + ...overrides, + }) as unknown as google.maps.places.PlaceResult; + +describe('LocationPickerComponent', () => { + let fixture: ComponentFixture; + let component: LocationPickerComponent; + const testWindow = window as unknown as { google?: unknown }; + let previousGoogle: unknown; + let hadGoogle = false; + + beforeAll(() => { + hadGoogle = 'google' in testWindow; + previousGoogle = testWindow.google; + testWindow.google = { maps: fakeGoogleMaps }; + }); + + afterAll(() => { + if (hadGoogle) { + testWindow.google = previousGoogle; + } else { + delete testWindow.google; + } + }); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [LocationPickerComponent], + providers: [ + { provide: ApiService, useValue: {} }, + { provide: MessageService, useValue: {} }, + { provide: EditService, useValue: {} }, + { provide: ProfileService, useValue: {} }, + { provide: PrLocationPipe, useValue: { transform: () => ({}) } }, + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(LocationPickerComponent); + component = fixture.componentInstance; + }); + + describe('createLocnFromPlace', () => { + it('populates the spec-aligned fields', () => { + const locn = component.createLocnFromPlace(buildPlace()); + + expect(locn.sublocation).toBe('55 Rue Plumet'); + expect(locn.city).toBe('Paris'); + expect(locn.adminOneName).toBe('Ile-de-France'); + expect(locn.country).toBe('France'); + expect(locn.postalCode).toBe('75007'); + expect(locn.latitude).toBe(48.83); + expect(locn.longitude).toBe(2.3); + }); + + it('does not write deprecated legacy fields', () => { + const locn = component.createLocnFromPlace(buildPlace()); + + expect(locn.streetNumber).toBeUndefined(); + expect(locn.streetName).toBeUndefined(); + expect(locn.locality).toBeUndefined(); + expect(locn.countryCode).toBeUndefined(); + expect(locn.adminOneCode).toBeUndefined(); + expect(locn.adminTwoName).toBeUndefined(); + expect(locn.adminTwoCode).toBeUndefined(); + }); + + it('sets sublocation to null when no street number or street name is available', () => { + const locn = component.createLocnFromPlace( + buildPlace({}, { street_number: undefined, route: undefined }), + ); + + expect(locn.sublocation).toBeNull(); + }); + + it('falls back to streetName alone when streetNumber is absent', () => { + const locn = component.createLocnFromPlace( + buildPlace({}, { street_number: undefined }), + ); + + expect(locn.sublocation).toBe('Rue Plumet'); + }); + + it('writes name when the place name does not include the sublocation', () => { + const locn = component.createLocnFromPlace(buildPlace()); + + expect(locn.name).toBe("Jean Valjean's House"); + }); + + it('omits name when the place name already matches the sublocation', () => { + const locn = component.createLocnFromPlace( + buildPlace({ name: '55 Rue Plumet' }), + ); + + expect(locn.name).toBeUndefined(); + }); + }); +}); diff --git a/src/app/file-browser/components/location-picker/location-picker.component.ts b/src/app/file-browser/components/location-picker/location-picker.component.ts index 14cc1b15d..603bd3ffd 100644 --- a/src/app/file-browser/components/location-picker/location-picker.component.ts +++ b/src/app/file-browser/components/location-picker/location-picker.component.ts @@ -229,32 +229,32 @@ export class LocationPickerComponent implements OnInit, AfterViewInit { createLocnFromPlace(place: google.maps.places.PlaceResult) { const addr = place.address_components; + const streetNumber = getComponentName(addr, 'street_number'); + const streetName = getComponentName(addr, 'route'); + const sublocation = + [streetNumber, streetName].filter(Boolean).join(' ') || null; const locn: LocnVOData = { latitude: place.geometry.location.lat(), longitude: place.geometry.location.lng(), - streetNumber: getComponentName(addr, 'street_number'), - streetName: getComponentName(addr, 'route'), postalCode: getComponentName(addr, 'postal_code'), - locality: getComponentName(addr, 'locality'), adminOneName: getComponentName(addr, 'administrative_area_level_1'), - adminOneCode: getComponentName(addr, 'administrative_area_level_1', true), - adminTwoName: getComponentName(addr, 'administrative_area_level_2'), - adminTwoCode: getComponentName(addr, 'administrative_area_level_2', true), country: getComponentName(addr, 'country'), - countryCode: getComponentName(addr, 'country', true), + sublocation, + city: getComponentName(addr, 'locality'), }; + if (place.name && (!sublocation || !place.name.includes(sublocation))) { + locn.name = place.name; + } + return locn; function getComponentName( addressComponents: google.maps.GeocoderAddressComponent[], - type, - getShortName = true, + type: string, ) { const component = find(addressComponents, (c) => c.types.includes(type)); - return component - ? component[getShortName ? 'short_name' : 'long_name'] - : null; + return component ? component.long_name : null; } } } diff --git a/src/app/models/locn-vo.ts b/src/app/models/locn-vo.ts index 409b9fa0e..2ebbfb20e 100644 --- a/src/app/models/locn-vo.ts +++ b/src/app/models/locn-vo.ts @@ -1,5 +1,7 @@ import { BaseVOData } from '@models/base-vo'; +export type LocationPrecision = 'approximate' | 'uncertain' | 'unknown'; + export interface LocnVOData extends BaseVOData { locnId?: number; timeZoneId?: number; @@ -25,6 +27,11 @@ export interface LocnVOData extends BaseVOData { geometryAsArray?: string; geoCodeType?: string; geoCodeResponseAsXml?: string; + name?: string; + sublocation?: string; + city?: string; + altitudeMeters?: number; + locationPrecision?: LocationPrecision; status?: string; type?: string; } diff --git a/src/app/shared/pipes/pr-location.pipe.spec.ts b/src/app/shared/pipes/pr-location.pipe.spec.ts new file mode 100644 index 000000000..62235f747 --- /dev/null +++ b/src/app/shared/pipes/pr-location.pipe.spec.ts @@ -0,0 +1,54 @@ +import { LocnVOData } from '@models'; +import { PrLocationPipe } from './pr-location.pipe'; + +describe('PrLocationPipe', () => { + let pipe: PrLocationPipe; + + beforeEach(() => { + pipe = new PrLocationPipe(); + }); + + it('returns null when input is null', () => { + expect(pipe.transform(null)).toBeNull(); + }); + + it('renders the spec-aligned fields', () => { + const locn: LocnVOData = { + sublocation: '55 Rue Plumet', + city: 'Paris', + adminOneName: 'Ile-de-France', + country: 'France', + latitude: 48.83, + longitude: 2.3, + }; + + const output = pipe.transform(locn); + + expect(output.line1).toBe('55 Rue Plumet'); + expect(output.line2).toContain('Paris'); + expect(output.full.startsWith('55 Rue Plumet, ')).toBe(true); + }); + + it('falls through to lat/long when address fields are missing', () => { + const locn: LocnVOData = { + latitude: 48.83, + longitude: 2.3, + country: 'France', + }; + + const output = pipe.transform(locn); + + expect(output.line1).toBe('48.83, 2.3'); + expect(output.line2).toContain('France'); + }); + + it('reports unknown when no address or coordinates fall through to line2', () => { + const locn: LocnVOData = { + sublocation: '55 Rue Plumet', + }; + + const output = pipe.transform(locn); + + expect(output.line2).toBe('Unknown Location'); + }); +}); diff --git a/src/app/shared/pipes/pr-location.pipe.ts b/src/app/shared/pipes/pr-location.pipe.ts index 27d732043..4327c9d1e 100644 --- a/src/app/shared/pipes/pr-location.pipe.ts +++ b/src/app/shared/pipes/pr-location.pipe.ts @@ -19,16 +19,19 @@ export class PrLocationPipe implements PipeTransform { const output: LocnPipeOutput = {}; + const hasCoordinates = + locnVO.latitude != null && + locnVO.latitude !== '' && + locnVO.longitude != null && + locnVO.longitude !== ''; + // order by priority/usefulness const queue = [ - locnVO.streetNumber - ? locnVO.streetNumber + ' ' + locnVO.streetName - : locnVO.streetName, - locnVO.locality, + locnVO.sublocation, + locnVO.city, locnVO.adminOneName, - locnVO.latitude + ', ' + locnVO.longitude, + hasCoordinates ? `${locnVO.latitude}, ${locnVO.longitude}` : null, locnVO.country, - locnVO.countryCode, ]; const line2 = []; diff --git a/src/app/shared/services/api/record.repo.spec.ts b/src/app/shared/services/api/record.repo.spec.ts index b027956b1..2b41ea893 100644 --- a/src/app/shared/services/api/record.repo.spec.ts +++ b/src/app/shared/services/api/record.repo.spec.ts @@ -6,7 +6,12 @@ import { import { of } from 'rxjs'; import { environment } from '@root/environments/environment'; import { HttpService } from '@shared/services/http/http.service'; -import { RecordRepo, RecordResponse } from '@shared/services/api/record.repo'; +import { + RecordRepo, + RecordResponse, + StelaLocation, + convertStelaLocationToLocnVOData, +} from '@shared/services/api/record.repo'; import { RecordVO } from '@root/app/models'; import { provideHttpClient, @@ -222,6 +227,59 @@ describe('RecordRepo', () => { }); }); + describe('convertStelaLocationToLocnVOData', () => { + it('returns null when the stela location has no id', () => { + expect(convertStelaLocationToLocnVOData(null)).toBeNull(); + expect( + convertStelaLocationToLocnVOData({ id: '' } as StelaLocation), + ).toBeNull(); + }); + + it('parses the id and remaps state/precision onto the LocnVO shape', () => { + const stelaLocation: StelaLocation = { + id: '42', + name: "Jean Valjean's House", + sublocation: '55 Rue Plumet', + city: 'Paris', + state: 'Ile-de-France', + postalCode: '75007', + country: 'France', + latitude: 48.83, + longitude: 2.3, + altitudeMeters: 35, + precision: 'approximate', + }; + + const result = convertStelaLocationToLocnVOData(stelaLocation); + + expect(result.locnId).toBe(42); + expect(result.name).toBe("Jean Valjean's House"); + expect(result.sublocation).toBe('55 Rue Plumet'); + expect(result.city).toBe('Paris'); + expect(result.adminOneName).toBe('Ile-de-France'); + expect(result.postalCode).toBe('75007'); + expect(result.country).toBe('France'); + expect(result.altitudeMeters).toBe(35); + expect(result.locationPrecision).toBe('approximate'); + + expect((result as Record).state).toBeUndefined(); + expect((result as Record).precision).toBeUndefined(); + }); + + it('coerces null state/precision to undefined', () => { + const stelaLocation: StelaLocation = { + id: '7', + state: null, + precision: null, + }; + + const result = convertStelaLocationToLocnVOData(stelaLocation); + + expect(result.adminOneName).toBeUndefined(); + expect(result.locationPrecision).toBeUndefined(); + }); + }); + describe('updateStelaRecord', () => { let httpV2PatchSpy: jasmine.Spy; let httpSendRequestPromiseSpy: jasmine.Spy; diff --git a/src/app/shared/services/api/record.repo.ts b/src/app/shared/services/api/record.repo.ts index b39dfb2cd..f0e5c7c78 100644 --- a/src/app/shared/services/api/record.repo.ts +++ b/src/app/shared/services/api/record.repo.ts @@ -5,6 +5,7 @@ import { FolderLinkType, ShareVO, LocnVOData, + LocationPrecision, } from '@root/app/models'; import { BaseResponse, @@ -71,16 +72,16 @@ interface StelaFile { } export interface StelaLocation { id: string; - streetNumber: string; - streetName: string; - locality: string; - county: string; - state: string; - latitude: number; - longitude: number; - country: string; - countryCode: string; - displayName: string | null; + name?: string | null; + sublocation?: string | null; + city?: string | null; + state?: string | null; + postalCode?: string | null; + country?: string | null; + latitude?: number | null; + longitude?: number | null; + altitudeMeters?: number | null; + precision?: LocationPrecision | null; } interface StelaArchive { id: string; @@ -157,14 +158,19 @@ export const convertStelaSharetoShareVO = (stelaShare: StelaShare): ShareVO => }); export const convertStelaLocationToLocnVOData = ( - stelaLocation: StelaLocation, -): LocnVOData => - stelaLocation?.id - ? { - ...stelaLocation, - locnId: Number.parseInt(stelaLocation.id, 10), - } - : null; + stelaLocation: StelaLocation | null | undefined, +): LocnVOData | null => { + if (!stelaLocation?.id) { + return null; + } + const { state, precision, ...rest } = stelaLocation; + return { + ...rest, + locnId: Number.parseInt(stelaLocation.id, 10), + adminOneName: state ?? undefined, + locationPrecision: precision ?? undefined, + }; +}; export const convertStelaRecordToRecordVO = ( stelaRecord: StelaRecord, From fcc3fd0874208d586f408c62b06a66622a280a15 Mon Sep 17 00:00:00 2001 From: Daniel Schultz Date: Sun, 31 May 2026 15:39:53 -0400 Subject: [PATCH 2/2] Use stela endpoints for folder and record location Stela now supports editing location metadata using the modern location fields. This change helps us migrate from the use PHP and the old model for locations. Issue #1001 Use new location metadata fields --- .../core/services/edit/edit.service.spec.ts | 20 ++++- src/app/core/services/edit/edit.service.ts | 66 +++++++++----- .../shared/services/api/folder.repo.spec.ts | 28 ++++++ src/app/shared/services/api/folder.repo.ts | 23 ++++- .../shared/services/api/record.repo.spec.ts | 85 ++++++++++++++++++- src/app/shared/services/api/record.repo.ts | 76 +++++++++++++++-- 6 files changed, 263 insertions(+), 35 deletions(-) diff --git a/src/app/core/services/edit/edit.service.spec.ts b/src/app/core/services/edit/edit.service.spec.ts index cfde96814..3ae6a3d7f 100644 --- a/src/app/core/services/edit/edit.service.spec.ts +++ b/src/app/core/services/edit/edit.service.spec.ts @@ -148,7 +148,10 @@ describe('EditService', () => { await service.updateItems([record], ['displayTime']); - expect(apiService.record.updateStelaRecord).toHaveBeenCalledWith(record); + expect(apiService.record.updateStelaRecord).toHaveBeenCalledWith(record, [ + 'displayTime', + ]); + expect(apiService.record.update).not.toHaveBeenCalled(); expect(apiService.record.get).toHaveBeenCalledWith([record]); }); @@ -202,7 +205,10 @@ describe('EditService', () => { await service.updateItems([record], ['displayTime', 'displayName']); - expect(apiService.record.updateStelaRecord).toHaveBeenCalledWith(record); + expect(apiService.record.updateStelaRecord).toHaveBeenCalledWith(record, [ + 'displayTime', + ]); + expect(apiService.record.update).toHaveBeenCalled(); expect(apiService.record.get).toHaveBeenCalledWith([record]); }); @@ -358,7 +364,10 @@ describe('EditService', () => { await service.updateItems(mockFolders, ['displayTime']); - expect(apiService.folder.updateStelaFolder).toHaveBeenCalledWith(folder); + expect(apiService.folder.updateStelaFolder).toHaveBeenCalledWith(folder, [ + 'displayTime', + ]); + expect(apiService.folder.update).not.toHaveBeenCalled(); expect(apiService.folder.getStelaFolderVOs).toHaveBeenCalledWith( mockFolders, @@ -425,7 +434,10 @@ describe('EditService', () => { await service.updateItems(mockFolders, ['displayTime', 'displayName']); - expect(apiService.folder.updateStelaFolder).toHaveBeenCalledWith(folder); + expect(apiService.folder.updateStelaFolder).toHaveBeenCalledWith(folder, [ + 'displayTime', + ]); + expect(apiService.folder.update).toHaveBeenCalledWith(mockFolders, [ 'displayName', ]); diff --git a/src/app/core/services/edit/edit.service.ts b/src/app/core/services/edit/edit.service.ts index 229e72f8b..a0065f0ce 100644 --- a/src/app/core/services/edit/edit.service.ts +++ b/src/app/core/services/edit/edit.service.ts @@ -688,16 +688,29 @@ export class EditService { const promises: Array> = recordKey?.length - ? recordKey.map(async (key) => - key === 'displayTime' - ? await Promise.all( - records.map( - async (record) => - await this.api.record.updateStelaRecord(record), - ), - ) - : await this.api.record.update(records, archiveId), - ) + ? recordKey.map(async (key) => { + if (key === 'displayTime') { + return await Promise.all( + records.map( + async (record) => + await this.api.record.updateStelaRecord(record, [ + 'displayTime', + ]), + ), + ); + } + if (key === 'LocnVO') { + return await Promise.all( + records.map( + async (record) => + await this.api.record.updateStelaRecord(record, [ + 'location', + ]), + ), + ); + } + return await this.api.record.update(records, archiveId); + }) : [this.api.record.update(records, archiveId)]; await Promise.all(promises); @@ -714,16 +727,29 @@ export class EditService { const promises: Array> = folderKeys?.length - ? folderKeys.map(async (key) => - key === 'displayTime' - ? await Promise.all( - folders.map( - async (folder) => - await this.api.folder.updateStelaFolder(folder), - ), - ) - : await this.api.folder.update(folders, [key]), - ) + ? folderKeys.map(async (key) => { + if (key === 'displayTime') { + return await Promise.all( + folders.map( + async (folder) => + await this.api.folder.updateStelaFolder(folder, [ + 'displayTime', + ]), + ), + ); + } + if (key === 'LocnVO') { + return await Promise.all( + folders.map( + async (folder) => + await this.api.folder.updateStelaFolder(folder, [ + 'location', + ]), + ), + ); + } + return await this.api.folder.update(folders, [key]); + }) : [this.api.folder.update(folders)]; await Promise.all(promises); diff --git a/src/app/shared/services/api/folder.repo.spec.ts b/src/app/shared/services/api/folder.repo.spec.ts index bea2d65f3..6e36847a8 100644 --- a/src/app/shared/services/api/folder.repo.spec.ts +++ b/src/app/shared/services/api/folder.repo.spec.ts @@ -286,6 +286,34 @@ describe('Folder repo', () => { expect(result.Results[0][0].FolderVO.folderId).toBe('123'); expect(result.Results[0][0].FolderVO.displayName).toBe('Test Folder'); }); + + it('should send the location (and not displayTime) when updating location', async () => { + const folderVO = new FolderVO({ + folderId: 123, + displayTime: '1985-05-20T00:00:00Z', + LocnVO: { + city: 'Paris', + adminOneName: 'Ile-de-France', + country: 'France', + latitude: 48.83, + longitude: 2.3, + }, + }); + + httpV2Spy.patch.and.returnValue(of([mockStelaFolder])); + + await folderRepo.updateStelaFolder(folderVO, ['location']); + + expect(httpV2Spy.patch).toHaveBeenCalledWith('v2/folder/123', { + location: { + city: 'Paris', + state: 'Ile-de-France', + country: 'France', + latitude: 48.83, + longitude: 2.3, + }, + }); + }); }); describe('getStelaFolderVOs', () => { diff --git a/src/app/shared/services/api/folder.repo.ts b/src/app/shared/services/api/folder.repo.ts index 87ffa4ae3..01718ae7c 100644 --- a/src/app/shared/services/api/folder.repo.ts +++ b/src/app/shared/services/api/folder.repo.ts @@ -4,11 +4,13 @@ import { firstValueFrom, Observable } from 'rxjs'; import { DataStatus } from '@models/data-status.enum'; import { ShareLink } from '@root/app/share-links/models/share-link'; import { + convertLocnVODataToStelaLocation, convertStelaLocationToLocnVOData, convertStelaRecordToRecordVO, convertStelaSharetoShareVO, convertStelaTagToTagVO, StelaLocation, + StelaLocationUpdateRequest, StelaShare, StelaTag, type StelaRecord, @@ -234,10 +236,23 @@ export class FolderRepo extends BaseRepo { return folderResponse?.items || []; } - public async updateStelaFolder(folderVO: FolderVO): Promise { - const payload = { - displayTime: folderVO.displayTime, - }; + public async updateStelaFolder( + folderVO: FolderVO, + fields: Array<'displayTime' | 'location'> = ['displayTime'], + ): Promise { + // We patch the folder itself, sending only the fields being updated. + // Locations are written as part of the folder rather than via standalone + // location objects. + const payload: { + displayTime?: string; + location?: StelaLocationUpdateRequest; + } = {}; + if (fields.includes('displayTime')) { + payload.displayTime = folderVO.displayTime; + } + if (fields.includes('location') && folderVO.LocnVO) { + payload.location = convertLocnVODataToStelaLocation(folderVO.LocnVO); + } const response = await firstValueFrom( this.httpV2.patch(`v2/folder/${folderVO.folderId}`, payload), diff --git a/src/app/shared/services/api/record.repo.spec.ts b/src/app/shared/services/api/record.repo.spec.ts index 2b41ea893..175577dd4 100644 --- a/src/app/shared/services/api/record.repo.spec.ts +++ b/src/app/shared/services/api/record.repo.spec.ts @@ -11,8 +11,9 @@ import { RecordResponse, StelaLocation, convertStelaLocationToLocnVOData, + convertLocnVODataToStelaLocation, } from '@shared/services/api/record.repo'; -import { RecordVO } from '@root/app/models'; +import { LocnVOData, RecordVO } from '@root/app/models'; import { provideHttpClient, withInterceptorsFromDi, @@ -280,6 +281,60 @@ describe('RecordRepo', () => { }); }); + describe('convertLocnVODataToStelaLocation', () => { + it('remaps the LocnVO shape onto the stela location request', () => { + const locn: LocnVOData = { + name: "Jean Valjean's House", + sublocation: '55 Rue Plumet', + city: 'Paris', + adminOneName: 'Ile-de-France', + postalCode: '75007', + country: 'France', + latitude: 48.83, + longitude: 2.3, + altitudeMeters: 35, + locationPrecision: 'approximate', + }; + + const result = convertLocnVODataToStelaLocation(locn); + + expect(result).toEqual({ + name: "Jean Valjean's House", + sublocation: '55 Rue Plumet', + city: 'Paris', + state: 'Ile-de-France', + postalCode: '75007', + country: 'France', + latitude: 48.83, + longitude: 2.3, + altitudeMeters: 35, + precision: 'approximate', + }); + }); + + it('coerces string coordinates to numbers', () => { + const result = convertLocnVODataToStelaLocation({ + latitude: '48.83', + longitude: '2.3', + }); + + expect(result.latitude).toBe(48.83); + expect(result.longitude).toBe(2.3); + }); + + it('omits undefined and empty fields so the location is clean', () => { + const result = convertLocnVODataToStelaLocation({ + city: 'Paris', + latitude: '', + longitude: null, + }); + + expect(result).toEqual({ city: 'Paris' }); + expect('latitude' in result).toBe(false); + expect('longitude' in result).toBe(false); + }); + }); + describe('updateStelaRecord', () => { let httpV2PatchSpy: jasmine.Spy; let httpSendRequestPromiseSpy: jasmine.Spy; @@ -364,5 +419,33 @@ describe('RecordRepo', () => { expect(resultRecord).toBeInstanceOf(RecordVO); }); + + it('should send the location (and not displayTime) when updating location', async () => { + const recordVO = new RecordVO({ + recordId: 42, + displayTime: '1985-05-20T00:00:00.000Z', + LocnVO: { + city: 'Paris', + adminOneName: 'Ile-de-France', + country: 'France', + latitude: 48.83, + longitude: 2.3, + }, + }); + + httpV2PatchSpy.and.returnValue(of([fakeStelaRecord])); + + await repo.updateStelaRecord(recordVO, ['location']); + + expect(httpV2PatchSpy).toHaveBeenCalledWith('v2/records/42', { + location: { + city: 'Paris', + state: 'Ile-de-France', + country: 'France', + latitude: 48.83, + longitude: 2.3, + }, + }); + }); }); }); diff --git a/src/app/shared/services/api/record.repo.ts b/src/app/shared/services/api/record.repo.ts index f0e5c7c78..ca65bbb49 100644 --- a/src/app/shared/services/api/record.repo.ts +++ b/src/app/shared/services/api/record.repo.ts @@ -172,6 +172,56 @@ export const convertStelaLocationToLocnVOData = ( }; }; +// The location shape accepted by the stela record/folder PATCH and POST +// endpoints. The location is written as part of the record/folder rather than +// as a standalone object, and is sent without an id (stela creates or updates +// the underlying location row). +export interface StelaLocationUpdateRequest { + name?: string; + sublocation?: string; + city?: string; + state?: string; + postalCode?: string; + country?: string; + latitude?: number; + longitude?: number; + altitudeMeters?: number; + precision?: LocationPrecision; +} + +const toStelaCoordinate = ( + value: string | number | null | undefined, +): number | undefined => + value === null || value === undefined || value === '' + ? undefined + : Number(value); + +export const convertLocnVODataToStelaLocation = ( + locn: LocnVOData, +): StelaLocationUpdateRequest => { + const location: StelaLocationUpdateRequest = { + name: locn.name ?? undefined, + sublocation: locn.sublocation ?? undefined, + city: locn.city ?? undefined, + state: locn.adminOneName ?? undefined, + postalCode: locn.postalCode ?? undefined, + country: locn.country ?? undefined, + latitude: toStelaCoordinate(locn.latitude), + longitude: toStelaCoordinate(locn.longitude), + altitudeMeters: locn.altitudeMeters ?? undefined, + precision: locn.locationPrecision ?? undefined, + }; + // Strip undefined fields so we send a clean, non-empty location object. + (Object.keys(location) as Array).forEach( + (key) => { + if (location[key] === undefined) { + delete location[key]; + } + }, + ); + return location; +}; + export const convertStelaRecordToRecordVO = ( stelaRecord: StelaRecord, ): RecordVO => @@ -480,17 +530,31 @@ export class RecordRepo extends BaseRepo { ); } - public async updateStelaRecord(recordVO: RecordVO): Promise { + public async updateStelaRecord( + recordVO: RecordVO, + fields: Array<'displayTime' | 'location'> = ['displayTime'], + ): Promise { const recordId = recordVO.recordId ?? (await this.getRecordIdByArchiveNbr(recordVO.archiveNbr)); - // For now we only send displayTime. This will evolve until we can - // update the whole record using this method. + // We patch the record itself, sending only the fields being updated. + // Locations are written as part of the record rather than via standalone + // location objects. This will evolve until we can update the whole record + // using this method. + const payload: { + displayTime?: string; + location?: StelaLocationUpdateRequest; + } = {}; + if (fields.includes('displayTime')) { + payload.displayTime = recordVO.displayTime; + } + if (fields.includes('location') && recordVO.LocnVO) { + payload.location = convertLocnVODataToStelaLocation(recordVO.LocnVO); + } + const stelaRecord = await firstValueFrom( - this.httpV2.patch(`v2/records/${recordId}`, { - displayTime: recordVO.displayTime, - }), + this.httpV2.patch(`v2/records/${recordId}`, payload), ); const simulatedV1RecordResponseResults = stelaRecord.map((record) => ({