Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -192,8 +192,12 @@
@if (currentRecord.LocnVO) {
<pr-static-map [location]="currentRecord.LocnVO"></pr-static-map>
}
@if (currentRecord.LocnVO) {
<span>{{ (currentRecord.LocnVO | prLocation)?.full }}</span>
@if (currentRecord.LocnVO | prLocation; as location) {
@if (location.name) {
<strong>{{ location.name }}</strong>
<br />
}
<span>{{ location.line1 }}, {{ location.line2 }}</span>
}
@if (!currentRecord.LocnVO && canEdit) {
<span class="add-location"> Click to add location</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ class MockPrConstantsPipe implements PipeTransform {
}
}

@Pipe({ name: 'prLocation', standalone: false })
class MockPrLocationPipe implements PipeTransform {
transform(value: any): any {
return value ? { name: value.name, line1: '', line2: '' } : null;
}
}

const defaultTagList: TagVOData[] = [
{
tagId: 1,
Expand Down Expand Up @@ -147,6 +154,7 @@ describe('FileViewerComponent', () => {
MockFileSizePipe,
MockGetAltTextPipe,
MockPrConstantsPipe,
MockPrLocationPipe,
GetThumbnailPipe,
],
imports: [HttpClientTestingModule],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
@if (currentLocation) {
<div class="location-panel" @ngIfFadeInAnimation>
<div class="location-info">
@if (currentLocationDisplay.name) {
<span>{{ currentLocationDisplay.name }}</span>
<br />
}
<span>{{ currentLocationDisplay.line1 }}</span>
<br />
<span>{{ currentLocationDisplay.line2 }}</span>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,25 +1,216 @@
// import { async, ComponentFixture, TestBed } from '@angular/core/testing';

// import { LocationPickerComponent } from './location-picker.component';

// describe('LocationPickerComponent', () => {
// let component: LocationPickerComponent;
// let fixture: ComponentFixture<LocationPickerComponent>;

// 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<string, { long_name: string; short_name: string }>
> = {},
): google.maps.GeocoderAddressComponent[] => {
const defaults: Record<string, { long_name: string; short_name: string }> = {
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,
Comment thread
cecilia-donnelly marked this conversation as resolved.
short_name: value.short_name,
types: [type],
})) as google.maps.GeocoderAddressComponent[];
};

const buildPlace = (
overrides: Partial<google.maps.places.PlaceResult> = {},
addressOverrides:
| Parameters<typeof buildAddressComponents>[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;
Comment thread
cecilia-donnelly marked this conversation as resolved.

describe('LocationPickerComponent', () => {
let fixture: ComponentFixture<LocationPickerComponent>;
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();
Comment thread
cecilia-donnelly marked this conversation as resolved.
expect(locn.locality).toBeUndefined();
expect(locn.countryCode).toBeUndefined();
expect((locn as Record<string, unknown>).displayName).toBeUndefined();
expect(locn.adminOneCode).toBeUndefined();
expect(locn.adminTwoName).toBeUndefined();
expect(locn.adminTwoCode).toBeUndefined();
});

it('sets sublocation to null but keeps the name when only a name is available', () => {
const locn = component.createLocnFromPlace(
buildPlace({}, { street_number: undefined, route: undefined }),
);

expect(locn.sublocation).toBeNull();
expect(locn.name).toBe("Jean Valjean's House");
});

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('stores the place name as-is, even when it matches the sublocation', () => {
const locn = component.createLocnFromPlace(
buildPlace({ name: '55 Rue Plumet' }),
);

expect(locn.name).toBe('55 Rue Plumet');
expect(locn.sublocation).toBe('55 Rue Plumet');
});

it('nulls the name when the place has no name', () => {
const locn = component.createLocnFromPlace(
buildPlace({ name: undefined }),
);

expect(locn.name).toBeNull();
});
});

describe('saveItem', () => {
it('persists the location directly, then references the saved locn on the record', async () => {
const savedLocn = {
locnId: 99,
name: 'The Grand Canyon',
sublocation: null,
};
const createSpy = jasmine
.createSpy('create')
.and.resolveTo({ getLocnVO: () => savedLocn });
const updateItemsSpy = jasmine
.createSpy('updateItems')
.and.resolveTo(undefined);
(TestBed.inject(ApiService) as unknown as { locn: unknown }).locn = {
create: createSpy,
};
(
TestBed.inject(EditService) as unknown as { updateItems: unknown }
).updateItems = updateItemsSpy;

const item = { update: jasmine.createSpy('update') };
component.item = item as unknown as typeof component.item;
component.currentLocation = {
latitude: 36.1,
longitude: -112.1,
name: 'The Grand Canyon',
sublocation: null,
};

await component.saveItem();

expect(createSpy).toHaveBeenCalledWith(component.currentLocation);
expect(updateItemsSpy).toHaveBeenCalledWith([item], ['LocnVO']);
// The record references the SAVED locn (which now has an id), not the
// raw picker object — and points its locnId FK straight at it, so the
// backend associates it by id instead of reverse-geocoding.
expect(item.update).toHaveBeenCalledWith({
LocnVO: savedLocn,
locnId: savedLocn.locnId,
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,13 @@ export class LocationPickerComponent implements OnInit, AfterViewInit {
}

async saveItem() {
this.item.update({ LocnVO: this.currentLocation });
// Persist the location directly (POST /locn stores exactly what the
// picker collected — no reverse geocoding), then reference the saved
// locn by id so the record update associates the existing location
// instead of re-deriving it. Mirrors saveProfileItem.
const response = await this.api.locn.create(this.currentLocation);
const locnVO = response.getLocnVO();
this.item.update({ LocnVO: locnVO, locnId: locnVO.locnId });
await this.editService.updateItems([this.item], ['LocnVO']);
}

Expand Down Expand Up @@ -229,32 +235,33 @@ 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;
Comment thread
cecilia-donnelly marked this conversation as resolved.
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'),
};

// Store the place name as-is, nulling it when absent so the backend
// clears any stale value (sublocation is nulled the same way). The pipe
// decides how to render it.
locn.name = place.name || null;

return locn;

function getComponentName(
addressComponents: google.maps.GeocoderAddressComponent[],
type,
getShortName = true,
) {
const component = find(addressComponents, (c) => c.types.includes(type));
return component
? component[getShortName ? 'short_name' : 'long_name']
: null;
return component ? component.long_name : null;
Comment thread
cecilia-donnelly marked this conversation as resolved.
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -153,8 +153,12 @@
(click)="onLocationClick()"
[class.can-edit]="canEdit"
>
@if (selectedItem.LocnVO) {
<span>{{ (selectedItem.LocnVO | prLocation)?.full }}</span>
@if (selectedItem.LocnVO | prLocation; as location) {
@if (location.name) {
<strong>{{ location.name }}</strong>
<br />
}
<span>{{ location.line1 }}, {{ location.line2 }}</span>
}
@if (!selectedItem.LocnVO && !canEdit) {
<span class="sidebar-item-content-empty"> No location </span>
Expand Down
7 changes: 7 additions & 0 deletions src/app/models/locn-vo.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
}
Loading
Loading