diff --git a/service/src/adapters/exports/adapters.exports.controllers.web.ts b/service/src/adapters/exports/adapters.exports.controllers.web.ts index 18368f849..11717d27a 100644 --- a/service/src/adapters/exports/adapters.exports.controllers.web.ts +++ b/service/src/adapters/exports/adapters.exports.controllers.web.ts @@ -164,14 +164,13 @@ function parseExportObservationFilter(body: any): ExportCreateParams['filter'][' const endDate = parseExportDate(iso8601EndDate, 'observations.endDate') if (endDate instanceof MageError) return endDate - let fieldFilter: ObservationFieldFilter | undefined + const fieldFilter: ObservationFieldFilter = {} if (typeof keyword === 'string' && keyword.length) { - fieldFilter = { keyword } - } else if (condition) { - const parsedCondition = parseConditionFilter(condition) - if (parsedCondition) { - fieldFilter = { condition: parsedCondition } - } + fieldFilter.keyword = keyword + } + const parsedCondition = condition ? parseConditionFilter(condition) : undefined + if (parsedCondition) { + fieldFilter.condition = parsedCondition } return { @@ -183,7 +182,7 @@ function parseExportObservationFilter(body: any): ExportCreateParams['filter'][' userIsAnyOf: Array.isArray(users) ? users : undefined, teamIsAnyOf: Array.isArray(teams) ? teams : undefined, hasAttachments: hasAttachments || undefined, - fieldFilter, + fieldFilter: Object.keys(fieldFilter).length ? fieldFilter : undefined, projection: Array.isArray(projection) ? projection : undefined } } diff --git a/service/src/app.impl/teams/app.impl.teams.ts b/service/src/app.impl/teams/app.impl.teams.ts index 503ba93e6..3219da608 100644 --- a/service/src/app.impl/teams/app.impl.teams.ts +++ b/service/src/app.impl/teams/app.impl.teams.ts @@ -21,6 +21,21 @@ export function SearchTeams(teamRepo: TeamRepository,permissions: api.TeamsPermi * single deduped user id list. Returns `undefined`, not an empty array, when neither input narrows * the result at all, or when a given team id list resolves to no members - callers should treat * `undefined` as "unconstrained" rather than "matches no one". + * + * KNOWN GAP: `teamIsAnyOf` is resolved via a global `teamRepo.findAllByIds()` lookup with no check + * that the given team ids actually belong to the event the caller is scoping to (observation/location + * search callers have a `context.mageEvent` available, but don't pass its teams in here). A team id + * for a team that was removed from the event - or that never belonged to it - still resolves normally + * and narrows results to that team's current membership. + * + * A correct fix needs the event's own team list, which isn't cheaply available yet: the event fetch + * this event context is built from (`EventRepository.findById`) does not populate `teams`; the only + * existing code that does is `findTeamsInEvent()` in `adapters.events.db.mongoose.ts`, which is itself + * marked `TODO: this is misplaced; create a team repository` and isn't wired into this request path. + * Fixing this means adding that event-team fetch (an extra DB round trip) to this function or its + * call sites (observation read, location read/recent, and likely exports), without disturbing the + * "team resolves to zero current members -> unconstrained" behavior documented above, which is a + * distinct, intentional case from "team id isn't associated with this event at all". */ export async function resolveUserIsAnyOf( teamRepo: TeamRepository, diff --git a/service/src/models/observation.js b/service/src/models/observation.js index 664c7ced4..5c260e702 100644 --- a/service/src/models/observation.js +++ b/service/src/models/observation.js @@ -478,10 +478,10 @@ exports.addFavorite = function (event, observationId, user, callback) { } }; - observationModel(event).findByIdAndUpdate(observationId, update, { new: true }).then( - r => callback(null, r), - e => callback(e) - ); + observationModel(event) + .findByIdAndUpdate(observationId, update, { new: true }) + .populate({ path: 'userId', select: 'displayName' }) + .exec().then(r => callback(null, r), e => callback(e)); }; exports.removeFavorite = function (event, observationId, user, callback) { @@ -491,10 +491,10 @@ exports.removeFavorite = function (event, observationId, user, callback) { } }; - observationModel(event).findByIdAndUpdate(observationId, update, { new: true }).then( - r => callback(null, r), - e => callback(e) - ); + observationModel(event) + .findByIdAndUpdate(observationId, update, { new: true }) + .populate({ path: 'userId', select: 'displayName' }) + .exec().then(r => callback(null, r), e => callback(e)); }; exports.addImportant = function (event, observationId, important, callback) { @@ -514,10 +514,11 @@ exports.removeImportant = function (event, id, callback) { } }; - observationModel(event).findByIdAndUpdate(id, update, { new: true }).then( - r => callback(null, r), - e => callback(e) - ); + observationModel(event) + .findByIdAndUpdate(id, update, { new: true }) + .populate({ path: 'userId', select: 'displayName' }) + .populate({ path: 'important.userId', select: 'displayName' }) + .exec().then(r => callback(null, r), e => callback(e)); }; exports.getAttachment = function (event, observationId, attachmentId, callback) { diff --git a/service/test/adapters/exports/adapters.exports.controllers.web.test.ts b/service/test/adapters/exports/adapters.exports.controllers.web.test.ts index f316e0fd8..035bf6a80 100644 --- a/service/test/adapters/exports/adapters.exports.controllers.web.test.ts +++ b/service/test/adapters/exports/adapters.exports.controllers.web.test.ts @@ -317,6 +317,29 @@ describe('exports web controller', function() { }) }) + it('combines a keyword and a condition into one filter', async function() { + let capturedParams: any + createRequestFactory.createRequest(Arg.all()).mimicks((req: express.Request, params: any) => { + capturedParams = params + return { ...params, context: {} } + }) + appLayer.createExport(Arg.all()).resolves(AppResponse.success(exp as unknown as Export)) + + await postClient.post(`${root}`).send({ + format: 'kml', + observations: { + keyword: 'wildfire', + condition: { formId: 1, field: 'field1', operator: '=', value: 'value1' } + } + }) + + const params = (capturedParams as CreateExportRequest).filter as any + expect(params.observations.fieldFilter).to.deep.equal({ + keyword: 'wildfire', + condition: { formId: 1, field: 'field1', operator: '=', value: 'value1' } + }) + }) + it('rejects an invalid observationStartDate', async function() { const res = await postClient.post(`${root}`).send({ format: 'kml', diff --git a/service/test/observation/observationFavoriteTest.js b/service/test/observation/observationFavoriteTest.js index 3ea2bd452..1fdd21885 100644 --- a/service/test/observation/observationFavoriteTest.js +++ b/service/test/observation/observationFavoriteTest.js @@ -119,6 +119,8 @@ describe("marking favorite observations", function () { const observationMock = sinon.mock(ObservationModel) .expects('findByIdAndUpdate') .withArgs(observationId.toString(), sinon.match({ '$addToSet': { favoriteUserIds: userId } }), sinon.match.any) + .chain('populate') + .chain('exec') .resolves(mockObservation); request(app) @@ -169,6 +171,8 @@ describe("marking favorite observations", function () { const observationMock = sinon.mock(ObservationModel) .expects('findByIdAndUpdate') .withArgs(observationId.toString(), sinon.match({ '$pull': { favoriteUserIds: userId } }), sinon.match.any) + .chain('populate') + .chain('exec') .resolves(mockObservation); request(app) diff --git a/service/test/observation/observationImportantTest.js b/service/test/observation/observationImportantTest.js index a11eeefac..70684a349 100644 --- a/service/test/observation/observationImportantTest.js +++ b/service/test/observation/observationImportantTest.js @@ -230,6 +230,9 @@ describe("observation important tests", function () { const observationMock = sinon.mock(ObservationModel) .expects('findByIdAndUpdate') .withArgs(observationId, sinon.match({ '$unset': { important: 1 } }), sinon.match.any) + .chain('populate') + .chain('populate') + .chain('exec') .resolves(mockObservation); request(app) diff --git a/web-app/angular.json b/web-app/angular.json index 5a24d7a2f..25f5d4ea7 100644 --- a/web-app/angular.json +++ b/web-app/angular.json @@ -141,7 +141,9 @@ "node_modules" ] }, - "scripts": [], + "scripts": [ + "node_modules/js_cols/dist/js_cols.min.js" + ], "codeCoverage": true } }, diff --git a/web-app/src/app/datetime-picker/datetime-picker.component.html b/web-app/src/app/datetime-picker/datetime-picker.component.html index c737d29a3..e087d1053 100644 --- a/web-app/src/app/datetime-picker/datetime-picker.component.html +++ b/web-app/src/app/datetime-picker/datetime-picker.component.html @@ -1,29 +1,27 @@
- {{ title }} Date - {{ title() }} Date + - - Date is required - - - Invalid date - + @if (dateModel.errors?.required && !dateModel.errors?.matDatepickerParse) { + Date is required + } + @if (dateModel.errors?.matDatepickerParse) { + Invalid date + }
- {{ title }} Time {{ timezone === 'gmt' ? '(GMT)' : '(Local)' }} + {{ title() }} Time {{ timezone() === 'gmt' ? '(GMT)' : '(Local)' }} - Invalid time diff --git a/web-app/src/app/datetime-picker/datetime-picker.component.spec.ts b/web-app/src/app/datetime-picker/datetime-picker.component.spec.ts index c16937b48..c3e09291a 100644 --- a/web-app/src/app/datetime-picker/datetime-picker.component.spec.ts +++ b/web-app/src/app/datetime-picker/datetime-picker.component.spec.ts @@ -1,27 +1,109 @@ -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; - +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatMomentDateModule } from '@angular/material-moment-adapter'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import moment from 'moment'; import { DatetimePickerComponent } from './datetime-picker.component'; -/* TODO at least one test MUST be implemented describe('DatetimePickerComponent', () => { let component: DatetimePickerComponent; let fixture: ComponentFixture; + let emitted: Date[]; + + const datetime = new Date(2024, 0, 15, 10, 30, 0); - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - declarations: [ DatetimePickerComponent ] - }) - .compileComponents(); - })); + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [DatetimePickerComponent, MatMomentDateModule, BrowserAnimationsModule] + }).compileComponents(); - beforeEach(() => { fixture = TestBed.createComponent(DatetimePickerComponent); component = fixture.componentInstance; + emitted = []; + component.dateTimeChange.subscribe(value => emitted.push(value)); + }); + + it('starts empty when there is no datetime', () => { + fixture.detectChanges(); + + expect(component.date()).toBeNull(); + expect(component.timeValue()).toBeNull(); + }); + + it('seeds the date and time from the datetime input', () => { + fixture.componentRef.setInput('datetime', datetime); + fixture.detectChanges(); + + expect(component.date()?.toDate()).toEqual(datetime); + expect(component.timeValue()?.toDate()).toEqual(datetime); + }); + + it('reseeds when the datetime input changes', () => { + fixture.componentRef.setInput('datetime', datetime); + fixture.detectChanges(); + const next = new Date(2024, 5, 1, 8, 0, 0); + + fixture.componentRef.setInput('datetime', next); fixture.detectChanges(); + + expect(component.date()?.toDate()).toEqual(next); + expect(component.timeValue()?.toDate()).toEqual(next); }); - // it('should create', () => { - // expect(component).toBeTruthy(); - // }); + it('emits the combined date and time when the date changes', () => { + fixture.componentRef.setInput('datetime', datetime); + fixture.detectChanges(); + + component.date.set(moment(new Date(2024, 1, 20))); + component.onDate(); + + expect(emitted).toEqual([new Date(2024, 1, 20, 10, 30, 0)]); + }); + + it('emits the combined date and time when the time changes', () => { + fixture.componentRef.setInput('datetime', datetime); + fixture.detectChanges(); + + component.timeValue.set(moment(new Date(2024, 0, 15, 14, 45, 0))); + component.onTime(); + + expect(emitted).toEqual([new Date(2024, 0, 15, 14, 45, 0)]); + }); + + it('does not emit when a time is picked before a date', () => { + fixture.detectChanges(); + + component.timeValue.set(moment(new Date(2024, 0, 15, 14, 45, 0))); + component.onTime(); + + expect(emitted).toEqual([]); + }); + + it('clears the time and does not emit when the date is cleared', () => { + fixture.componentRef.setInput('datetime', datetime); + fixture.detectChanges(); + + component.date.set(null); + component.onDate(); + + expect(component.timeValue()).toBeNull(); + expect(emitted).toEqual([]); + }); + + it('keeps the picked wall clock time as UTC in gmt mode', () => { + fixture.componentRef.setInput('datetime', datetime); + fixture.componentRef.setInput('timezone', 'gmt'); + fixture.detectChanges(); + + component.timeValue.set(moment(new Date(2024, 0, 15, 14, 45, 0))); + component.onTime(); + + expect(emitted[0].toISOString()).toBe('2024-01-15T14:45:00.000Z'); + }); + + it('treats the required attribute string as true', () => { + fixture.componentRef.setInput('required', 'true'); + fixture.detectChanges(); + + expect(component.required()).toBeTrue(); + }); }); -*/ diff --git a/web-app/src/app/datetime-picker/datetime-picker.component.ts b/web-app/src/app/datetime-picker/datetime-picker.component.ts index 10e6dc01e..65b411d68 100644 --- a/web-app/src/app/datetime-picker/datetime-picker.component.ts +++ b/web-app/src/app/datetime-picker/datetime-picker.component.ts @@ -1,81 +1,73 @@ import { + ChangeDetectionStrategy, Component, - EventEmitter, - Input, - OnChanges, - Output, - SimpleChanges, - ViewChild + booleanAttribute, + input, + linkedSignal, + output, + viewChild } from '@angular/core'; -import { NgModel } from '@angular/forms'; +import { FormsModule, NgModel } from '@angular/forms'; +import { MatDatepickerModule } from '@angular/material/datepicker'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatTimepickerModule } from '@angular/material/timepicker'; import moment from 'moment'; @Component({ - selector: 'datetime-picker', - templateUrl: './datetime-picker.component.html', - styleUrls: ['./datetime-picker.component.scss'], - standalone: false + selector: 'datetime-picker', + templateUrl: './datetime-picker.component.html', + styleUrls: ['./datetime-picker.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [FormsModule, MatFormFieldModule, MatInputModule, MatDatepickerModule, MatTimepickerModule] }) -export class DatetimePickerComponent implements OnChanges { - @Input() title: string; - @Input() required: boolean; - @Input() datetime: Date; - @Input() timezone: 'local' | 'gmt' = 'local'; +export class DatetimePickerComponent { + title = input('') + required = input(false, { transform: booleanAttribute }) + datetime = input(null) + timezone = input<'local' | 'gmt'>('local') - @Output() dateTimeChange = new EventEmitter(); + dateTimeChange = output() - @ViewChild('dateModel') dateModel: NgModel; + dateModel = viewChild('dateModel') - date: moment.Moment | null = null; - timeValue: moment.Moment | null = null; - - ngOnChanges(changes: SimpleChanges): void { - if (changes.datetime) { - const value = changes.datetime.currentValue; - - if (value) { - const m = moment(value); - this.date = m.clone(); - this.timeValue = m.clone(); - } else { - this.date = null; - this.timeValue = null; - } - } - } + date = linkedSignal(() => this.datetime() ? moment(this.datetime()) : null) + timeValue = linkedSignal(() => this.datetime() ? moment(this.datetime()) : null) onDate(): void { - if (!this.date) { - this.timeValue = null; - return; + if (!this.date()) { + this.timeValue.set(null) + return } - if (!this.dateModel?.invalid) { - this.setValue(); + if (!this.dateModel()?.invalid) { + this.setValue() } } onTime(): void { - if (this.timeValue) { - this.setValue(); + if (this.timeValue()) { + this.setValue() } } private setValue(): void { - if (!this.date || !this.timeValue) { - return; + const date = this.date() + const time = this.timeValue() + if (!date || !time) { + return } - const date = this.date.clone().set({ - hour: this.timeValue.hours(), - minute: this.timeValue.minutes(), - second: this.timeValue.seconds() - }); + const value = date.clone().set({ + hour: time.hours(), + minute: time.minutes(), + second: time.seconds() + }) - if (this.timezone === 'gmt') { - date.utc(true); + if (this.timezone() === 'gmt') { + value.utc(true) } - this.dateTimeChange.emit(date.toDate()); + this.dateTimeChange.emit(value.toDate()) } } diff --git a/web-app/src/app/entities/layer/entities.layer.ts b/web-app/src/app/entities/layer/entities.layer.ts index 8c1335119..18ec62252 100644 --- a/web-app/src/app/entities/layer/entities.layer.ts +++ b/web-app/src/app/entities/layer/entities.layer.ts @@ -1,13 +1,44 @@ export type LayerId = number -export type Layer = { +type LayerState = 'available' | 'unavailable' | 'processing' + +export type Layer = ImageryLayer | FeatureLayer | GeoPackageLayer + +interface ImageryLayer { + type: 'Imagery' + id: LayerId + name: string + state: LayerState + url?: string + base?: boolean + format?: 'XYZ' | 'TMS' | 'WMS' + wms?: { layers?: string, styles?: string, format?: string, transparent?: boolean, version?: string } +} + +interface FeatureLayer { + type: 'Feature' id: LayerId name: string - type: 'Imagery' | 'Feature' | 'GeoPackage' - state: 'available' | 'unavailable' | 'processing' + state: LayerState url?: string } +interface GeoPackageTable { + name: string + type: 'tile' | 'feature' + minZoom?: number + maxZoom?: number + bbox?: number[] +} + +interface GeoPackageLayer { + type: 'GeoPackage' + id: LayerId + name: string + state: LayerState + tables?: GeoPackageTable[] +} + export function layerIconName(layer: { type?: Layer['type'] } | null | undefined): string { if (!layer) return 'map' if (layer.type === 'Imagery') return 'satellite_alt' diff --git a/web-app/src/app/entities/observation/entities.observation.ts b/web-app/src/app/entities/observation/entities.observation.ts index c9169c0ca..3745733c7 100644 --- a/web-app/src/app/entities/observation/entities.observation.ts +++ b/web-app/src/app/entities/observation/entities.observation.ts @@ -1,32 +1,43 @@ import { User } from "@ngageoint/mage.web-core-lib/user" import { AttachmentAction } from "../../observation/observation-edit/observation-edit-attachment/observation-edit-attachment-action" -import { Style } from "../map/entities.map" import { EventId } from "../event/entities.event" export type ObservationId = string +export const ObservationStateName = Object.freeze({ + Active: 'active', + Archived: 'archive' +} as const) +export type ObservationStateName = (typeof ObservationStateName)[keyof typeof ObservationStateName] + export type FormProperties = { id: string formId: number [name: string]: any } +export type ObservationStyle = { + color?: string + fillColor?: string + fillOpacity?: number + opacity?: number + weight?: number + iconUrl: string +} + export type Observation = { id: ObservationId eventId: EventId type: 'Feature' - geometry: { - type: string - coordinates: number[] - } + geometry: GeoJSON.Geometry url: string user?: Pick userId?: string attachments: Attachment[] deviceId?: string createdAt: Date - lastModified: Date - style: Style + lastModified: string + style: ObservationStyle favoriteUserIds: string[] properties: { forms: FormProperties[] @@ -37,7 +48,7 @@ export type Observation = { } state?: { id: string - name: 'active' | 'archived' + name: ObservationStateName userId?: string url: string } diff --git a/web-app/src/app/entities/user/entities.user-location.ts b/web-app/src/app/entities/user/entities.user-location.ts new file mode 100644 index 000000000..a0b88874e --- /dev/null +++ b/web-app/src/app/entities/user/entities.user-location.ts @@ -0,0 +1,25 @@ +export type UserId = string + +export type UserLocationProperties = { + timestamp: string + accuracy?: number + [name: string]: unknown +} + +export type UserLocation = GeoJSON.Feature & { + id: UserId + style?: { iconUrl: string } +} + +export type UserWithLocation = { + id: UserId + userId: UserId + user: { + id: UserId + displayName: string + iconUrl?: string + avatarUrl?: string + lastUpdated?: string + } + location: UserLocation +} diff --git a/web-app/src/app/event/event-member-filter.component.spec.ts b/web-app/src/app/event/event-member-filter.component.spec.ts index 62f393634..9813eaf09 100644 --- a/web-app/src/app/event/event-member-filter.component.spec.ts +++ b/web-app/src/app/event/event-member-filter.component.spec.ts @@ -1,6 +1,6 @@ import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing' import { NoopAnimationsModule } from '@angular/platform-browser/animations' -import { of } from 'rxjs' +import { Subject, of } from 'rxjs' import { EventService } from './event.service' import { EventMemberFilterComponent, MemberFilterSelection } from './event-member-filter.component' @@ -18,7 +18,7 @@ const eventMembers = [ function createEventServiceSpy(): jasmine.SpyObj { const spy = jasmine.createSpyObj('EventService', ['getMembers', 'searchMembers']) spy.getMembers.and.returnValue(of(eventMembers as any)) - spy.searchMembers.and.callFake((_event: any, term: string) => { + spy.searchMembers.and.callFake((_eventId: any, term: string) => { const q = (term || '').toLowerCase() return of(eventMembers.filter(u => u.displayName.toLowerCase().includes(q)) as any) }) @@ -47,26 +47,30 @@ describe('EventMemberFilterComponent', () => { component = fixture.componentInstance }) - function setEventAndTeams(event: any, teams: any[]): void { - fixture.componentRef.setInput('event', event) + function setEventAndTeams(eventId: number | null, teams: any[]): void { + fixture.componentRef.setInput('eventId', eventId) fixture.componentRef.setInput('teams', teams) fixture.detectChanges() } - function setEventTeamsAndFilter(event: any, teams: any[], filter: MemberFilterSelection): void { - fixture.componentRef.setInput('filter', filter) - fixture.componentRef.setInput('event', event) + function setEventTeamsAndFilter(eventId: number | null, teams: any[], initialFilter: MemberFilterSelection): void { + fixture.componentRef.setInput('initialFilter', initialFilter) + fixture.componentRef.setInput('eventId', eventId) fixture.componentRef.setInput('teams', teams) fixture.detectChanges() } + function selectTeam(id: string, display: string): void { + component.onOptionSelected({ option: { value: { type: 'team' as const, id, display } } } as any) + } + it('should create', () => { fixture.detectChanges() expect(component).toBeTruthy() }) it('loads teams from input and searches members from the event service on input change', fakeAsync(() => { - setEventAndTeams({ id: 1 }, [team1, team2]) + setEventAndTeams(1, [team1, team2]) tick(SEARCH_DEBOUNCE_MS) const groups = component.filteredGroups() @@ -76,18 +80,18 @@ describe('EventMemberFilterComponent', () => { expect(teamGroup?.options.length).toBe(2) expect(teamGroup?.options.map(o => o.display)).toEqual(jasmine.arrayContaining(['Alpha Team', 'Bravo Team'])) expect(memberGroup?.options.length).toBe(3) - expect(eventService.searchMembers).toHaveBeenCalledWith({ id: 1 }, '') + expect(eventService.searchMembers).toHaveBeenCalledWith(1, '') })) it('shows no groups when teams input is empty', fakeAsync(() => { - setEventAndTeams({ id: 1 }, []) + setEventAndTeams(1, []) tick(SEARCH_DEBOUNCE_MS) expect(component.filteredGroups().length).toBe(0) expect(eventService.searchMembers).not.toHaveBeenCalled() })) - it('shows no groups when event input is not set', fakeAsync(() => { + it('shows no groups when event id input is not set', fakeAsync(() => { setEventAndTeams(null, [team1]) tick(SEARCH_DEBOUNCE_MS) @@ -96,7 +100,7 @@ describe('EventMemberFilterComponent', () => { })) it('searches the server for members as the query changes, debounced', fakeAsync(() => { - setEventAndTeams({ id: 1 }, [team1, team2]) + setEventAndTeams(1, [team1, team2]) tick(SEARCH_DEBOUNCE_MS) eventService.searchMembers.calls.reset() @@ -104,7 +108,7 @@ describe('EventMemberFilterComponent', () => { fixture.detectChanges() tick(SEARCH_DEBOUNCE_MS) - expect(eventService.searchMembers).toHaveBeenCalledWith({ id: 1 }, 'ali') + expect(eventService.searchMembers).toHaveBeenCalledWith(1, 'ali') const groups = component.filteredGroups() const memberGroup = groups.find(g => g.label === 'Members') expect(memberGroup?.options.length).toBe(1) @@ -112,7 +116,7 @@ describe('EventMemberFilterComponent', () => { })) it('filters team groups locally by the search query', fakeAsync(() => { - setEventAndTeams({ id: 1 }, [team1, team2]) + setEventAndTeams(1, [team1, team2]) tick(SEARCH_DEBOUNCE_MS) component.inputControl.setValue('alpha') tick(SEARCH_DEBOUNCE_MS) @@ -124,7 +128,7 @@ describe('EventMemberFilterComponent', () => { })) it('emits teamIds and userIds when a team is selected', fakeAsync(() => { - setEventAndTeams({ id: 1 }, [team1, team2]) + setEventAndTeams(1, [team1, team2]) tick(SEARCH_DEBOUNCE_MS) const emitted: MemberFilterSelection[] = [] @@ -138,7 +142,7 @@ describe('EventMemberFilterComponent', () => { })) it('emits teamIds and userIds when a user is selected', fakeAsync(() => { - setEventAndTeams({ id: 1 }, [team1]) + setEventAndTeams(1, [team1]) tick(SEARCH_DEBOUNCE_MS) const emitted: MemberFilterSelection[] = [] @@ -153,7 +157,7 @@ describe('EventMemberFilterComponent', () => { })) it('does not add duplicate selections', fakeAsync(() => { - setEventAndTeams({ id: 1 }, [team1]) + setEventAndTeams(1, [team1]) tick(SEARCH_DEBOUNCE_MS) const teamOption = { type: 'team' as const, id: 'team1', display: 'Alpha Team', raw: team1 } @@ -164,7 +168,7 @@ describe('EventMemberFilterComponent', () => { })) it('removes a selection and emits', fakeAsync(() => { - setEventAndTeams({ id: 1 }, [team1]) + setEventAndTeams(1, [team1]) tick(SEARCH_DEBOUNCE_MS) const emitted: MemberFilterSelection[] = [] @@ -178,23 +182,70 @@ describe('EventMemberFilterComponent', () => { expect(emitted[emitted.length - 1].teamIds).toEqual([]) })) - it('resets selection when inputs change', fakeAsync(() => { - setEventAndTeams({ id: 1 }, [team1]) - tick(SEARCH_DEBOUNCE_MS) + describe('when the event changes', () => { + it('clears the selection', fakeAsync(() => { + setEventAndTeams(1, [team1]) + tick(SEARCH_DEBOUNCE_MS) + selectTeam('team1', 'Alpha Team') + expect(component.selected().length).toBe(1) - const teamOption = { type: 'team' as const, id: 'team1', display: 'Alpha Team', raw: team1 } - component.onOptionSelected({ option: { value: teamOption } } as any) - expect(component.selected().length).toBe(1) + setEventAndTeams(2, [team2]) + tick(SEARCH_DEBOUNCE_MS) - setEventAndTeams({ id: 1 }, [team2]) - tick(SEARCH_DEBOUNCE_MS) - expect(component.selected().length).toBe(0) - })) + expect(component.selected().length).toBe(0) + })) + + it('keeps the selection when only the teams change for the same event', fakeAsync(() => { + setEventAndTeams(1, [team1]) + tick(SEARCH_DEBOUNCE_MS) + selectTeam('team1', 'Alpha Team') + + setEventAndTeams(1, [team1, team2]) + tick(SEARCH_DEBOUNCE_MS) + + expect(component.selected().map(s => s.id)).toEqual(['team1']) + })) - describe('pre-population from filter input', () => { + it('does not select the members that finish loading for the previous event', fakeAsync(() => { + const members$ = new Subject() + eventService.getMembers.and.returnValue(members$) + setEventTeamsAndFilter(1, [team1], { teamIds: [], userIds: ['u1'] }) + tick(SEARCH_DEBOUNCE_MS) + + setEventAndTeams(2, [team2]) + members$.next(eventMembers) + tick(SEARCH_DEBOUNCE_MS) + + expect(component.selected().length).toBe(0) + })) + }) + + describe('initial filter', () => { + it('is read once, so a later change from the parent does not touch the selection', fakeAsync(() => { + setEventTeamsAndFilter(1, [team1, team2], { teamIds: ['team1'], userIds: [] }) + tick(SEARCH_DEBOUNCE_MS) + + fixture.componentRef.setInput('initialFilter', { teamIds: ['team2'], userIds: [] }) + fixture.detectChanges() + tick(SEARCH_DEBOUNCE_MS) + + expect(component.selected().map(s => s.id)).toEqual(['team1']) + })) + + it('is ignored when it arrives after the component was created without one', fakeAsync(() => { + setEventAndTeams(1, [team1, team2]) + tick(SEARCH_DEBOUNCE_MS) + selectTeam('team1', 'Alpha Team') + + fixture.componentRef.setInput('initialFilter', { teamIds: ['team2'], userIds: [] }) + fixture.detectChanges() + tick(SEARCH_DEBOUNCE_MS) + + expect(component.selected().map(s => s.id)).toEqual(['team1']) + })) it('pre-selects teams from saved filter', fakeAsync(() => { - setEventTeamsAndFilter({ id: 1 }, [team1, team2], { teamIds: ['team1'], userIds: [] }) + setEventTeamsAndFilter(1, [team1, team2], { teamIds: ['team1'], userIds: [] }) tick(SEARCH_DEBOUNCE_MS) expect(component.selected().length).toBe(1) expect(component.selected()[0].id).toBe('team1') @@ -203,16 +254,16 @@ describe('EventMemberFilterComponent', () => { })) it('pre-selects users from saved filter using a bulk lookup', fakeAsync(() => { - setEventTeamsAndFilter({ id: 1 }, [team1], { teamIds: [], userIds: ['u1'] }) + setEventTeamsAndFilter(1, [team1], { teamIds: [], userIds: ['u1'] }) tick(SEARCH_DEBOUNCE_MS) expect(component.selected().length).toBe(1) expect(component.selected()[0].id).toBe('u1') expect(component.selected()[0].type).toBe('user') - expect(eventService.getMembers).toHaveBeenCalledWith({ id: 1 }) + expect(eventService.getMembers).toHaveBeenCalledWith(1) })) it('pre-selects both teams and users from saved filter', fakeAsync(() => { - setEventTeamsAndFilter({ id: 1 }, [team1], { teamIds: ['team1'], userIds: ['u2'] }) + setEventTeamsAndFilter(1, [team1], { teamIds: ['team1'], userIds: ['u2'] }) tick(SEARCH_DEBOUNCE_MS) expect(component.selected().length).toBe(2) expect(component.selected().map(s => s.id)).toContain('team1') @@ -220,7 +271,7 @@ describe('EventMemberFilterComponent', () => { })) it('makes no selection when filter has ids not present in the loaded options', fakeAsync(() => { - setEventTeamsAndFilter({ id: 1 }, [team1], { teamIds: ['unknown-team'], userIds: ['unknown-user'] }) + setEventTeamsAndFilter(1, [team1], { teamIds: ['unknown-team'], userIds: ['unknown-user'] }) tick(SEARCH_DEBOUNCE_MS) expect(component.selected().length).toBe(0) })) diff --git a/web-app/src/app/event/event-member-filter.component.ts b/web-app/src/app/event/event-member-filter.component.ts index 0be005116..64c610851 100644 --- a/web-app/src/app/event/event-member-filter.component.ts +++ b/web-app/src/app/event/event-member-filter.component.ts @@ -1,4 +1,4 @@ -import { Component, ElementRef, ViewChild, computed, effect, input, output, signal } from '@angular/core' +import { Component, DestroyRef, ElementRef, ViewChild, computed, effect, inject, input, output, signal, untracked } from '@angular/core' import { FormControl, ReactiveFormsModule } from '@angular/forms' import { COMMA, ENTER } from '@angular/cdk/keycodes' import { MatAutocompleteModule, MatAutocompleteSelectedEvent } from '@angular/material/autocomplete' @@ -7,9 +7,10 @@ import { MatFormFieldModule } from '@angular/material/form-field' import { MatIconModule } from '@angular/material/icon' import { MatInputModule } from '@angular/material/input' import { User } from '@ngageoint/mage.web-core-lib/user' -import { toSignal } from '@angular/core/rxjs-interop' +import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop' import { Subject, debounceTime, of, switchMap } from 'rxjs' import { EventService } from './event.service' +import { EventId } from '../entities/event/entities.event' import { Team } from '../entities/team/entities.team' const SEARCH_DEBOUNCE_MS = 250 @@ -47,9 +48,9 @@ interface MemberOptionGroup { ] }) export class EventMemberFilterComponent { - event = input() + eventId = input() teams = input([]) - filter = input(null) + initialFilter = input(null) memberFilterChanged = output() @@ -60,15 +61,15 @@ export class EventMemberFilterComponent { inputControl = new FormControl('') private queryText = toSignal(this.inputControl.valueChanges, { initialValue: '' }) - private search$ = new Subject<{ event: any; teams: Team[]; term: string }>() + private search$ = new Subject<{ eventId: EventId | null | undefined; teams: Team[]; term: string }>() private searchResults = toSignal( this.search$.pipe( debounceTime(SEARCH_DEBOUNCE_MS), - switchMap(({ event, teams, term }) => { - if (!event || !teams.length) { + switchMap(({ eventId, teams, term }) => { + if (eventId == null || !teams.length) { return of([] as User[]) } - return this.eventService.searchMembers(event, term) + return this.eventService.searchMembers(eventId, term) }) ), { initialValue: [] as User[] } @@ -76,8 +77,12 @@ export class EventMemberFilterComponent { selected = signal([]) + private destroyRef = inject(DestroyRef) + private initialized = false + private lastEventId: EventId | null | undefined + private teamOptions = computed(() => - this.event() ? this.teams().map(t => this.teamToOption(t)) : [] + this.eventId() != null ? this.teams().map(t => this.teamToOption(t)) : [] ) private userOptions = computed(() => @@ -90,35 +95,36 @@ export class EventMemberFilterComponent { constructor(private eventService: EventService) { effect(() => { - const event = this.event() + const eventId = this.eventId() const teams = this.teams() const term = this.queryText() ?? '' - this.search$.next({ event, teams, term: typeof term === 'string' ? term : '' }) + this.search$.next({ eventId, teams, term: typeof term === 'string' ? term : '' }) }) - effect((onCleanup) => { - const event = this.event() + effect(() => { + const eventId = this.eventId() const teams = this.teams() - const filter = this.filter() - this.selected.set([]) + const initialFilter = this.initialFilter() + if (this.initialized) return - if (!event || !teams.length || !filter) { + if (!initialFilter) { + this.initialized = true return } + if (eventId == null || !teams.length) return - const teamSelections = this.teamOptions().filter(o => filter.teamIds.includes(o.id)) - - if (filter.userIds.length) { - const subscription = this.eventService.getMembers(event).subscribe((users: User[]) => { - const userSelections = users - .filter(u => filter.userIds.includes(u.id)) - .map(u => this.userToOption(u)) - this.selected.set([...teamSelections, ...userSelections]) - }) - onCleanup(() => subscription.unsubscribe()) - } else { - this.selected.set(teamSelections) - } + this.initialized = true + untracked(() => this.select(eventId, initialFilter)) + }) + + effect(() => { + const eventId = this.eventId() + untracked(() => { + if (this.lastEventId !== undefined && eventId !== this.lastEventId) { + this.selected.set([]) + } + this.lastEventId = eventId + }) }) } @@ -142,6 +148,24 @@ export class EventMemberFilterComponent { return '' } + private select(eventId: EventId, filter: MemberFilterSelection): void { + const teamSelections = this.teamOptions().filter(o => filter.teamIds.includes(o.id)) + + if (!filter.userIds.length) { + this.selected.set(teamSelections) + return + } + + this.eventService.getMembers(eventId).pipe(takeUntilDestroyed(this.destroyRef)).subscribe(users => { + if (this.eventId() !== eventId) return + + const userSelections = users + .filter(u => filter.userIds.includes(u.id)) + .map(u => this.userToOption(u)) + this.selected.set([...teamSelections, ...userSelections]) + }) + } + private buildGroups(): MemberOptionGroup[] { const query = (this.queryText() || '').toLowerCase() const selectedIds = new Set(this.selected().map(s => `${s.type}:${s.id}`)) diff --git a/web-app/src/app/event/event.service.spec.ts b/web-app/src/app/event/event.service.spec.ts index 5c94ec86e..52711e6ae 100644 --- a/web-app/src/app/event/event.service.spec.ts +++ b/web-app/src/app/event/event.service.spec.ts @@ -1,179 +1,1323 @@ -import { TestBed } from '@angular/core/testing'; -import { of } from 'rxjs'; -import { EventService } from './event.service'; -import { FilterService } from '../filter/filter.service'; -import { UserService } from '../user/user.service'; -import { LocalStorageService } from '../http/local-storage.service'; -import { SessionService } from 'mage-web-app/http/session.service'; -import { PollingService } from './polling.service'; -import { LocationService } from '../user/location/location.service'; -import { ObservationService } from '../observation/observation.service'; -import { LayerService } from '../layer/layer.service'; -import { FeedService } from '@ngageoint/mage.web-core-lib/feed'; -import { provideHttpClientTesting } from '@angular/common/http/testing'; -import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; - -const event: any = { id: 1, name: 'Event 1', teams: [], forms: [] }; - -describe('Event Service Tests', () => { - - let service: EventService; - let filterService: FilterService; - let pollingService: jasmine.SpyObj; - let locationService: jasmine.SpyObj; - let observationService: jasmine.SpyObj; - let layerService: jasmine.SpyObj; - let feedService: jasmine.SpyObj; +import { TestBed, fakeAsync, tick, discardPeriodicTasks } from '@angular/core/testing' +import { HttpClientTestingModule } from '@angular/common/http/testing' +import { EventService } from './event.service' +import { FilterService } from '../filter/filter.service' +import { PollingService } from './polling.service' +import { ObservationService } from '../observation/observation.service' +import { LayerService } from '../layer/layer.service' +import { LocationService } from '../user/location/location.service' +import { SessionService } from '../http/session.service' +import { FeedService } from '@ngageoint/mage.web-core-lib/feed' +import { BehaviorSubject, Subject, of, throwError } from 'rxjs' +import { DEFAULT_LOCATION_FILTER, DEFAULT_OBSERVATION_FILTER, EventObservationFilter } from '../filter/filter.types' + +const mockEvent: any = { + id: 1, + name: 'Test Event', + teams: [{ id: 't1', userIds: ['user1', 'u2'] }], + forms: [] +} + +function observation(overrides: any = {}) { + return { + id: 'obs1', + userId: 'user1', + eventId: 1, + lastModified: '2024-01-01T00:00:00Z', + attachments: [], + favoriteUserIds: [], + properties: { timestamp: '2024-01-01T00:00:00Z' }, + ...overrides + } +} + +function mapObservation(overrides: any = {}) { + return { + id: 'obs1', + type: 'Feature', + geometry: { type: 'Point', coordinates: [0, 0] }, + lastModified: '2024-01-01T00:00:00Z', + state: { id: 'state1', name: 'active' }, + properties: { forms: [] }, + ...overrides + } +} + +function userLocation(overrides: any = {}) { + return { + id: 'user1', + user: { id: 'user1' }, + locations: [{ properties: { timestamp: '2024-01-01T00:00:00Z' } }], + ...overrides + } +} + +class MockPollingService { + pollingInterval$ = new BehaviorSubject(30000).asObservable() + getPollingInterval = jasmine.createSpy('getPollingInterval').and.returnValue(30000) +} + +class MockObservationService { + pagingSubject = new BehaviorSubject(null) + paging$ = this.pagingSubject.asObservable() + getObservationsForMap = jasmine.createSpy('getObservationsForMap').and.returnValue(of([])) + getObservationsPage = jasmine.createSpy('getObservationsPage').and.returnValue(of({ items: [], totalCount: 0, pageIndex: 0, pageSize: 50 })) + getPagingOptions = jasmine.createSpy('getPagingOptions').and.returnValue(null) + setPagingOptions = jasmine.createSpy('setPagingOptions') + saveObservationForEvent = jasmine.createSpy('saveObservationForEvent').and.returnValue(of({})) + addObservationFavorite = jasmine.createSpy('addObservationFavorite').and.returnValue(of({})) + removeObservationFavorite = jasmine.createSpy('removeObservationFavorite').and.returnValue(of({})) + markObservationAsImportantForEvent = jasmine.createSpy('markObservationAsImportantForEvent').and.returnValue(of({})) + clearObservationAsImportantForEvent = jasmine.createSpy('clearObservationAsImportantForEvent').and.returnValue(of({})) + archiveObservationForEvent = jasmine.createSpy('archiveObservationForEvent').and.callFake((_event: any, obs: any) => of(obs)) + deleteAttachmentInObservationForEvent = jasmine.createSpy('deleteAttachmentInObservationForEvent').and.returnValue(of(null)) +} + +class MockLayerService { + getLayersForEvent = jasmine.createSpy('getLayersForEvent').and.returnValue(of([])) +} + +class MockLocationService { + getUserLocationsForEvent = jasmine.createSpy('getUserLocationsForEvent').and.returnValue(of([])) +} + +class MockFeedService { + fetchFeeds = jasmine.createSpy('fetchFeeds').and.returnValue(of([])) + fetchFeedItems = jasmine.createSpy('fetchFeedItems').and.returnValue(of({ items: { features: [] } })) +} + +class MockSessionService { + user = { id: 'current-user' } + getToken = jasmine.createSpy('getToken').and.returnValue('test-token') +} + +describe('EventService', () => { + let service: EventService + let filterService: jasmine.SpyObj + let observationService: MockObservationService + let locationService: MockLocationService + let eventSubject: BehaviorSubject + let observationFilterSubject: BehaviorSubject + let locationFilterSubject: BehaviorSubject beforeEach(() => { - const userService = jasmine.createSpyObj('UserService', ['addRecentEvent']); - userService.addRecentEvent.and.returnValue(of({})); - - const localStorageService = jasmine.createSpyObj('LocalStorageService', [ - 'getTimeInterval', 'setTimeInterval', - 'getTeams', 'setTeams', - 'setUsers', 'setForms' - ]); - localStorageService.getTimeInterval.and.returnValue(null); - localStorageService.getTeams.and.returnValue([]); - - pollingService = jasmine.createSpyObj('PollingService', ['addListener', 'removeListener', 'getPollingInterval']); - locationService = jasmine.createSpyObj('LocationService', ['getUserLocationsForEvent']); - locationService.getUserLocationsForEvent.and.returnValue(of([])); - observationService = jasmine.createSpyObj('ObservationService', ['getObservationsForEvent']); - observationService.getObservationsForEvent.and.returnValue(of([])); - layerService = jasmine.createSpyObj('LayerService', ['getLayersForEvent']); - layerService.getLayersForEvent.and.returnValue(of([])); - feedService = jasmine.createSpyObj('FeedService', ['fetchFeeds']); - feedService.fetchFeeds.and.returnValue(of([])); + eventSubject = new BehaviorSubject(null) + observationFilterSubject = new BehaviorSubject(null) + locationFilterSubject = new BehaviorSubject(null) + + filterService = jasmine.createSpyObj('FilterService', ['getEvent', 'getObservationFilter', 'getLocationFilter', 'getEffectiveTimeInterval'], { + event$: eventSubject.asObservable(), + observationFilter$: observationFilterSubject.asObservable(), + locationFilter$: locationFilterSubject.asObservable() + }) + filterService.getEvent.and.returnValue(null) + filterService.getObservationFilter.and.returnValue(null) + filterService.getLocationFilter.and.returnValue(null) + filterService.getEffectiveTimeInterval.and.returnValue({}) + + observationService = new MockObservationService() + locationService = new MockLocationService() TestBed.configureTestingModule({ - imports: [], + imports: [HttpClientTestingModule], providers: [ EventService, - FilterService, - { provide: UserService, useValue: userService }, - { provide: LocalStorageService, useValue: localStorageService }, - { provide: SessionService, useValue: {} }, - { provide: PollingService, useValue: pollingService }, - { provide: LocationService, useValue: locationService }, + { provide: FilterService, useValue: filterService }, + { provide: PollingService, useClass: MockPollingService }, { provide: ObservationService, useValue: observationService }, - { provide: LayerService, useValue: layerService }, - { provide: FeedService, useValue: feedService }, - provideHttpClient(withInterceptorsFromDi()), - provideHttpClientTesting() + { provide: LayerService, useClass: MockLayerService }, + { provide: LocationService, useValue: locationService }, + { provide: FeedService, useClass: MockFeedService }, + { provide: SessionService, useClass: MockSessionService } ] - }); + }) - service = TestBed.inject(EventService); - filterService = TestBed.inject(FilterService); - }); + service = TestBed.inject(EventService) + }) afterEach(() => { - service.destroy(); - }); + service.destroy() + }) + + // Calls init() with an active event so this.observations and this.locations are initialized + function initWithEvent() { + filterService.getEvent.and.returnValue(mockEvent) + service.init() + } + + describe('destroy', () => { + const feed = { id: 'f1', title: 'Feed', updateFrequencySeconds: 1, itemsHaveSpatialDimension: true } + + it('stops polling feeds when a feed request is still in flight', fakeAsync(() => { + const items$ = new Subject() + const feedService: any = TestBed.inject(FeedService) + feedService.fetchFeeds.and.returnValue(of([feed])) + feedService.fetchFeedItems.and.callFake(() => items$.asObservable()) + filterService.getEvent.and.returnValue(mockEvent) + service.init() + service.onEventChanged(null, mockEvent as any) + const callsBefore = feedService.fetchFeedItems.calls.count() + + service.destroy() + items$.next({ items: { features: [] } }) + items$.complete() + tick(5000) + + expect(feedService.fetchFeedItems.calls.count()).toBe(callsBefore) + discardPeriodicTasks() + })) + + it('ignores a layers response that arrives after destroy', () => { + const layers$ = new Subject() + TestBed.inject(LayerService).getLayersForEvent = jasmine.createSpy('getLayersForEvent').and.returnValue(layers$.asObservable()) + const listener = { onLayersChanged: jasmine.createSpy('onLayersChanged') } + service.addLayersChangedListener(listener) + filterService.getEvent.and.returnValue(mockEvent) + service.init() + service.onEventChanged(null, mockEvent as any) + listener.onLayersChanged.calls.reset() + + service.destroy() + layers$.next([{ id: 1 }]) + + expect(listener.onLayersChanged).not.toHaveBeenCalled() + }) + + it('ignores a feeds response that arrives after destroy', () => { + const feeds$ = new Subject() + TestBed.inject(FeedService).fetchFeeds = jasmine.createSpy('fetchFeeds').and.returnValue(feeds$.asObservable()) + const listener = { onFeedItemsChanged: jasmine.createSpy('onFeedItemsChanged') } + service.addFeedItemsChangedListener(listener) + filterService.getEvent.and.returnValue(mockEvent) + service.init() + service.onEventChanged(null, mockEvent as any) + + service.destroy() + feeds$.next([feed]) + + expect(listener.onFeedItemsChanged).not.toHaveBeenCalled() + }) + + it('resets the observation, page and location streams', () => { + initWithEvent() + service.parseMapObservations([mapObservation()]) + service.parseLocations([userLocation()]) + + service.destroy() + + let mapObservations: any = 'unset' + let observationPage: any = 'unset' + let locations: any = 'unset' + service.mapObservations$.subscribe(value => mapObservations = value) + service.observationPage$.subscribe(value => observationPage = value) + service.locations$.subscribe(value => locations = value) + expect(mapObservations).toBeNull() + expect(observationPage).toBeNull() + expect(locations).toBeNull() + }) + }) + + describe('init', () => { + it('fetches the observation page when paging is set but not when it is cleared', () => { + filterService.getEvent.and.returnValue(mockEvent) + service.init() + observationService.getPagingOptions.and.returnValue({ page: 1, page_size: 50 }) + observationService.getObservationsPage.calls.reset() + + observationService.pagingSubject.next({ page: 1, page_size: 50 }) + expect(observationService.getObservationsPage).toHaveBeenCalledTimes(1) + + observationService.getPagingOptions.and.returnValue(null) + observationService.pagingSubject.next(null) + expect(observationService.getObservationsPage).toHaveBeenCalledTimes(1) + }) + + it('fetches observations immediately when observation filter emits with an active event', () => { + filterService.getEvent.and.returnValue(mockEvent) + service.init() + expect(observationService.getObservationsForMap).toHaveBeenCalled() + }) + + it('does not fetch observations when no event is set', () => { + filterService.getEvent.and.returnValue(null) + service.init() + expect(observationService.getObservationsForMap).not.toHaveBeenCalled() + }) + + it('tears down subscriptions from a second init/destroy cycle', () => { + filterService.getEvent.and.returnValue(mockEvent) + service.init() + service.destroy() + service.init() + service.destroy() + + observationService.getObservationsForMap.calls.reset() + observationFilterSubject.next({ ...DEFAULT_OBSERVATION_FILTER, hasAttachments: true }) + + expect(observationService.getObservationsForMap).not.toHaveBeenCalled() + }) + + it('re-fetches observations when observation filter changes', () => { + filterService.getEvent.and.returnValue(mockEvent) + service.init() + observationService.getObservationsForMap.calls.reset() + + observationFilterSubject.next({ hasAttachments: true } as any) + + expect(observationService.getObservationsForMap).toHaveBeenCalled() + }) + + it('emits empty array on observations$ when observation filter changes', () => { + filterService.getEvent.and.returnValue(mockEvent) + service.init() + service.parseMapObservations([mapObservation()]) // populate some data + + const emissions: any[][] = [] + service.mapObservations$.subscribe(v => { if (v !== null) emissions.push(v) }) + + observationFilterSubject.next({ hasAttachments: true } as any) + + expect(emissions[emissions.length - 1]).toEqual([]) + }) + + it('re-fetches observations when event switches even if filter value is unchanged', () => { + const event2: any = { id: 2, name: 'Event 2', teams: [], forms: [] } + const sharedFilter = { hasAttachments: true } as any + + filterService.getEvent.and.returnValue(mockEvent) + service.init() + observationFilterSubject.next(sharedFilter) + observationService.getObservationsForMap.calls.reset() + + filterService.getEvent.and.returnValue(event2) + observationFilterSubject.next(sharedFilter) + + expect(observationService.getObservationsForMap).toHaveBeenCalled() + }) + + it('fetches locations immediately when location filter emits with an active event', () => { + filterService.getEvent.and.returnValue(mockEvent) + service.init() + expect(locationService.getUserLocationsForEvent).toHaveBeenCalled() + }) + + it('does not fetch locations when no event is set', () => { + filterService.getEvent.and.returnValue(null) + service.init() + expect(locationService.getUserLocationsForEvent).not.toHaveBeenCalled() + }) + + it('re-fetches locations when location filter changes', () => { + filterService.getEvent.and.returnValue(mockEvent) + service.init() + locationService.getUserLocationsForEvent.calls.reset() + + locationFilterSubject.next({ memberFilter: { teamIds: ['t1'], userIds: [] } }) + + expect(locationService.getUserLocationsForEvent).toHaveBeenCalled() + }) + + it('re-fetches locations when event switches even if filter value is unchanged', () => { + const event2: any = { id: 2, name: 'Event 2', teams: [], forms: [] } + const sharedFilter = { memberFilter: { teamIds: ['t1'], userIds: [] } } + + filterService.getEvent.and.returnValue(mockEvent) + service.init() + locationFilterSubject.next(sharedFilter) + locationService.getUserLocationsForEvent.calls.reset() + + filterService.getEvent.and.returnValue(event2) + locationFilterSubject.next(sharedFilter) + + expect(locationService.getUserLocationsForEvent).toHaveBeenCalled() + }) + + it('emits empty array on locations$ when location filter changes', () => { + filterService.getEvent.and.returnValue(mockEvent) + service.init() + service.parseLocations([userLocation()]) // populate some data + + const emissions: any[][] = [] + service.locations$.subscribe(result => { if (result !== null) emissions.push(result.data) }) + + locationFilterSubject.next({ memberFilter: { teamIds: ['t1'], userIds: [] } }) + + expect(emissions[emissions.length - 1]).toEqual([]) + }) + + it('registers a new event when event$ emits', () => { + const anotherEvent = { id: 2, name: 'Another', teams: [], forms: [] } + service.init() + eventSubject.next(anotherEvent) + expect(service.getEventById(anotherEvent.id)).toBeDefined() + }) + }) + + describe('onEventChanged', () => { + it('registers a new event in eventsById', () => { + service.onEventChanged(null, mockEvent) + expect(service.getEventById(mockEvent.id)).toBeDefined() + }) + + it('removes event from eventsById on event removal', () => { + service.onEventChanged(null, mockEvent) + service.onEventChanged(mockEvent, null) + expect(service.getEventById(mockEvent.id)).toBeUndefined() + }) + + it('tells layer listeners to remove the previous event\'s layers by id', () => { + TestBed.inject(LayerService).getLayersForEvent = jasmine.createSpy('getLayersForEvent').and.returnValue(of([{ id: 1 }, { id: 2 }])) + const listener = { onLayersChanged: jasmine.createSpy('onLayersChanged') } + service.addLayersChangedListener(listener) + service.onEventChanged(null, mockEvent) + listener.onLayersChanged.calls.reset() + + service.onEventChanged(mockEvent, null) + + expect(listener.onLayersChanged.calls.mostRecent().args[0].removed).toEqual([1, 2]) + }) + }) + + describe('observations$', () => { + it('emits null before init is called', () => { + let latest: any = 'not-set' + service.mapObservations$.subscribe(v => latest = v) + expect(latest).toBeNull() + }) + + it('emits array after parseObservations adds an observation', () => { + initWithEvent() + const obs = mapObservation() + const emissions: any[][] = [] + service.mapObservations$.subscribe(v => { if (v !== null) emissions.push(v) }) + + service.parseMapObservations([obs]) + + const latest = emissions[emissions.length - 1] + expect(latest.length).toBe(1) + expect(latest[0].id).toBe(obs.id) + }) + + it('does not emit when observations are unchanged', () => { + initWithEvent() + const obs = mapObservation() + service.parseMapObservations([obs]) + + const emissions: any[][] = [] + service.mapObservations$.subscribe(v => { if (v !== null) emissions.push(v) }) + const countBefore = emissions.length + + service.parseMapObservations([obs]) // same lastModified — no change + + expect(emissions.length).toBe(countBefore) + }) + }) + + describe('locations$', () => { + it('emits null before init is called', () => { + let latest: any = 'not-set' + service.locations$.subscribe(result => latest = result) + expect(latest).toBeNull() + }) + + it('emits array after parseLocations adds a user location', () => { + initWithEvent() + const emissions: any[][] = [] + service.locations$.subscribe(result => { if (result !== null) emissions.push(result.data) }) + + service.parseLocations([userLocation()]) + + const latest = emissions[emissions.length - 1] + expect(latest.length).toBe(1) + }) + + it('does not emit when locations are unchanged', () => { + initWithEvent() + service.parseLocations([userLocation()]) + + const emissions: any[][] = [] + service.locations$.subscribe(result => { if (result !== null) emissions.push(result.data) }) + const countBefore = emissions.length + + service.parseLocations([userLocation()]) // same timestamp — no change + + expect(emissions.length).toBe(countBefore) + }) + }) + + describe('parseObservations', () => { + beforeEach(() => { + initWithEvent() + }) + + it('adds new observations', () => { + const obs = mapObservation() + const latest: any[] = [] + service.mapObservations$.subscribe(v => { if (v) latest.splice(0, latest.length, ...v) }) + + service.parseMapObservations([obs]) + + expect(latest.find(o => o.id === obs.id)).toBeDefined() + }) + + it('updates observations with a changed lastModified', () => { + const obs = mapObservation() + service.parseMapObservations([obs]) + + const updated = { ...obs, lastModified: '2024-01-02T00:00:00Z' } + const emissions: any[][] = [] + service.mapObservations$.subscribe(v => { if (v) emissions.push(v) }) + const countBefore = emissions.length + + service.parseMapObservations([updated]) + + expect(emissions.length).toBeGreaterThan(countBefore) + const latest = emissions[emissions.length - 1] + expect(latest.find((o: any) => o.lastModified === updated.lastModified)).toBeDefined() + }) + + it('emits empty array when fetch returns no results', () => { + const emissions: any[][] = [] + service.mapObservations$.subscribe(v => { if (v !== null) emissions.push(v) }) + const countBefore = emissions.length + + service.parseMapObservations([]) + + expect(emissions.length).toBeGreaterThan(countBefore) + expect(emissions[emissions.length - 1]).toEqual([]) + }) + + it('does not emit for an observation with an unchanged lastModified', () => { + const obs = mapObservation() + service.parseMapObservations([obs]) + + const emissions: any[][] = [] + service.mapObservations$.subscribe(v => { if (v) emissions.push(v) }) + const countBefore = emissions.length + + service.parseMapObservations([obs]) + + expect(emissions.length).toBe(countBefore) + }) + + it('removes archived observations', () => { + const obs = mapObservation() + service.parseMapObservations([obs]) + + const latest: any[] = [] + service.mapObservations$.subscribe(v => { if (v) latest.splice(0, latest.length, ...v) }) + + service.parseMapObservations([{ ...obs, state: { ...obs.state, name: 'archive' } }]) + + expect(latest.find(o => o.id === obs.id)).toBeUndefined() + }) + + it('does not emit for an archived observation that was never in the cache', () => { + const emissions: any[][] = [] + service.mapObservations$.subscribe(v => { if (v) emissions.push(v) }) + const countBefore = emissions.length + + service.parseMapObservations([mapObservation({ state: { id: 'state1', name: 'archive' } })]) + + expect(emissions.length).toBe(countBefore) + }) + + it('tracks the highest lastModified across all observations', () => { + const obs1 = mapObservation({ id: 'obs1', lastModified: '2024-01-01T00:00:00Z' }) + const obs2 = mapObservation({ id: 'obs2', lastModified: '2024-01-03T00:00:00Z' }) + service.parseMapObservations([obs1, obs2]) + + observationService.getObservationsForMap.calls.reset() + service.fetchMapObservations().subscribe() + + const params = observationService.getObservationsForMap.calls.mostRecent().args[1] + expect(params.startDate).toBe('2024-01-03T00:00:00Z') + }) + }) + + describe('parseLocations', () => { + beforeEach(() => { + initWithEvent() + }) + + it('gives a user with an icon a marker icon url with only the access token', () => { + const latest: any[] = [] + service.locations$.subscribe(result => { if (result) latest.splice(0, latest.length, ...result.data) }) + + service.parseLocations([userLocation({ user: { id: 'user1', iconUrl: '/api/users/user1/icon' } })]) + + expect(latest[0].location.style.iconUrl).toBe('/api/users/user1/icon?access_token=test-token') + }) + + it('does not style a user without an icon', () => { + const latest: any[] = [] + service.locations$.subscribe(result => { if (result) latest.splice(0, latest.length, ...result.data) }) + + service.parseLocations([userLocation()]) + + expect(latest[0].location.style).toBeUndefined() + }) + + it('adds new user locations', () => { + const latest: any[] = [] + service.locations$.subscribe(result => { if (result) latest.splice(0, latest.length, ...result.data) }) + + service.parseLocations([userLocation()]) + + expect(latest.length).toBe(1) + }) + + it('updates user locations with a changed timestamp', () => { + service.parseLocations([userLocation()]) + + const updated = userLocation({ locations: [{ properties: { timestamp: '2024-01-02T00:00:00Z' } }] }) + const emissions: any[][] = [] + service.locations$.subscribe(result => { if (result) emissions.push(result.data) }) + const countBefore = emissions.length + + service.parseLocations([updated]) + + expect(emissions.length).toBeGreaterThan(countBefore) + }) + + it('emits empty array when fetch returns no results', () => { + const emissions: any[][] = [] + service.locations$.subscribe(result => { if (result !== null) emissions.push(result.data) }) + const countBefore = emissions.length + + service.parseLocations([]) + + expect(emissions.length).toBeGreaterThan(countBefore) + expect(emissions[emissions.length - 1]).toEqual([]) + }) + + it('does not emit for a user location with an unchanged timestamp', () => { + service.parseLocations([userLocation()]) + + const emissions: any[][] = [] + service.locations$.subscribe(result => { if (result) emissions.push(result.data) }) + const countBefore = emissions.length + + service.parseLocations([userLocation()]) + + expect(emissions.length).toBe(countBefore) + }) + + it('removes user locations not returned by the server', () => { + service.parseLocations([userLocation()]) + + const latest: any[] = [] + service.locations$.subscribe(result => { if (result) latest.splice(0, latest.length, ...result.data) }) + + service.parseLocations([]) + + expect(latest.length).toBe(0) + }) + + it('attaches iconUrl with access token when user has an iconUrl', () => { + const loc = userLocation({ user: { id: 'user1', lastUpdated: '2024-01-01', iconUrl: '/api/user1/icon' } }) + const latest: any[] = [] + service.locations$.subscribe(result => { if (result) latest.splice(0, latest.length, ...result.data) }) + + service.parseLocations([loc]) + + expect(latest[0].location.style.iconUrl).toContain('test-token') + }) + }) + + describe('isUserInEvent', () => { + it('returns true when user is in a team in the event', () => { + expect(service.isUserInEvent({ id: 'user1' } as any, mockEvent as any)).toBeTrue() + }) + + it('returns false when user is not in any team', () => { + expect(service.isUserInEvent({ id: 'unknown' } as any, mockEvent as any)).toBeFalse() + }) + + it('returns false when event is null', () => { + expect(service.isUserInEvent({ id: 'user1' } as any, null as any)).toBeFalse() + }) + }) + + describe('fetchObservations', () => { + beforeEach(() => { + initWithEvent() + observationService.getObservationsForMap.calls.reset() + }) + + it('uses states=active on initial fetch before any observations are loaded', () => { + service.fetchMapObservations().subscribe() + + const params = observationService.getObservationsForMap.calls.mostRecent().args[1] + expect(params.states).toBe('active') + expect(params.startDate).toBeUndefined() + }) + + it('uses startDate instead of states after observations have been loaded', () => { + service.parseMapObservations([mapObservation({ lastModified: '2024-06-01T00:00:00Z' })]) + + service.fetchMapObservations().subscribe() + + const params = observationService.getObservationsForMap.calls.mostRecent().args[1] + expect(params.startDate).toBe('2024-06-01T00:00:00Z') + expect(params.states).toBeUndefined() + }) + + it('passes time interval dates to observation service', () => { + const start = new Date('2024-01-01') + const end = new Date('2024-01-31') + filterService.getEffectiveTimeInterval.and.returnValue({ start, end }) + + service.fetchMapObservations().subscribe() + + const params = observationService.getObservationsForMap.calls.mostRecent().args[1] + expect(params.observationStartDate).toBe(start.toISOString()) + expect(params.observationEndDate).toBe(end.toISOString()) + }) + + it('passes hasAttachments when filter is set', () => { + filterService.getObservationFilter.and.returnValue({ ...DEFAULT_OBSERVATION_FILTER, hasAttachments: true }) + + service.fetchMapObservations().subscribe() + + const params = observationService.getObservationsForMap.calls.mostRecent().args[1] + expect(params.hasAttachments).toBeTrue() + }) + + it('passes important when isFlaggedImportant is set', () => { + filterService.getObservationFilter.and.returnValue({ ...DEFAULT_OBSERVATION_FILTER, isFlaggedImportant: true }) + + service.fetchMapObservations().subscribe() + + const params = observationService.getObservationsForMap.calls.mostRecent().args[1] + expect(params.important).toBeTrue() + }) + + it('passes favoritedBy with current user id when isUserFavorite is set', () => { + filterService.getObservationFilter.and.returnValue({ ...DEFAULT_OBSERVATION_FILTER, isUserFavorite: true }) + + service.fetchMapObservations().subscribe() + + const params = observationService.getObservationsForMap.calls.mostRecent().args[1] + expect(params.favoritedBy).toBe('current-user') + }) + + it('passes team and user ids from memberFilter', () => { + filterService.getObservationFilter.and.returnValue({ + ...DEFAULT_OBSERVATION_FILTER, memberFilter: { teamIds: ['t1', 't2'], userIds: ['u3'] } + }) + + service.fetchMapObservations().subscribe() + + const params = observationService.getObservationsForMap.calls.mostRecent().args[1] + expect(params.teams).toEqual(['t1', 't2']) + expect(params.users).toEqual(['u3']) + }) + + it('does not pass team/user ids when memberFilter arrays are empty', () => { + filterService.getObservationFilter.and.returnValue({ + ...DEFAULT_OBSERVATION_FILTER, memberFilter: { teamIds: [], userIds: [] } + }) + + service.fetchMapObservations().subscribe() + + const params = observationService.getObservationsForMap.calls.mostRecent().args[1] + expect(params.teams).toBeUndefined() + expect(params.users).toBeUndefined() + }) + }) + + describe('fetchObservationPage', () => { + beforeEach(() => { + initWithEvent() + observationService.getPagingOptions.and.returnValue({ page: 0, page_size: 50 }) + observationService.getObservationsPage.calls.reset() + }) + + it('returns early without calling service when paging options are null', () => { + observationService.getPagingOptions.and.returnValue(null) + + service.fetchObservationPage().subscribe() + + expect(observationService.getObservationsPage).not.toHaveBeenCalled() + }) + + it('passes sort=timestamp+desc', () => { + service.fetchObservationPage().subscribe() + + const params = observationService.getObservationsPage.calls.mostRecent().args[1] + expect(params.sort).toBe('timestamp+desc') + }) + + it('passes page and page_size from paging options', () => { + observationService.getPagingOptions.and.returnValue({ page: 2, page_size: 25 }) + + service.fetchObservationPage().subscribe() + + const params = observationService.getObservationsPage.calls.mostRecent().args[1] + expect(params.page).toBe(2) + expect(params.page_size).toBe(25) + }) + + it('passes time interval dates to observation service', () => { + const start = new Date('2024-01-01') + const end = new Date('2024-01-31') + filterService.getEffectiveTimeInterval.and.returnValue({ start, end }) + + service.fetchObservationPage().subscribe() + + const params = observationService.getObservationsPage.calls.mostRecent().args[1] + expect(params.observationStartDate).toBe(start.toISOString()) + expect(params.observationEndDate).toBe(end.toISOString()) + }) + + it('passes hasAttachments when filter is set', () => { + filterService.getObservationFilter.and.returnValue({ ...DEFAULT_OBSERVATION_FILTER, hasAttachments: true }) + + service.fetchObservationPage().subscribe() + + const params = observationService.getObservationsPage.calls.mostRecent().args[1] + expect(params.hasAttachments).toBeTrue() + }) + + it('passes important when isFlaggedImportant is set', () => { + filterService.getObservationFilter.and.returnValue({ ...DEFAULT_OBSERVATION_FILTER, isFlaggedImportant: true }) + + service.fetchObservationPage().subscribe() + + const params = observationService.getObservationsPage.calls.mostRecent().args[1] + expect(params.important).toBeTrue() + }) + + it('passes favoritedBy with current user id when isUserFavorite is set', () => { + filterService.getObservationFilter.and.returnValue({ ...DEFAULT_OBSERVATION_FILTER, isUserFavorite: true }) + + service.fetchObservationPage().subscribe() + + const params = observationService.getObservationsPage.calls.mostRecent().args[1] + expect(params.favoritedBy).toBe('current-user') + }) + + it('passes team and user ids from memberFilter', () => { + filterService.getObservationFilter.and.returnValue({ + ...DEFAULT_OBSERVATION_FILTER, memberFilter: { teamIds: ['t1', 't2'], userIds: ['u3'] } + }) + + service.fetchObservationPage().subscribe() + + const params = observationService.getObservationsPage.calls.mostRecent().args[1] + expect(params.teams).toEqual(['t1', 't2']) + expect(params.users).toEqual(['u3']) + }) + + it('does not pass team/user ids when memberFilter arrays are empty', () => { + filterService.getObservationFilter.and.returnValue({ + ...DEFAULT_OBSERVATION_FILTER, memberFilter: { teamIds: [], userIds: [] } + }) + + service.fetchObservationPage().subscribe() + + const params = observationService.getObservationsPage.calls.mostRecent().args[1] + expect(params.teams).toBeUndefined() + expect(params.users).toBeUndefined() + }) + + it('emits the page result on observationPage$', () => { + const page = { items: [observation()], totalCount: 1, pageIndex: 0, pageSize: 50 } + observationService.getObservationsPage.and.returnValue(of(page)) + + const emissions: any[] = [] + service.observationPage$.subscribe(v => { if (v) emissions.push(v) }) + + service.fetchObservationPage().subscribe() + + expect(emissions[emissions.length - 1]).toEqual({ + data: page.items, + totalCount: page.totalCount, + pageIndex: 0, + error: null, + userInitiated: false + }) + }) + + it('emits an error result on observationPage$ when the request fails, preserving prior data', () => { + const page = { items: [observation()], totalCount: 1, pageIndex: 0, pageSize: 50 } + observationService.getObservationsPage.and.returnValue(of(page)) + service.fetchObservationPage().subscribe() + + const err = new Error('boom') + observationService.getObservationsPage.and.returnValue(throwError(() => err)) + + const emissions: any[] = [] + service.observationPage$.subscribe(v => { if (v) emissions.push(v) }) + + service.fetchObservationPage().subscribe() + + const last = emissions[emissions.length - 1] + expect(last.error).toBe(err) + expect(last.data).toEqual(page.items) + }) + }) + + describe('paging$ subscription', () => { + beforeEach(() => { + filterService.getEvent.and.returnValue(mockEvent) + observationService.getPagingOptions.and.returnValue({ page: 0, page_size: 50 }) + service.init() + observationService.getObservationsPage.calls.reset() + }) + + it('calls getObservationsPage when paging$ emits a non-null value', () => { + observationService.pagingSubject.next({ page: 0, page_size: 50 }) + + expect(observationService.getObservationsPage).toHaveBeenCalled() + }) + + it('does not call getObservationsPage when paging$ emits null', () => { + observationService.pagingSubject.next(null) + + expect(observationService.getObservationsPage).not.toHaveBeenCalled() + }) + + it('does not call getObservationsPage when no event is set', () => { + filterService.getEvent.and.returnValue(null) + + observationService.pagingSubject.next({ page: 0, page_size: 50 }) + + expect(observationService.getObservationsPage).not.toHaveBeenCalled() + }) + + it('calls getObservationsPage on each emission even with the same value', () => { + const paging = { page: 0, page_size: 50 } + observationService.pagingSubject.next(paging) + observationService.pagingSubject.next(paging) + + expect(observationService.getObservationsPage).toHaveBeenCalledTimes(2) + }) + + it('reschedules the poll tick once the page fetch settles', () => { + const scheduleSpy = spyOn(service as any, 'schedulePollTick').and.callThrough() + + observationService.pagingSubject.next({ page: 0, page_size: 50 }) + + expect(scheduleSpy).toHaveBeenCalled() + }) + }) + + describe('retrySearch', () => { + it('does nothing when there is no active event', () => { + filterService.getEvent.and.returnValue(null) + service.init() + observationService.getObservationsForMap.calls.reset() + observationService.getObservationsPage.calls.reset() + + service.retrySearch() + + expect(observationService.getObservationsForMap).not.toHaveBeenCalled() + expect(observationService.getObservationsPage).not.toHaveBeenCalled() + }) + + it('re-fetches both the map observations and the current page as user-initiated', () => { + observationService.getPagingOptions.and.returnValue({ page: 0, page_size: 50 }) + initWithEvent() + observationService.getObservationsForMap.calls.reset() + observationService.getObservationsPage.calls.reset() + + service.retrySearch() + + expect(observationService.getObservationsForMap).toHaveBeenCalled() + expect(observationService.getObservationsPage).toHaveBeenCalled() + }) + + it('surfaces the error on observationPage$ as user-initiated when the retry fails', () => { + observationService.getPagingOptions.and.returnValue({ page: 0, page_size: 50 }) + initWithEvent() + + const failure = new Error('still down') + observationService.getObservationsPage.and.returnValue(throwError(() => failure)) + + const emissions: any[] = [] + service.observationPage$.subscribe(result => { if (result !== null) emissions.push(result) }) + + service.retrySearch() + + const latest = emissions[emissions.length - 1] + expect(latest.error).toBe(failure) + expect(latest.userInitiated).toBeTrue() + }) + }) + + describe('retryLocations', () => { + it('does nothing when there is no active event', () => { + filterService.getEvent.and.returnValue(null) + service.init() + locationService.getUserLocationsForEvent.calls.reset() + + service.retryLocations() + + expect(locationService.getUserLocationsForEvent).not.toHaveBeenCalled() + }) + + it('re-fetches locations as user-initiated', () => { + initWithEvent() + locationService.getUserLocationsForEvent.calls.reset() + + service.retryLocations() + + expect(locationService.getUserLocationsForEvent).toHaveBeenCalled() + }) + + it('surfaces the error on locations$ as user-initiated when the retry fails', () => { + initWithEvent() + + const failure = new Error('still down') + locationService.getUserLocationsForEvent.and.returnValue(throwError(() => failure)) + + const emissions: any[] = [] + service.locations$.subscribe(result => { if (result !== null) emissions.push(result) }) + + service.retryLocations() + + const latest = emissions[emissions.length - 1] + expect(latest.error).toBe(failure) + expect(latest.userInitiated).toBeTrue() + }) + }) + + describe('fetchLocations', () => { + beforeEach(() => { + initWithEvent() + locationService.getUserLocationsForEvent.calls.reset() + }) + + it('passes time interval dates to location service', () => { + const start = new Date('2024-01-01') + const end = new Date('2024-01-31') + filterService.getEffectiveTimeInterval.and.returnValue({ start, end }) + + service.fetchLocations().subscribe() + + const params = locationService.getUserLocationsForEvent.calls.mostRecent().args[1] + expect(params.startDate).toBe(start.toISOString()) + expect(params.endDate).toBe(end.toISOString()) + }) + + it('passes team and user ids from memberFilter', () => { + filterService.getLocationFilter.and.returnValue({ + ...DEFAULT_LOCATION_FILTER, memberFilter: { teamIds: ['t1'], userIds: ['u1'] } + }) + + service.fetchLocations().subscribe() + + const params = locationService.getUserLocationsForEvent.calls.mostRecent().args[1] + expect(params.teams).toEqual(['t1']) + expect(params.users).toEqual(['u1']) + }) + + it('does not pass team/user ids when memberFilter arrays are empty', () => { + filterService.getLocationFilter.and.returnValue({ + ...DEFAULT_LOCATION_FILTER, memberFilter: { teamIds: [], userIds: [] } + }) + + service.fetchLocations().subscribe() + + const params = locationService.getUserLocationsForEvent.calls.mostRecent().args[1] + expect(params.teams).toBeUndefined() + expect(params.users).toBeUndefined() + }) + }) + + describe('archiveObservation', () => { + let obs: any + + beforeEach(() => { + obs = observation() + initWithEvent() + service.onEventChanged(null, mockEvent as any) + service.parseMapObservations([obs]) + observationService.getPagingOptions.and.returnValue({ page: 0, page_size: 50 }) + observationService.getObservationsPage.calls.reset() + }) + + it('removes the observation from the map observations', () => { + const latest: any[] = [] + service.mapObservations$.subscribe(v => { if (v) latest.splice(0, latest.length, ...v) }) + + service.archiveObservation(obs).subscribe() + + expect(latest.find(o => o.id === obs.id)).toBeUndefined() + }) + + it('refreshes the observation page so the list drops the archived row', () => { + service.archiveObservation(obs).subscribe() + + expect(observationService.getObservationsPage).toHaveBeenCalled() + }) + }) + + describe('removeObservationFavorite', () => { + let obs: any + + beforeEach(() => { + obs = observation({ favoriteUserIds: ['current-user'] }) + initWithEvent() + service.onEventChanged(null, mockEvent as any) + service.parseMapObservations([obs]) + observationService.removeObservationFavorite.and.returnValue(of({ ...obs, favoriteUserIds: [] })) + observationService.getObservationsForMap.calls.reset() + }) + + it('removes observation from list when isUserFavorite filter is active', () => { + filterService.getObservationFilter.and.returnValue({ ...DEFAULT_OBSERVATION_FILTER, isUserFavorite: true }) + const latest: any[] = [] + service.mapObservations$.subscribe(v => { if (v) latest.splice(0, latest.length, ...v) }) + + service.removeObservationFavorite(obs).subscribe() + + expect(latest.find(o => o.id === obs.id)).toBeUndefined() + }) + + it('keeps observation in list when isUserFavorite filter is not active', () => { + filterService.getObservationFilter.and.returnValue(null) + const latest: any[] = [] + service.mapObservations$.subscribe(v => { if (v) latest.splice(0, latest.length, ...v) }) + + service.removeObservationFavorite(obs).subscribe() + + expect(latest.find(o => o.id === obs.id)).toBeDefined() + }) + + it('refreshes the paginated observation list when removed from the filtered map results', () => { + filterService.getObservationFilter.and.returnValue({ ...DEFAULT_OBSERVATION_FILTER, isUserFavorite: true }) + observationService.getPagingOptions.and.returnValue({ page: 0, page_size: 50 }) + observationService.getObservationsPage.calls.reset() + + service.removeObservationFavorite(obs).subscribe() + + expect(observationService.getObservationsPage).toHaveBeenCalled() + }) + + it('does not refresh the paginated observation list when the filter is not active', () => { + filterService.getObservationFilter.and.returnValue(null) + observationService.getPagingOptions.and.returnValue({ page: 0, page_size: 50 }) + observationService.getObservationsPage.calls.reset() + + service.removeObservationFavorite(obs).subscribe() + + expect(observationService.getObservationsPage).not.toHaveBeenCalled() + }) + }) + + describe('clearObservationAsImportant', () => { + let obs: any + + beforeEach(() => { + obs = observation() + initWithEvent() + service.onEventChanged(null, mockEvent as any) + service.parseMapObservations([obs]) + observationService.clearObservationAsImportantForEvent.and.returnValue(of(obs)) + observationService.getObservationsForMap.calls.reset() + }) + + it('removes observation from list when isFlaggedImportant filter is active', () => { + filterService.getObservationFilter.and.returnValue({ ...DEFAULT_OBSERVATION_FILTER, isFlaggedImportant: true }) + const latest: any[] = [] + service.mapObservations$.subscribe(v => { if (v) latest.splice(0, latest.length, ...v) }) + + service.clearObservationAsImportant(obs).subscribe() + + expect(latest.find(o => o.id === obs.id)).toBeUndefined() + }) + + it('keeps observation in list when isFlaggedImportant filter is not active', () => { + filterService.getObservationFilter.and.returnValue(null) + const latest: any[] = [] + service.mapObservations$.subscribe(v => { if (v) latest.splice(0, latest.length, ...v) }) + + service.clearObservationAsImportant(obs).subscribe() + + expect(latest.find(o => o.id === obs.id)).toBeDefined() + }) + + it('refreshes the paginated observation list when removed from the filtered map results', () => { + filterService.getObservationFilter.and.returnValue({ ...DEFAULT_OBSERVATION_FILTER, isFlaggedImportant: true }) + observationService.getPagingOptions.and.returnValue({ page: 0, page_size: 50 }) + observationService.getObservationsPage.calls.reset() + + service.clearObservationAsImportant(obs).subscribe() + + expect(observationService.getObservationsPage).toHaveBeenCalled() + }) + + it('does not refresh the paginated observation list when the filter is not active', () => { + filterService.getObservationFilter.and.returnValue(null) + observationService.getPagingOptions.and.returnValue({ page: 0, page_size: 50 }) + observationService.getObservationsPage.calls.reset() + + service.clearObservationAsImportant(obs).subscribe() + + expect(observationService.getObservationsPage).not.toHaveBeenCalled() + }) + }) + + describe('observation page updates', () => { + let obs: any + let other: any + let page: any + + beforeEach(() => { + obs = observation({ id: 'o1', favoriteUserIds: [] }) + other = observation({ id: 'o2' }) + initWithEvent() + service.onEventChanged(null, mockEvent as any) + filterService.getObservationFilter.and.returnValue(null) + ;(service as any).observationPageSubject.next({ data: [obs, other], totalCount: 2, pageIndex: 0, error: null, userInitiated: false }) + service.observationPage$.subscribe(value => page = value) + }) + + function expectReplaced(updated: any): void { + expect(page.data[0]).toBe(updated) + expect(page.data[1]).toBe(other) + expect(page.totalCount).toBe(2) + } + + it('replaces the observation in the page after it is flagged important', () => { + const updated = { ...obs, important: { description: 'look here' } } + observationService.markObservationAsImportantForEvent.and.returnValue(of(updated)) + + service.markObservationAsImportant(obs, { description: 'look here' }).subscribe() + + expectReplaced(updated) + }) + + it('replaces the observation in the page after the important flag is cleared', () => { + const updated = { ...obs, important: undefined } + observationService.clearObservationAsImportantForEvent.and.returnValue(of(updated)) + + service.clearObservationAsImportant(obs).subscribe() + + expectReplaced(updated) + }) + + it('replaces the observation in the page after it is favorited', () => { + const updated = { ...obs, favoriteUserIds: ['current-user'] } + observationService.addObservationFavorite.and.returnValue(of(updated)) + + service.addObservationFavorite(obs).subscribe() + + expectReplaced(updated) + }) - it('should be created', () => { - expect(service).toBeTruthy(); - }); + it('replaces the observation in the page after it is unfavorited', () => { + const updated = { ...obs, favoriteUserIds: [] } + observationService.removeObservationFavorite.and.returnValue(of(updated)) - describe('event changes', () => { + service.removeObservationFavorite(obs).subscribe() - it('creates an eventsById bucket and fetches layers/feeds/data when an event is selected', () => { - service.init(); + expectReplaced(updated) + }) - filterService.setEvent(event); + it('leaves the page alone when the updated observation is not on it', () => { + const before = page + observationService.markObservationAsImportantForEvent.and.returnValue(of({ ...obs, id: 'elsewhere' })) - expect(service.getEventById(event.id)).toBeTruthy(); - expect(layerService.getLayersForEvent).toHaveBeenCalledWith(event); - expect(feedService.fetchFeeds).toHaveBeenCalledWith(event.id); - expect(locationService.getUserLocationsForEvent).toHaveBeenCalled(); - expect(observationService.getObservationsForEvent).toHaveBeenCalled(); - }); + service.markObservationAsImportant(obs, { description: 'look here' }).subscribe() - it('tears down the old eventsById bucket when the event changes', () => { - service.init(); - filterService.setEvent(event); - expect(service.getEventById(event.id)).toBeTruthy(); + expect(page).toBe(before) + }) + }) - const otherEvent: any = { id: 2, name: 'Event 2', teams: [], forms: [] }; - filterService.setEvent(otherEvent); + describe('deleteAttachmentForObservation', () => { + let obs: any + let attachment: any - expect(service.getEventById(event.id)).toBeUndefined(); - expect(service.getEventById(otherEvent.id)).toBeTruthy(); - }); + beforeEach(() => { + attachment = { id: 'att1', name: 'test.jpg' } + obs = observation({ attachments: [attachment] }) + initWithEvent() + service.onEventChanged(null, mockEvent as any) + service.parseMapObservations([obs]) + }) - it('does not react to a listener once destroy has been called', () => { - service.init(); - service.destroy(); + it('removes observation when hasAttachments filter is active and last attachment is deleted', () => { + filterService.getObservationFilter.and.returnValue({ ...DEFAULT_OBSERVATION_FILTER, hasAttachments: true }) + const latest: any[] = [] + service.mapObservations$.subscribe(v => { if (v) latest.splice(0, latest.length, ...v) }) - filterService.setEvent(event); + service.deleteAttachmentForObservation(obs, attachment) - expect(service.getEventById(event.id)).toBeUndefined(); - }); - }); + expect(latest.find(o => o.id === obs.id)).toBeUndefined() + }) - describe('interval changes', () => { + it('keeps observation when hasAttachments filter is active but more attachments remain', () => { + const second = { id: 'att2', name: 'other.jpg' } + obs.attachments = [attachment, second] + filterService.getObservationFilter.and.returnValue({ ...DEFAULT_OBSERVATION_FILTER, hasAttachments: true }) + const latest: any[] = [] + service.mapObservations$.subscribe(v => { if (v) latest.splice(0, latest.length, ...v) }) - it('re-fetches when the time interval changes for an active event', () => { - service.init(); - filterService.setEvent(event); + service.deleteAttachmentForObservation(obs, attachment) - locationService.getUserLocationsForEvent.calls.reset(); - observationService.getObservationsForEvent.calls.reset(); + expect(latest.find(o => o.id === obs.id)).toBeDefined() + }) - filterService.setTimeInterval({ choice: { filter: 86400, label: 'Last 24 Hours' } }); + it('keeps observation when hasAttachments filter is not active', () => { + filterService.getObservationFilter.and.returnValue(null) + const latest: any[] = [] + service.mapObservations$.subscribe(v => { if (v) latest.splice(0, latest.length, ...v) }) - expect(locationService.getUserLocationsForEvent).toHaveBeenCalled(); - expect(observationService.getObservationsForEvent).toHaveBeenCalled(); - }); - }); + service.deleteAttachmentForObservation(obs, attachment) - describe('team/user/form filter changes', () => { + expect(latest.find(o => o.id === obs.id)).toBeDefined() + }) - it('re-evaluates the current event observations/users when teams change', () => { - service.init(); - filterService.setEvent(event); + it('refreshes the paginated observation list when the last attachment is removed under an active filter', () => { + filterService.getObservationFilter.and.returnValue({ ...DEFAULT_OBSERVATION_FILTER, hasAttachments: true }) + observationService.getPagingOptions.and.returnValue({ page: 0, page_size: 50 }) + observationService.getObservationsPage.calls.reset() - const listener = jasmine.createSpyObj('listener', ['onObservationsChanged']); - service.addObservationsChangedListener(listener); - listener.onObservationsChanged.calls.reset(); + service.deleteAttachmentForObservation(obs, attachment) - filterService.setTeams([{ id: '5', name: 'Team 5', userIds: [], acl: {} }]); + expect(observationService.getObservationsPage).toHaveBeenCalled() + }) - expect(listener.onObservationsChanged).toHaveBeenCalled(); - }); + it('does not refresh the paginated observation list when the filter is not active', () => { + filterService.getObservationFilter.and.returnValue(null) + observationService.getPagingOptions.and.returnValue({ page: 0, page_size: 50 }) + observationService.getObservationsPage.calls.reset() - it('re-evaluates on users and forms changes too', () => { - service.init(); - filterService.setEvent(event); + service.deleteAttachmentForObservation(obs, attachment) - const listener = jasmine.createSpyObj('listener', ['onObservationsChanged']); - service.addObservationsChangedListener(listener); + expect(observationService.getObservationsPage).not.toHaveBeenCalled() + }) + }) - listener.onObservationsChanged.calls.reset(); - filterService.setUsers([{ id: 'u1' } as any]); - expect(listener.onObservationsChanged).toHaveBeenCalledTimes(1); + describe('saveObservation', () => { + let obs: any - listener.onObservationsChanged.calls.reset(); - filterService.setForms([{ id: 1 } as any]); - expect(listener.onObservationsChanged).toHaveBeenCalledTimes(1); - }); - }); + beforeEach(() => { + obs = observation() + initWithEvent() + service.onEventChanged(null, mockEvent as any) + service.parseMapObservations([obs]) + observationService.saveObservationForEvent.and.returnValue(of(obs)) + observationService.getObservationsForMap.calls.reset() + }) - describe('action filter changes', () => { + it('removes observation from local list immediately after save', () => { + const latest: any[] = [] + service.mapObservations$.subscribe(v => { if (v) latest.splice(0, latest.length, ...v) }) - it('re-evaluates observations against the action filter', () => { - service.init(); - filterService.setEvent(event); + service.saveObservation(obs).subscribe() - const listener = jasmine.createSpyObj('listener', ['onObservationsChanged']); - service.addObservationsChangedListener(listener); - listener.onObservationsChanged.calls.reset(); + expect(latest.find(o => o.id === obs.id)).toBeUndefined() + }) - filterService.setFilter({ actionFilter: 'important' }); + it('triggers a re-fetch after save', () => { + service.saveObservation(obs).subscribe() - expect(listener.onObservationsChanged).toHaveBeenCalled(); - }); - }); -}); + expect(observationService.getObservationsForMap).toHaveBeenCalled() + }) + }) +}) diff --git a/web-app/src/app/event/event.service.ts b/web-app/src/app/event/event.service.ts index 5bd0175ef..a39cbf302 100644 --- a/web-app/src/app/event/event.service.ts +++ b/web-app/src/app/event/event.service.ts @@ -2,109 +2,206 @@ import { Injectable } from "@angular/core"; import { Observable, Subject, + BehaviorSubject, catchError, - combineLatest, - finalize, map, - merge, of, pairwise, - skip, startWith, + Subscription, + switchMap, take, takeUntil, tap, } from "rxjs"; import { FilterService } from "../filter/filter.service"; import { PollingService } from "./polling.service"; -import { ObservationService } from "../observation/observation.service"; +import { ObservationService, ObservationsPageRequestOptions, ObservationsRequestOptions } from "../observation/observation.service"; import { HttpClient, HttpParams } from "@angular/common/http"; import { LayerService } from "../layer/layer.service"; -import { LocationService } from "../user/location/location.service"; +import { LocationService, LocationsRequestOptions } from "../user/location/location.service"; import { SessionService } from "../http/session.service"; import * as _ from "lodash"; import moment from 'moment'; -import { FeedService } from "@ngageoint/mage.web-core-lib/feed"; +import { Feed, FeedService } from "@ngageoint/mage.web-core-lib/feed"; import { User } from "@ngageoint/mage.web-core-lib/user"; -import { MemberPage, filterChanges } from "./event.types"; -import { Filter } from "../filter/filter.types"; -import { MageEvent, Form, FormField } from "../entities/event/entities.event"; +import { MemberPage } from "./event.types"; +import { MageEvent, Form, FormField, EventId } from "../entities/event/entities.event"; import { Layer } from "../entities/layer/entities.layer"; import { Team } from "../entities/team/entities.team"; -import { Attachment, Observation } from "../entities/observation/entities.observation"; +import { Attachment, Observation, ObservationStateName } from "../entities/observation/entities.observation"; +import { EventObservationFilter } from "../filter/filter.types"; + +export interface FetchResult { + data: T[] + error: any + userInitiated: boolean +} + +export interface PagedFetchResult extends FetchResult { + totalCount: number + pageIndex: number +} + +interface CachedEvent extends MageEvent { + layersById?: Record + feedsById?: Record +} + +interface FeedSyncState { + id: string + lastSync: number +} + +interface LocationsCache { + event?: MageEvent | null + byId: any +} @Injectable({ providedIn: "root", }) export class EventService { - private observationsChangedListeners: any = []; - private usersChangedListeners: any = []; + private destroy$ = new Subject(); + + private mapObservationsSubject = new BehaviorSubject(null); + readonly mapObservations$: Observable = this.mapObservationsSubject.asObservable(); + + private observationPageSubject = new BehaviorSubject | null>(null); + readonly observationPage$: Observable | null> = this.observationPageSubject.asObservable(); + + private locationsSubject = new BehaviorSubject | null>(null); + readonly locations$: Observable | null> = this.locationsSubject.asObservable(); + + private mapObservations: { + event: MageEvent | null, + lastModified?: string, + byId: Record + } = { event: null, byId: {} }; + + private locations: LocationsCache | null = null; + + private observationsTrigger$ = new Subject<{ userInitiated: boolean }>(); + private observationPageTrigger$ = new Subject<{ userInitiated: boolean }>(); + private locationsTrigger$ = new Subject<{ userInitiated: boolean }>(); + private pollTrigger$ = new Subject(); + private layersChangedListeners: any = []; private feedItemsChangedListeners: any = []; - private pollListeners: any = []; - private eventsById: any = {}; + private eventsById: Record = {}; private pollingTimeout: any = null; + private currentPollingInterval = 0; private feedPollTimeout: any = null; - private feedSyncStates: any = {}; - private destroy$ = new Subject(); + private feedSyncStates: FeedSyncState[] = []; + private feedItemsSubscription: Subscription | null = null; constructor( private pollingService: PollingService, private httpClient: HttpClient, + private sessionService: SessionService, private feedService: FeedService, private layerService: LayerService, private filterService: FilterService, private locationService: LocationService, - private observationService: ObservationService, - private sessionService: SessionService + private observationService: ObservationService ) { } init() { this.destroy$ = new Subject(); - this.pollingService.addListener(this); - - this.filterService.event$ - .pipe(startWith(null), pairwise(), takeUntil(this.destroy$)) - .subscribe(([prev, curr]) => { - if (prev?.id !== curr?.id) { - this.onEventChanged({ - added: curr ? [curr] : [], - removed: prev ? [prev] : [], - }); - if (curr) { - this.fetch().subscribe(); - } + + this.filterService.event$.pipe( + startWith(null), + pairwise(), + takeUntil(this.destroy$) + ).subscribe(([prev, curr]) => { + if (curr?.id !== prev?.id) { + this.onEventChanged(prev, curr); + } + }); + + this.observationsTrigger$.pipe( + tap((trigger) => { + if (trigger.userInitiated) { + const event = this.filterService.getEvent(); + this.mapObservations = { event, byId: {} }; + this.mapObservationsSubject.next([]); } - }); + }), + switchMap(() => this.fetchMapObservations()), + takeUntil(this.destroy$) + ).subscribe(() => this.onFetchSettled()); + + this.observationPageTrigger$.pipe( + switchMap((trigger) => this.fetchObservationPage(trigger.userInitiated)), + takeUntil(this.destroy$) + ).subscribe(() => this.onFetchSettled()); + + this.locationsTrigger$.pipe( + tap((trigger) => { + if (trigger.userInitiated) { + const event = this.filterService.getEvent(); + this.locations = { event, byId: {} }; + this.locationsSubject.next({ data: [], error: null, userInitiated: true }); + } + }), + switchMap((trigger) => this.fetchLocations(trigger.userInitiated)), + takeUntil(this.destroy$) + ).subscribe(() => this.onFetchSettled()); + + this.filterService.observationFilter$.pipe( + takeUntil(this.destroy$) + ).subscribe(() => { + if (!this.filterService.getEvent()) return; + this.observationsTrigger$.next({ userInitiated: true }); + }); - this.filterService.interval$ - .pipe(skip(1), takeUntil(this.destroy$)) - .subscribe(() => { - this.fetch().subscribe(); - }); + this.filterService.locationFilter$.pipe( + takeUntil(this.destroy$) + ).subscribe(() => { + if (!this.filterService.getEvent()) return; + this.locationsTrigger$.next({ userInitiated: true }); + }); - merge( - this.filterService.teams$.pipe(skip(1)), - this.filterService.users$.pipe(skip(1)), - this.filterService.forms$.pipe(skip(1)) - ) - .pipe(takeUntil(this.destroy$)) - .subscribe(() => { - this.onFiltersChanged(); - }); + this.pollTrigger$.pipe( + takeUntil(this.destroy$) + ).subscribe(() => { + this.observationsTrigger$.next({ userInitiated: false }); + this.locationsTrigger$.next({ userInitiated: false }); + this.observationPageTrigger$.next({ userInitiated: false }); + }); - this.filterService.actionFilter$ - .pipe(skip(1), takeUntil(this.destroy$)) - .subscribe(() => { - this.onActionFilterChanged(); - }); + this.pollingService.pollingInterval$.pipe( + takeUntil(this.destroy$) + ).subscribe(interval => { + this.currentPollingInterval = interval; + this.schedulePollTick(); + }); + + this.observationService.paging$.pipe( + takeUntil(this.destroy$) + ).subscribe((paging) => { + if (!paging) return; + this.observationPageTrigger$.next({ userInitiated: false }); + }); + } + + private onFetchSettled(): void { + this.schedulePollTick(); + } + + private schedulePollTick(): void { + if (this.pollingTimeout) clearTimeout(this.pollingTimeout); + if (this.currentPollingInterval > 0) { + this.pollingTimeout = setTimeout(() => { + this.pollTrigger$.next(); + }, this.currentPollingInterval); + } } destroy() { this.eventsById = {}; this.destroy$.next(); this.destroy$.complete(); - this.pollingService.removeListener(this); if (this.pollingTimeout) { clearTimeout(this.pollingTimeout); @@ -113,6 +210,16 @@ export class EventService { if (this.feedPollTimeout) { clearTimeout(this.feedPollTimeout); } + + this.feedItemsSubscription?.unsubscribe(); + this.feedItemsSubscription = null; + this.feedSyncStates = []; + + this.mapObservations = { event: null, byId: {} }; + this.locations = null; + this.mapObservationsSubject.next(null); + this.observationPageSubject.next(null); + this.locationsSubject.next(null); } query(options?: any): Observable { @@ -144,365 +251,170 @@ export class EventService { ); } - onEventChanged(event: filterChanges) { - const { added = [], removed = [] } = event; - added.forEach((added: any) => { - if (!this.eventsById[added.id]) { - this.eventsById[added.id] = JSON.parse(JSON.stringify(added)); - - this.eventsById[added.id].filteredObservationsById = {}; - this.eventsById[added.id].observationsById = {}; - this.eventsById[added.id].usersById = {}; - this.eventsById[added.id].filteredUsersById = {}; - } - - this.fetchLayers(added); - this.fetchFeeds(added); - }); - - removed.forEach((removed: any) => { - this.observationsChanged({ - removed: Object.values( - this.eventsById[removed.id]?.filteredObservationsById || {} - ), - }); - this.usersChanged({ - removed: Object.values( - this.eventsById[removed.id]?.filteredUsersById || {} - ), - }); - this.layersChanged( - { - removed: Object.values(this.eventsById[removed.id]?.layersById || {}), - }, - removed - ); - this.feedItemsChanged( - { - removed: Object.values( - this.eventsById[removed.id]?.feedsById || {} - ).map((feed: any) => ({ feed })), - }, - removed - ); - delete this.eventsById[removed.id]; - }); - } - - /** - * Updates List of Observations and Users when the team/user/form filter changes - * @return {void} No Return - */ - - onFiltersChanged(): void { - const event = this.filterService.getEvent(); - if (!event) return; - - const teamsEvent = this.eventsById[event.id]; - if (!teamsEvent) return; - - // remove observations that are not made by filtered users - const observationsRemoved = []; - Object.values(teamsEvent.filteredObservationsById).forEach( - (observation: Observation) => { - if ( - !this.filterService.isUserInList(observation.userId) || - !this.filterService.isUserInTeamFilter(observation.userId) || - !this.filterService.hasFormInList(observation.properties.forms) - ) { - delete teamsEvent.filteredObservationsById[observation.id]; - observationsRemoved.push(observation); - } - } - ); - - // remove users that are not part of filtered teams - const usersRemoved = []; - Object.values(teamsEvent.filteredUsersById).forEach((user: User) => { - if ( - !this.filterService.isUserInList(user.id) || - !this.filterService.isUserInTeamFilter(user.id) - ) { - delete teamsEvent.filteredUsersById[user.id]; - usersRemoved.push(user); - } - }); - - // add any observations that are part of the filtered teams - const observationsAdded = []; - Object.values(teamsEvent.observationsById).forEach( - (observation: Observation) => { - if ( - this.filterService.isUserInList(observation.userId) && - this.filterService.isUserInTeamFilter(observation.userId) && - this.filterService.hasFormInList(observation.properties.forms) && - !teamsEvent.filteredObservationsById[observation.id] - ) { - observationsAdded.push(observation); - teamsEvent.filteredObservationsById[observation.id] = observation; - } - } - ); - - // add any users that are part of the filtered teams - const usersAdded = []; - Object.values(teamsEvent.usersById).forEach((user: User) => { - if ( - this.filterService.isUserInList(user.id) && - this.filterService.isUserInTeamFilter(user.id) && - !teamsEvent.filteredUsersById[user.id] - ) { - usersAdded.push(user); - teamsEvent.filteredUsersById[user.id] = user; - } - }); - - this.observationsChanged({ - added: observationsAdded, - removed: observationsRemoved, - }); - this.usersChanged({ added: usersAdded, removed: usersRemoved }); - } - - onActionFilterChanged() { - const event = this.filterService.getEvent(); - if (!event) return; - - const actionEvent = this.eventsById[event.id]; - - const observationsRemoved = []; - Object.values(actionEvent.filteredObservationsById).forEach( - (observation: Observation) => { - if (!this.filterService.observationInFilter(observation)) { - delete actionEvent.filteredObservationsById[observation.id]; - observationsRemoved.push(observation); - } - } - ); - - const observationsAdded = []; - // add any observations that are part of the filtered actions - Object.values(actionEvent.observationsById).forEach( - (observation: Observation) => { - if ( - !actionEvent.filteredObservationsById[observation.id] && - this.filterService.observationInFilter(observation) - ) { - observationsAdded.push(observation); - actionEvent.filteredObservationsById[observation.id] = observation; - } - } - ); - - this.observationsChanged({ - added: observationsAdded, - removed: observationsRemoved, - }); - } - - onPollingIntervalChanged(interval: any) { - if (this.pollingTimeout) { - // cancel previous poll - clearTimeout(this.pollingTimeout); + onEventChanged(prevEvent: MageEvent | null, currEvent: MageEvent | null) { + if (currEvent) { + this.eventsById[currEvent.id] = JSON.parse(JSON.stringify(currEvent)); + this.fetchLayers(currEvent); + this.fetchFeeds(currEvent); } - this.pollingTimeout = setTimeout(() => { - this.poll(interval); - }, interval); - } - - addObservationsChangedListener(listener: any) { - this.observationsChangedListeners.push(listener); - - if (typeof listener.onObservationsChanged === "function") { - Object.values(this.eventsById).forEach((event: any) => { - listener.onObservationsChanged({ - added: Object.values(event.observationsById), - }); - }); - } - } - - removeObservationsChangedListener(listener) { - this.observationsChangedListeners = - this.observationsChangedListeners.filter((l: any) => { - return listener !== l; - }); - } - - addUsersChangedListener(listener) { - this.usersChangedListeners.push(listener); - - if (typeof listener.onUsersChanged === "function") { - Object.values(this.eventsById).forEach((event: any) => { - listener.onUsersChanged({ added: Object.values(event.usersById) }); - }); + if (prevEvent) { + this.layersChanged({ removed: Object.values(this.eventsById[prevEvent.id]?.layersById || {}).map(layer => layer.id) }, prevEvent); + this.feedItemsChanged({ removed: Object.values(this.eventsById[prevEvent.id]?.feedsById || {}).map((feed: any) => ({ feed })) }, prevEvent); + delete this.eventsById[prevEvent.id]; } } - removeUsersChangedListener(listener) { - this.usersChangedListeners = this.usersChangedListeners.filter((l: any) => { - return listener !== l; - }); - } - addLayersChangedListener(listener) { this.layersChangedListeners.push(listener); - if (typeof listener.onLayersChanged === "function") { + if (typeof listener.onLayersChanged === 'function') { Object.values(this.eventsById).forEach((event: any) => { - listener.onLayersChanged( - { added: Object.values(event.layersById) }, - event - ); // TODO this could be old layers, admin panel might have changed layers + listener.onLayersChanged({ added: Object.values(event.layersById) }, event); }); } } addFeedItemsChangedListener(listener) { this.feedItemsChangedListeners.push(listener); - - if (typeof listener.onFeedItemsChanged === "function") { - Object.values(this.eventsById).forEach((event: any) => { - // TODO what do I send here? - // listener.onFeedItemsChanged({ added: _.values(event.feedsById) }, event); - }); - } } - addPollListener(listener) { - this.pollListeners.push(listener); + removeLayersChangedListener(listener) { + this.layersChangedListeners = this.layersChangedListeners.filter((l: any) => { + return listener !== l; + }); } - removePollListener(listener) { - this.pollListeners = this.pollListeners.filter((l: any) => { + removeFeedItemsChangedListener(listener) { + this.feedItemsChangedListeners = this.feedItemsChangedListeners.filter((l: any) => { return listener !== l; }); } - removeLayersChangedListener(listener) { - this.layersChangedListeners = this.layersChangedListeners.filter( - (l: any) => { - return listener !== l; - } - ); + getEventById(eventId: EventId): MageEvent | undefined { + return this.eventsById[eventId]; } - removeFeedItemsChangedListener(listener) { - this.feedItemsChangedListeners = this.feedItemsChangedListeners.filter( - (l: any) => { - return listener !== l; - } - ); + retrySearch(): void { + if (!this.filterService.getEvent()) return; + this.observationsTrigger$.next({ userInitiated: true }); + this.observationPageTrigger$.next({ userInitiated: true }); } - getEventById(eventId) { - return this.eventsById[eventId]; + retryLocations(): void { + if (!this.filterService.getEvent()) return; + this.locationsTrigger$.next({ userInitiated: true }); } saveObservation(observation: Observation) { const event = this.eventsById[observation.eventId]; - const isNewObservation = !observation.id; - - return this.observationService - .saveObservationForEvent(event, observation) + return this.observationService.saveObservationForEvent(event, observation) .pipe( - tap((update: Observation) => { - event.observationsById[update.id] = update; - - // Check if this new observation passes the current filter - if (this.filterService.observationInFilter(update)) { - event.filteredObservationsById[update.id] = update; - isNewObservation - ? this.observationsChanged({ added: [update] }) - : this.observationsChanged({ updated: [update] }); - } + tap(() => { + delete this.mapObservations.byId[observation.id]; + this.mapObservationsChanged(); + this.observationsTrigger$.next({ userInitiated: false }); + this.observationPageTrigger$.next({ userInitiated: true }); }) ); } - addObservationFavorite(observation) { - let event = this.eventsById[observation.eventId]; - return this.observationService - .addObservationFavorite(event, observation) + private updateObservationInPage(update: Observation): void { + const page = this.observationPageSubject.getValue(); + if (page?.data.some(observation => observation.id === update.id)) { + this.observationPageSubject.next({ + ...page, + data: page.data.map(observation => observation.id === update.id ? update : observation) + }); + } + } + + addObservationFavorite(observation: Observation) { + const event = this.eventsById[observation.eventId]; + return this.observationService.addObservationFavorite(event, observation) .pipe( tap((update: Observation) => { - event.observationsById[update.id] = update; - this.observationsChanged({ updated: [update] }); + this.mapObservations.byId[update.id] = update; + this.mapObservationsChanged(); + this.updateObservationInPage(update); }) ); } - removeObservationFavorite(observation) { - let event = this.eventsById[observation.eventId]; - return this.observationService - .removeObservationFavorite(event, observation) + removeObservationFavorite(observation: Observation) { + const event = this.eventsById[observation.eventId]; + return this.observationService.removeObservationFavorite(event, observation) .pipe( tap((update: Observation) => { - event.observationsById[update.id] = update; - this.observationsChanged({ updated: [update] }); + if (this.filterService.getObservationFilter()?.isUserFavorite) { + delete this.mapObservations.byId[update.id]; + this.observationPageTrigger$.next({ userInitiated: true }); + } else { + this.mapObservations.byId[update.id] = update; + this.updateObservationInPage(update); + } + this.mapObservationsChanged(); }) ); } - markObservationAsImportant( - observation: Observation, - important - ): Observable { - let event = this.eventsById[observation.eventId]; - return this.observationService - .markObservationAsImportantForEvent(event, observation, important) + markObservationAsImportant(observation: Observation, important: Pick, 'description'>): Observable { + const event = this.eventsById[observation.eventId]; + return this.observationService.markObservationAsImportantForEvent(event, observation, important) .pipe( tap((update: Observation) => { - event.observationsById[update.id] = update; - this.observationsChanged({ updated: [update] }); + this.mapObservations.byId[update.id] = update; + this.mapObservationsChanged(); + this.updateObservationInPage(update); }) ); } - clearObservationAsImportant( - observation: Observation - ): Observable { - let event = this.eventsById[observation.eventId]; - return this.observationService - .clearObservationAsImportantForEvent(event, observation) + clearObservationAsImportant(observation: Observation): Observable { + const event = this.eventsById[observation.eventId]; + return this.observationService.clearObservationAsImportantForEvent(event, observation) .pipe( tap((update: Observation) => { - event.observationsById[update.id] = update; - this.observationsChanged({ updated: [update] }); + if (this.filterService.getObservationFilter()?.isFlaggedImportant) { + delete this.mapObservations.byId[update.id]; + this.observationPageTrigger$.next({ userInitiated: true }); + } else { + this.mapObservations.byId[update.id] = update; + this.updateObservationInPage(update); + } + this.mapObservationsChanged(); }) ); } - archiveObservation(observation): Observable { - let event = this.eventsById[observation.eventId]; - return this.observationService - .archiveObservationForEvent(event, observation) + archiveObservation(observation: Observation): Observable { + const event = this.eventsById[observation.eventId]; + return this.observationService.archiveObservationForEvent(event, observation) .pipe( tap((archived: Observation) => { - delete event.observationsById[archived.id]; - this.observationsChanged({ removed: [archived] }); + delete this.mapObservations.byId[archived.id]; + this.mapObservationsChanged(); + this.observationPageTrigger$.next({ userInitiated: true }); }) ); } addAttachmentToObservation(observation: Observation, attachment: Attachment) { const event = this.eventsById[observation.eventId]; - this.observationService.addAttachmentToObservationForEvent( - event, - observation, - attachment - ); - this.observationsChanged({ updated: [observation] }); + this.observationService.addAttachmentToObservationForEvent(event, observation, attachment); + this.mapObservationsChanged(); } - deleteAttachmentForObservation(observation, attachment) { + deleteAttachmentForObservation(observation: Observation, attachment: Attachment) { const event = this.eventsById[observation.eventId]; - return this.observationService - .deleteAttachmentInObservationForEvent(event, observation, attachment) - .subscribe((observation: Observation) => { - this.observationsChanged({ updated: [observation] }); - }); + return this.observationService.deleteAttachmentInObservationForEvent(event, observation, attachment).subscribe(() => { + const remainingAttachments = observation.attachments.filter(a => a !== attachment); + if (this.filterService.getObservationFilter()?.hasAttachments && remainingAttachments.length === 0) { + delete this.mapObservations.byId[observation.id]; + this.observationPageTrigger$.next({ userInitiated: true }); + } + this.mapObservationsChanged(); + }); } getFormField(form: Form, fieldName: string) { @@ -510,7 +422,7 @@ export class EventService { } getForms(observation: Observation, options?: any) { - let event = this.eventsById[observation.eventId]; + const event = this.eventsById[observation.eventId]; return this.getFormsForEvent(event, options); } @@ -524,7 +436,7 @@ export class EventService { return forms; } - createForm(observationForm: any, formDefinition: any, viewModel?: any) { + createForm(observationForm: Record, formDefinition: any, viewModel?: any) { const form = JSON.parse(JSON.stringify(formDefinition)); form.remoteId = observationForm.id; @@ -553,59 +465,45 @@ export class EventService { return form; } - exportForm(event): Observable
{ + exportForm(event: MageEvent): Observable { return this.httpClient.get(`/api/event/${event.id}/form.zip`); } - getMembers(event): Observable { + getMembers(eventId: EventId): Observable { return this.httpClient - .get(`/api/events/${event.id}/members?page_size=${Number.MAX_SAFE_INTEGER}`) + .get(`/api/events/${eventId}/members?page_size=${Number.MAX_SAFE_INTEGER}`) .pipe( take(1), map(res => res.items) ); } - searchMembers(event, term: string, pageSize = 20): Observable { + searchMembers(eventId: EventId, term: string, pageSize = 20): Observable { const params = new HttpParams() .set('term', term || '') .set('page_size', String(pageSize)); return this.httpClient - .get(`/api/events/${event.id}/members`, { params }) + .get(`/api/events/${eventId}/members`, { params }) .pipe( take(1), map(res => res.items) ); } - - isUserInEvent(user, event): boolean { - if (!event) return false; - return event.teams.some((team: Team) => team.userIds.includes(user.id)); + isUserInEvent(user: User, mageEvent: MageEvent | null): boolean { + return mageEvent?.teams?.some((team: Team) => team.userIds.includes(user.id)) ?? false; } - usersChanged(changed) { - this.usersChangedListeners.forEach((listener: any) => { - changed.added = changed.added || []; - changed.updated = changed.updated || []; - changed.removed = changed.removed || []; - - if (typeof listener.onUsersChanged === "function") { - listener.onUsersChanged(changed); - } + usersChanged(userInitiated = false) { + this.locationsSubject.next({ + data: Object.values(this.locations?.byId ?? {}), + error: null, + userInitiated }); } - observationsChanged(changed: filterChanges) { - this.observationsChangedListeners.forEach((listener: any) => { - changed.added = changed.added || []; - changed.updated = changed.updated || []; - changed.removed = changed.removed || []; - - if (typeof listener.onObservationsChanged === "function") { - listener.onObservationsChanged(changed); - } - }); + mapObservationsChanged() { + this.mapObservationsSubject.next(Object.values(this.mapObservations?.byId ?? {})); } layersChanged(changed, event) { @@ -632,73 +530,155 @@ export class EventService { }); } - fetch(): Observable { - const event = this.filterService.getEvent(); - if (!event) { - return of(); - } + private buildObservationFilterParams(observationFilter: EventObservationFilter | null): Partial { + const params: Partial = {}; + + const { start, end } = this.filterService.getEffectiveTimeInterval(observationFilter?.timeInterval); + if (start) params.observationStartDate = start.toISOString(); + if (end) params.observationEndDate = end.toISOString(); + if (observationFilter?.hasAttachments) params.hasAttachments = true; + if (observationFilter?.isFlaggedImportant) params.important = true; + if (observationFilter?.isUserFavorite) params.favoritedBy = this.sessionService.user.id; + if (observationFilter?.fieldFilter) params.filter = observationFilter.fieldFilter; + if (observationFilter?.memberFilter?.teamIds?.length) params.teams = observationFilter.memberFilter.teamIds; + if (observationFilter?.memberFilter?.userIds?.length) params.users = observationFilter.memberFilter.userIds; + + return params; + } + + fetchMapObservations(): Observable { + const event = this.mapObservations.event; + if (!event) return of(null); - const parameters: any = {}; - const interval = this.filterService.getInterval(); - if (interval) { - const time = this.filterService.formatInterval(interval); - parameters.interval = time; + const lastModified = this.mapObservations.lastModified; + const observationFilter = this.filterService.getObservationFilter(); + + const params: ObservationsRequestOptions = { + ...this.buildObservationFilterParams(observationFilter) + }; + + if (lastModified) { + params.startDate = lastModified; + } else { + params.states = ObservationStateName.Active; } - return combineLatest([ - this.locationService - .getUserLocationsForEvent(event, parameters) - .pipe(map((locations: any) => this.parseLocations(event, locations))), - this.observationService - .getObservationsForEvent(event, parameters) - .pipe( - map((observations: Observation[]) => - this.parseObservations(event, observations) - ) - ), - ]); - } - - fetchLayers(event) { - return this.layerService - .getLayersForEvent(event) - .subscribe((layers: Layer[]) => { - const added = layers.filter((l) => { - return !Object.keys(this.eventsById[event.id].layersById || {}).some( - (layerId: any) => l.id === layerId - ); - }); - - const removed = Object.keys( - this.eventsById[event.id].layersById || {} - ).filter((layerId: any) => { - return !layers.some((l: Layer) => l.id === layerId); - }); - - this.eventsById[event.id].layersById = _.keyBy(layers, "id"); - this.layersChanged({ added: added, removed: removed }, event); - }); + return this.observationService + .getObservationsForMap(event, params) + .pipe( + map((observations: Observation[]) => this.parseMapObservations(observations, lastModified)), + catchError((err) => { + console.error(`error fetching map observations for event ${event.id}`, err); + return of(null); + }) + ); } - fetchFeeds(event) { - this.feedService.fetchFeeds(event.id).subscribe((feeds) => { - this.feedItemsChanged( - { - added: feeds.map((feed) => { - return { - feed, - items: [], - }; - }), - }, - event + fetchObservationPage(userInitiated = false): Observable { + const event = this.filterService.getEvent(); + if (!event) return of(null); + + const pagingOptions = this.observationService.getPagingOptions(); + if (!pagingOptions) return of(null); + + const observationFilter = this.filterService.getObservationFilter(); + + const params: ObservationsPageRequestOptions = { + populate: true, + states: ObservationStateName.Active, + include_total_count: true, + page: pagingOptions.page, + page_size: pagingOptions.page_size, + sort: 'timestamp+desc', + ...this.buildObservationFilterParams(observationFilter) + }; + + return this.observationService + .getObservationsPage(event, params) + .pipe( + tap((page: any) => this.observationPageSubject.next({ + data: page.items ?? [], + totalCount: page.totalCount ?? 0, + pageIndex: pagingOptions.page, + error: null, + userInitiated + })), + catchError((err) => { + console.error(`error fetching observation page for event ${event.id}`, err); + const prev = this.observationPageSubject.getValue(); + this.observationPageSubject.next({ + data: prev?.data ?? [], + totalCount: prev?.totalCount ?? 0, + pageIndex: prev?.pageIndex ?? pagingOptions.page, + error: err, + userInitiated + }); + return of(null); + }) + ); + } + + fetchLocations(userInitiated = false): Observable { + const event = this.locations?.event; + if (!event) return of(null); + + const locationFilter = this.filterService.getLocationFilter(); + const params: LocationsRequestOptions = { populate: true }; + + const { start, end } = this.filterService.getEffectiveTimeInterval(locationFilter?.timeInterval); + if (start) params.startDate = start.toISOString(); + if (end) params.endDate = end.toISOString(); + + if (locationFilter?.memberFilter?.teamIds?.length) params.teams = locationFilter.memberFilter.teamIds; + if (locationFilter?.memberFilter?.userIds?.length) params.users = locationFilter.memberFilter.userIds; + + return this.locationService + .getUserLocationsForEvent(event, params) + .pipe( + map((userLocations: any) => this.parseLocations(userLocations, userInitiated)), + catchError((err) => { + console.error(`error fetching locations for event ${event.id}`, err); + this.locationsSubject.next({ + data: Object.values(this.locations?.byId ?? {}), + error: err, + userInitiated + }); + return of(null); + }) ); + } - this.eventsById[event.id].feedsById = _.keyBy(feeds, "id"); - this.feedSyncStates = feeds.map((feed) => { + fetchLayers(event: MageEvent) { + return this.layerService.getLayersForEvent(event).pipe(takeUntil(this.destroy$)).subscribe((layers: Layer[]) => { + const added = layers.filter((l) => { + return !Object.keys(this.eventsById[event.id].layersById || {}).some((layerId: any) => l.id === layerId); + }); + + const removed = Object.keys(this.eventsById[event.id].layersById || {}).filter((layerId: any) => { + return !layers.some((l: Layer) => l.id === layerId); + }); + + this.eventsById[event.id].layersById = _.keyBy(layers, 'id'); + this.layersChanged({ added: added, removed: removed }, event); + }); + } + + fetchFeeds(event: MageEvent) { + this.feedService.fetchFeeds(event.id).pipe(takeUntil(this.destroy$)).subscribe(feeds => { + this.feedItemsChanged({ + added: feeds.map(feed => { + return { + feed, + items: [] as GeoJSON.Feature[] + }; + }) + }, event); + + this.eventsById[event.id].feedsById = _.keyBy(feeds, 'id'); + this.feedSyncStates = feeds.map(feed => { return { id: feed.id, - lastSync: 0, + lastSync: 0 }; }); @@ -706,166 +686,117 @@ export class EventService { }); } - parseObservations(event: MageEvent, observations: Observation[]): void { - const added = []; - const updated = []; - const removed = []; + parseMapObservations(observations: Observation[], lastModified: string | undefined = undefined): void { + const added: Observation[] = []; + const updated: Observation[] = []; + const removed: Observation[] = []; - const observationsById = {}; - let filteredObservationsById = - this.eventsById[event.id].filteredObservationsById; + const trackLastModified = (observation: Observation) => { + const observationLastModified = observation.lastModified; + if (observationLastModified && (!lastModified || observationLastModified > lastModified)) { + lastModified = observationLastModified; + } + }; + + const observationsById = this.mapObservations.byId; observations.forEach((observation: Observation) => { - // Check if this observation passes the current filter - if (this.filterService.observationInFilter(observation)) { - // Check if we already have this observation, if so update, otherwise add - let localObservation = filteredObservationsById[observation.id]; - if (localObservation) { - if (localObservation.lastModified !== observation.lastModified) { - updated.push(observation); - } else if (observation.attachments) { - let some = _.some(observation.attachments, function (attachment) { - let localAttachment = _.find( - localObservation.attachments, - function (a) { - return a.id === attachment.id; - } - ); - return ( - !localAttachment || - localAttachment.lastModified !== attachment.lastModified - ); - }); - - if (some) updated.push(observation); - } - } else { + trackLastModified(observation); + if (observation.state?.name === ObservationStateName.Archived) { + if (observationsById[observation.id]) { + removed.push(observation); + delete observationsById[observation.id]; + } + } else { + const local = observationsById[observation.id]; + if (!local) { added.push(observation); + observationsById[observation.id] = observation; + } else if (local.lastModified !== observation.lastModified) { + updated.push(observation); + observationsById[observation.id] = observation; } - - // remove from list of observations if it came back from server - // remaining elements in this list will be removed - delete filteredObservationsById[observation.id]; - - observationsById[observation.id] = observation; } }); - // remaining elements were not pulled from the server, hence we should remove them - removed.push(...Object.values(filteredObservationsById)); - - this.eventsById[event.id].observationsById = _.keyBy(observations, "id"); - this.eventsById[event.id].filteredObservationsById = observationsById; + this.mapObservations.lastModified = lastModified; - this.observationsChanged({ - added: added, - updated: updated, - removed: removed, - }); + if (added.length || updated.length || removed.length || !lastModified) { + this.mapObservationsChanged(); + } } - parseLocations(event: MageEvent, userLocations: any): void { + parseLocations(userLocations: any, userInitiated = false): void { + if (!this.locations) return; + const added = []; const updated = []; - const usersById = {}; - const filteredUsersById = this.eventsById[event.id].filteredUsersById; + const existingUsersById = { ...this.locations.byId }; + const usersById: Record = {}; userLocations.forEach((userLocation: any) => { - // Track each location feature by users id, - // so update the locations id to match the usersId const location = userLocation.locations[0]; location.id = userLocation.id; userLocation.location = location; delete userLocation.locations; - if (userLocation.user.iconUrl) { - let params = new HttpParams(); - params = params.append( - "access_token", - this.sessionService.getToken() - ); - params = params.append("_dc", userLocation.user.lastUpdated); + const token = this.sessionService.getToken(); + if (userLocation.user.iconUrl && token) { + const params = new HttpParams().append('access_token', token); location.style = { - iconUrl: `${userLocation.user.iconUrl}?${params.toString()}`, + iconUrl: `${userLocation.user.iconUrl}?${params.toString()}` }; } - if (this.filterService.isUserInTeamFilter(userLocation.id)) { - // Check if we already have this user, if so update, otherwise add - const localUser = filteredUsersById[userLocation.id]; - if (localUser) { - if ( - userLocation.location.properties.timestamp !== - localUser.location.properties.timestamp - ) { - updated.push(userLocation); - } - } else { - added.push(userLocation); + const localUser = existingUsersById[userLocation.id]; + if (localUser) { + if (userLocation.location.properties.timestamp !== localUser.location.properties.timestamp) { + updated.push(userLocation); } - - // remove from list of observations if it came back from server - // remaining elements in this list will be removed - delete filteredUsersById[userLocation.id]; - - usersById[userLocation.id] = userLocation; + } else { + added.push(userLocation); } - }); - // remaining elements were not pulled from the server, hence we should remove them - const removed = _.values(filteredUsersById); + delete existingUsersById[userLocation.id]; + usersById[userLocation.id] = userLocation; + }); - this.eventsById[event.id].usersById = _.keyBy(userLocations, "id"); - this.eventsById[event.id].filteredUsersById = usersById; + const removed = Object.values(existingUsersById); - this.usersChanged({ added: added, updated: updated, removed: removed }); - } + this.locations.byId = usersById; - poll(interval) { - if (interval <= 0) { - return; + if (added.length || updated.length || removed.length || Object.keys(usersById).length === 0) { + this.usersChanged(userInitiated); } - this.fetch().subscribe(() => { - this.pollListeners.forEach((listener: any) => { - if (typeof listener.onPoll === "function") { - listener.onPoll(); - } - }); - - this.pollingTimeout = setTimeout(() => { - this.poll(interval); - }, interval); - }); } - getNextFeed(event: MageEvent) { + getNextFeed(event: MageEvent): Feed | undefined { const now = Date.now(); - const feedsInSyncPriorityOrder = _.sortBy(this.feedSyncStates, (feed) => { - return feed.lastSync; + const feedsById = this.eventsById[event.id]?.feedsById ?? {}; + const feedsInSyncPriorityOrder = _.sortBy(this.feedSyncStates, feed => { return feed.lastSync; }); + const nextFeed = feedsInSyncPriorityOrder.find(syncState => { + if (!syncState.lastSync) { + return true; + } + const feed = feedsById[syncState.id]; + if (feed && (now - syncState.lastSync) > ((feed.updateFrequencySeconds ?? 0) * 1000)) { + return true; + } }); - const nextFeed = - feedsInSyncPriorityOrder.find((syncState) => { - if (!syncState.lastSync) { - return true; - } - const feed = this.eventsById[event.id].feedsById[syncState.id]; - if (now - syncState.lastSync > feed.updateFrequencySeconds * 1000) { - return true; - } - }) || {}; - return this.eventsById[event.id].feedsById[nextFeed.id]; + return nextFeed ? feedsById[nextFeed.id] : undefined; } getFeedFetchDelay(event: MageEvent) { const now = Date.now(); - const delays = this.feedSyncStates.map((syncState) => { - const feed = this.eventsById[event.id].feedsById[syncState.id]; - if (!syncState.lastSync) { + const feedsById = this.eventsById[event.id]?.feedsById ?? {}; + const delays = this.feedSyncStates.map(syncState => { + const feed = feedsById[syncState.id]; + if (!syncState.lastSync || !feed) { return 0; } const elapsed = now - syncState.lastSync; - const frequencyMillis = feed.updateFrequencySeconds * 1000; + const frequencyMillis = (feed.updateFrequencySeconds ?? 0) * 1000; return frequencyMillis - elapsed; }); @@ -874,6 +805,7 @@ export class EventService { pollFeeds() { const event = this.filterService.getEvent(); + if (!event) return; const feed = this.getNextFeed(event); const scheduleNextPoll = () => { const delayMillis = this.getFeedFetchDelay(event); @@ -887,33 +819,22 @@ export class EventService { return scheduleNextPoll(); } - this.feedService - .fetchFeedItems(event, feed) - .pipe( - tap((content: any) => { - // TODO is this really created or updated, maybe just create as empty when, - // feeds come back - this.feedItemsChanged( - { - updated: [{ feed, items: content.items.features }], - }, - event - ); - }), - catchError((err) => { - // TODO: add error handling - console.error( - `error fetching feed content for feed ${feed.id}, ${feed.title}`, - err - ); - return of(); - }), - finalize(() => { - const state = this.feedSyncStates.find((f) => f.id === feed.id); - state.lastSync = Date.now(); - scheduleNextPoll(); - }) - ) - .subscribe(); + this.feedItemsSubscription = this.feedService.fetchFeedItems(event, feed).pipe( + tap((content: any) => { + this.feedItemsChanged({ + updated: [{ feed, items: content.items.features }] + }, event); + }), + catchError((err) => { + console.error(`error fetching feed content for feed ${feed.id}, ${feed.title}`, err); + return of(); + }) + ).subscribe({ + complete: () => { + const state = this.feedSyncStates.find(f => f.id === feed.id); + if (state) state.lastSync = Date.now(); + scheduleNextPoll(); + } + }); } } diff --git a/web-app/src/app/event/polling.service.spec.ts b/web-app/src/app/event/polling.service.spec.ts index d999e47d2..73dd3b8a2 100644 --- a/web-app/src/app/event/polling.service.spec.ts +++ b/web-app/src/app/event/polling.service.spec.ts @@ -1,20 +1,78 @@ import { TestBed } from '@angular/core/testing'; import { PollingService } from './polling.service'; +import { LocalStorageService } from '../http/local-storage.service'; -describe('Polling Service Tests', () => { +describe('PollingService', () => { + let service: PollingService; + let localStorageService: jasmine.SpyObj; - beforeEach(() => { + function createService(savedInterval: number | null): PollingService { + localStorageService = jasmine.createSpyObj('LocalStorageService', ['getPollingInterval', 'setPollingInterval']); + localStorageService.getPollingInterval.and.returnValue(savedInterval); + + TestBed.resetTestingModule(); TestBed.configureTestingModule({ - providers: [PollingService], - imports: [] + providers: [ + PollingService, + { provide: LocalStorageService, useValue: localStorageService } + ] }); + + return TestBed.inject(PollingService); + } + + it('should be created', () => { + service = createService(null); + expect(service).toBeTruthy(); }); - afterEach(() => { + describe('initial interval', () => { + it('defaults to 30 seconds when nothing is saved', () => { + service = createService(null); + expect(service.getPollingInterval()).toBe(30000); + }); + + it('defaults to 30 seconds when the saved value is zero', () => { + service = createService(0); + expect(service.getPollingInterval()).toBe(30000); + }); + + it('uses the saved interval when present', () => { + service = createService(5000); + expect(service.getPollingInterval()).toBe(5000); + }); + + it('emits the initial interval on pollingInterval$ to a new subscriber', (done) => { + service = createService(5000); + service.pollingInterval$.subscribe(interval => { + expect(interval).toBe(5000); + done(); + }); + }); }); - it('should be created', () => { - const service: PollingService = TestBed.inject(PollingService); - expect(service).toBeTruthy(); - }); -}); \ No newline at end of file + describe('setPollingInterval', () => { + beforeEach(() => { + service = createService(30000); + }); + + it('persists the new interval to local storage', () => { + service.setPollingInterval(120000); + expect(localStorageService.setPollingInterval).toHaveBeenCalledWith(120000); + }); + + it('updates getPollingInterval', () => { + service.setPollingInterval(120000); + expect(service.getPollingInterval()).toBe(120000); + }); + + it('emits the new interval on pollingInterval$', () => { + const emitted: number[] = []; + service.pollingInterval$.subscribe(interval => emitted.push(interval)); + + service.setPollingInterval(120000); + + expect(emitted).toEqual([30000, 120000]); + }); + }); +}); diff --git a/web-app/src/app/event/polling.service.ts b/web-app/src/app/event/polling.service.ts index fc94a1ac3..312928188 100644 --- a/web-app/src/app/event/polling.service.ts +++ b/web-app/src/app/event/polling.service.ts @@ -1,49 +1,29 @@ import { Injectable } from "@angular/core"; +import { BehaviorSubject, Observable } from "rxjs"; import { LocalStorageService } from "../http/local-storage.service"; -import * as _ from 'underscore'; @Injectable({ providedIn: 'root' }) export class PollingService { + private pollingIntervalSubject: BehaviorSubject; + readonly pollingInterval$: Observable; - private listeners = []; - private pollingInterval: any - - constructor( - private localStorageService: LocalStorageService - ) { - this.pollingInterval = parseInt(localStorageService.getPollingInterval()) - - if (!this.pollingInterval || parseInt(this.pollingInterval) === 0 || Number.isNaN(parseInt(this.pollingInterval))) { - this.pollingInterval = 30000; + constructor(private localStorageService: LocalStorageService) { + let interval = localStorageService.getPollingInterval(); + if (!interval) { + interval = 30000; } - } - - addListener(listener: any) { - this.listeners.push(listener); - - if (_.isFunction(listener.onPollingIntervalChanged)) { - listener.onPollingIntervalChanged(this.pollingInterval); - } - } - - removeListener(listener: any) { - this.listeners = _.reject(this.listeners, function (l: any) { return listener === l; }); + this.pollingIntervalSubject = new BehaviorSubject(interval); + this.pollingInterval$ = this.pollingIntervalSubject.asObservable(); } setPollingInterval(interval: number) { - this.pollingInterval = interval; this.localStorageService.setPollingInterval(interval); - _.each(this.listeners, function (listener: any) { - if (_.isFunction(listener.onPollingIntervalChanged)) { - listener.onPollingIntervalChanged(interval); - } - }); + this.pollingIntervalSubject.next(interval); } - getPollingInterval() { - return this.pollingInterval; + getPollingInterval(): number { + return this.pollingIntervalSubject.getValue(); } - -} \ No newline at end of file +} diff --git a/web-app/src/app/export/export-create/export-create.component.html b/web-app/src/app/export/export-create/export-create.component.html index 7bfd78838..6712dcf9e 100644 --- a/web-app/src/app/export/export-create/export-create.component.html +++ b/web-app/src/app/export/export-create/export-create.component.html @@ -6,7 +6,7 @@
info - The event and time range below default to your current map filters. + Your current event and filters are applied by default. Adjust them below to change what gets exported.
@@ -95,9 +95,15 @@
- + - + + Keyword Filter + + info_outline + + +
Form Fields To Include
@@ -177,7 +183,7 @@
Filter by Team or Member
- +
diff --git a/web-app/src/app/export/export-create/export-create.component.scss b/web-app/src/app/export/export-create/export-create.component.scss index 2b08f10b5..04a396b82 100644 --- a/web-app/src/app/export/export-create/export-create.component.scss +++ b/web-app/src/app/export/export-create/export-create.component.scss @@ -81,12 +81,10 @@ mat-panel-description { line-height: 14px; } -mat-expansion-panel { - --mat-expansion-container-background-color: white; -} - mat-toolbar { - background: white; + background: var(--mat-sys-surface-container-lowest); + color: var(--mat-sys-primary); + --mat-icon-button-icon-color: var(--mat-sys-primary); gap: 8px; } @@ -105,7 +103,7 @@ mat-form-field { .export { display: flex; flex-direction: column; - background: white; + background: var(--mat-sys-surface-container-low); height: 100%; } @@ -308,6 +306,18 @@ mat-paginator { margin-bottom: -1.25em; } +.keyword-filter-field { + width: 100%; +} + +.keyword-hint-icon { + font-size: 18px; + height: 18px; + width: 18px; + color: var(--mat-sys-on-surface-variant); + cursor: help; +} + .form__field { margin-left: 16px; } diff --git a/web-app/src/app/export/export-create/export-create.component.spec.ts b/web-app/src/app/export/export-create/export-create.component.spec.ts index 04b71ee3b..b97790429 100644 --- a/web-app/src/app/export/export-create/export-create.component.spec.ts +++ b/web-app/src/app/export/export-create/export-create.component.spec.ts @@ -1,4 +1,4 @@ -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { ComponentFixture, TestBed, fakeAsync, tick, waitForAsync } from '@angular/core/testing'; import { NO_ERRORS_SCHEMA } from '@angular/core'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { MatAutocompleteModule } from '@angular/material/autocomplete'; @@ -50,7 +50,7 @@ describe('ExportCreateComponent', () => { const exportService = jasmine.createSpyObj('ExportService', ['export']); const eventService = jasmine.createSpyObj('EventService', ['query']); - const filterService = jasmine.createSpyObj('FilterService', ['getEvent', 'getTeams', 'getUsers']); + const filterService = jasmine.createSpyObj('FilterService', ['getEvent', 'getObservationFilter', 'getLocationFilter']); const observationService = jasmine.createSpyObj('ObservationService', ['getObservationsPage']); const locationService = jasmine.createSpyObj('LocationService', ['getUserLocationsCount']); const sessionService = { user: { id: 'user1' } }; @@ -59,8 +59,8 @@ describe('ExportCreateComponent', () => { exportService.export.and.returnValue(of({})); eventService.query.and.returnValue(of([event])); filterService.getEvent.and.returnValue(event); - filterService.getTeams.and.returnValue([]); - filterService.getUsers.and.returnValue([]); + filterService.getObservationFilter.and.returnValue(null); + filterService.getLocationFilter.and.returnValue(null); observationService.getObservationsPage.and.returnValue(of({ items: [], totalCount: 0, links: { next: null, prev: null } })); locationService.getUserLocationsCount.and.returnValue(of({ totalCount: 0 })); exportService.export.calls.reset(); @@ -175,12 +175,149 @@ describe('ExportCreateComponent', () => { expect(request.observations.startDate).not.toEqual(request.locations.startDate); }); - it('includes keyword and condition in the export request from the field filter', () => { + it('includes keyword and condition in the export request from the field filter', fakeAsync(() => { component.eventControl.setValue(event); - component.onFilterChanged({ keyword: 'wildfire' }); + component.keywordControl.setValue('wildfire'); + tick(300); component.submit(); const request = exportService.export.calls.mostRecent().args[1]; expect(request.observations.keyword).toEqual('wildfire'); + })); + + it('applies the same keyword typed again after switching events', fakeAsync(() => { + component.eventControl.setValue(event); + component.keywordControl.setValue('wildfire'); + tick(300); + + component.onEventSelected({ option: { value: event } } as any); + component.keywordControl.setValue('wildfire'); + tick(300); + component.submit(); + + const request = exportService.export.calls.mostRecent().args[1]; + expect(request.observations.keyword).toEqual('wildfire'); + })); + + describe('defaults from the current filters', () => { + const condition = { formId: 10, field: 'field1', operator: '=', value: 'open' } as any; + + function createWithFilters(observationFilter: any, locationFilter: any = null): ExportCreateComponent { + filterService.getObservationFilter.and.returnValue(observationFilter); + filterService.getLocationFilter.and.returnValue(locationFilter); + const seeded = TestBed.createComponent(ExportCreateComponent); + seeded.detectChanges(); + return seeded.componentInstance; + } + + it('starts with no filters when there are none', () => { + const seeded = createWithFilters(null); + + expect(seeded.hasAttachments).toBe(false); + expect(seeded.isFavorite).toBe(false); + expect(seeded.isImportant).toBe(false); + expect(seeded.memberFilter).toBeNull(); + expect(seeded.keywordControl.value).toBe(''); + expect(seeded.filter).toBeNull(); + }); + + it('seeds the attachment, favorite and important toggles', () => { + const seeded = createWithFilters({ timeInterval: {}, hasAttachments: true, isUserFavorite: true, isFlaggedImportant: true }); + + expect(seeded.hasAttachments).toBe(true); + expect(seeded.isFavorite).toBe(true); + expect(seeded.isImportant).toBe(true); + }); + + it('seeds the observation and location member filters separately', () => { + const seeded = createWithFilters( + { timeInterval: {}, memberFilter: { teamIds: ['t1'], userIds: [] } }, + { timeInterval: {}, memberFilter: { teamIds: [], userIds: ['u1'] } } + ); + + expect(seeded.memberFilter).toEqual({ teamIds: ['t1'], userIds: [] }); + expect(seeded.locationMemberFilter).toEqual({ teamIds: [], userIds: ['u1'] }); + }); + + it('seeds the keyword and condition into the field filter', () => { + const seeded = createWithFilters({ timeInterval: {}, fieldFilter: { keyword: 'wildfire', condition } }); + + expect(seeded.keywordControl.value).toBe('wildfire'); + expect(seeded.currentCondition).toEqual(condition); + expect(seeded.filter).toEqual({ keyword: 'wildfire', condition }); + }); + + it('sends the seeded filters in the export request', () => { + const seeded = createWithFilters({ + timeInterval: {}, + hasAttachments: true, + isUserFavorite: true, + isFlaggedImportant: true, + memberFilter: { teamIds: ['t1'], userIds: [] }, + fieldFilter: { keyword: 'wildfire', condition } + }); + + seeded.submit(); + + const request = exportService.export.calls.mostRecent().args[1]; + expect(request.observations.hasAttachments).toBe(true); + expect(request.observations.favorites).toBe(true); + expect(request.observations.important).toBe(true); + expect(request.observations.teams).toEqual(['t1']); + expect(request.observations.keyword).toEqual('wildfire'); + expect(request.observations.condition).toEqual(condition); + }); + + it('requests the preview with the seeded filters', () => { + observationService.getObservationsPage.calls.reset(); + + createWithFilters({ timeInterval: {}, isFlaggedImportant: true, fieldFilter: { keyword: 'wildfire' } }); + + const options = observationService.getObservationsPage.calls.mostRecent().args[1]; + expect(options.important).toBe(true); + expect(options.filter).toEqual({ keyword: 'wildfire', condition: undefined }); + }); + + it('clears the seeded filters when a different event is selected', () => { + const seeded = createWithFilters({ + timeInterval: {}, + hasAttachments: true, + memberFilter: { teamIds: ['t1'], userIds: [] }, + fieldFilter: { keyword: 'wildfire', condition } + }); + + seeded.onEventSelected({ option: { value: event } } as any); + + expect(seeded.memberFilter).toBeNull(); + expect(seeded.keywordControl.value).toBe(''); + expect(seeded.currentCondition).toBeUndefined(); + expect(seeded.filter).toBeNull(); + }); + }); + + it('clears the condition chips when the event is selected', () => { + const conditionFilter = jasmine.createSpyObj('ObservationFieldFilterComponent', ['clear']); + component.conditionFilter = conditionFilter; + component.eventControl.setValue(event); + + component.onEventSelected({ option: { value: event } } as any); + + expect(conditionFilter.clear).toHaveBeenCalled(); + }); + + it('exports the day shown in the date pickers when a custom time range is left untouched', () => { + component.eventControl.setValue(event); + component.observationExportTime = 'custom'; + component.locationExportTime = 'custom'; + + component.submit(); + + const request = exportService.export.calls.mostRecent().args[1]; + const start = component.defaultStartDate.toISOString(); + const end = component.defaultEndDate.toISOString(); + expect(request.observations.startDate).toEqual(start); + expect(request.observations.endDate).toEqual(end); + expect(request.locations.startDate).toEqual(start); + expect(request.locations.endDate).toEqual(end); }); it('keeps observation and location member filters independent', () => { diff --git a/web-app/src/app/export/export-create/export-create.component.ts b/web-app/src/app/export/export-create/export-create.component.ts index d594767f8..7f8766e74 100644 --- a/web-app/src/app/export/export-create/export-create.component.ts +++ b/web-app/src/app/export/export-create/export-create.component.ts @@ -6,14 +6,14 @@ import { FilterService } from 'src/app/filter/filter.service'; import { FilterChoice, INTERVAL_CHOICES } from 'src/app/filter/filter.types'; import { MageEvent as FilterEvent } from 'src/app/entities/event/entities.event'; import { Export, ExportFormat, ExportFormProjection, ExportRequest, FormProjection } from '../entities.export'; -import { ObservationFieldFilter } from '../../entities/observation/filter/entities.observation.filter' +import { Condition, ObservationFieldFilter } from '../../entities/observation/filter/entities.observation.filter' import { MemberFilterSelection } from '../../event/event-member-filter.component'; -import { EMPTY, map, Observable, startWith, Subject, switchMap } from 'rxjs'; +import { debounceTime, EMPTY, map, Observable, startWith, Subject, switchMap } from 'rxjs'; import { PageEvent } from '@angular/material/paginator'; import { AbstractControl, FormControl, ValidationErrors } from '@angular/forms'; import { EventService } from 'src/app/event/event.service'; import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; -import { ObservationService, ObservationsPageRequestOptions } from 'src/app/observation/observation.service'; +import { ObservationService, ObservationsPage, ObservationsPageRequestOptions } from 'src/app/observation/observation.service'; import { SessionService } from 'src/app/http/session.service'; import { LocationService } from 'src/app/user/location/location.service'; import { Form } from '../../entities/event/entities.event'; @@ -101,6 +101,8 @@ export class ExportCreateComponent implements OnInit { formProjections: FormProjection[] = [] filter: ObservationFieldFilter | null = null + keywordControl = new FormControl('') + currentCondition?: Condition memberFilter: MemberFilterSelection | null = null locationMemberFilter: MemberFilterSelection | null = null @@ -126,11 +128,7 @@ export class ExportCreateComponent implements OnInit { showObservationPreview = false - preview?: { - items: any[], - totalCount: number, - links: { next: number | null, prev: number | null } - } + preview?: ObservationsPage locationPreview?: { totalCount: number } loadingLocationPreview = true @@ -156,15 +154,16 @@ export class ExportCreateComponent implements OnInit { } ngOnInit(): void { - const timeKey = this.mapCurrentFilterTimeValue() - this.observationExportTime = timeKey - this.locationExportTime = timeKey - if (timeKey === 'custom') { - this.observationStartDate = this.filterService.interval.options?.startDate ?? this.defaultStartDate - this.observationEndDate = this.filterService.interval.options?.endDate ?? this.defaultEndDate - this.locationStartDate = this.filterService.interval.options?.startDate ?? this.defaultStartDate - this.locationEndDate = this.filterService.interval.options?.endDate ?? this.defaultEndDate - } + const observationFilter = this.filterService.getObservationFilter() + const locationFilter = this.filterService.getLocationFilter() + + this.observationExportTime = observationFilter?.timeInterval?.choice?.filter ?? 86400 + this.locationExportTime = locationFilter?.timeInterval?.choice?.filter ?? 86400 + + this.observationStartDate = observationFilter?.timeInterval?.options?.startDate ?? this.defaultStartDate + this.observationEndDate = observationFilter?.timeInterval?.options?.endDate ?? this.defaultEndDate + this.locationStartDate = locationFilter?.timeInterval?.options?.startDate ?? this.defaultStartDate + this.locationEndDate = locationFilter?.timeInterval?.options?.endDate ?? this.defaultEndDate this.getObservations$.pipe( switchMap(options => this.exportEvent ? this.observationService.getObservationsPage(this.exportEvent, options) : EMPTY) @@ -180,17 +179,24 @@ export class ExportCreateComponent implements OnInit { this.loadingLocationPreview = false }) + this.keywordControl.valueChanges.pipe( + debounceTime(300) + ).subscribe(() => this.updateFieldFilter()) + const event = this.filterService.getEvent() this.eventControl.setValue(event) this.setEvent(event) - const currentMemberFilter = this.mapCurrentMemberFilter() - if (currentMemberFilter) { - this.memberFilter = currentMemberFilter - this.locationMemberFilter = currentMemberFilter - this.refreshPreview() - this.refreshLocationPreview() - } + this.hasAttachments = observationFilter?.hasAttachments ?? false + this.isFavorite = observationFilter?.isUserFavorite ?? false + this.isImportant = observationFilter?.isFlaggedImportant ?? false + this.memberFilter = observationFilter?.memberFilter ?? null + this.keywordControl.setValue(observationFilter?.fieldFilter?.keyword ?? '', { emitEvent: false }) + this.currentCondition = observationFilter?.fieldFilter?.condition ?? undefined + this.updateFieldFilter() + + this.locationMemberFilter = locationFilter?.memberFilter ?? null + this.refreshLocationPreview() this.eventService.query().subscribe((events: FilterEvent[]) => { this.events = events @@ -356,8 +362,16 @@ export class ExportCreateComponent implements OnInit { this.close.emit() } - onFilterChanged(filter: ObservationFieldFilter): void { - this.filter = (filter?.condition || filter?.keyword?.length) ? filter : null + onConditionChanged(condition: Condition | undefined): void { + this.currentCondition = condition + this.updateFieldFilter() + } + + private updateFieldFilter(): void { + const keyword = (this.keywordControl.value || '').trim() + this.filter = (keyword || this.currentCondition) + ? { keyword: keyword || undefined, condition: this.currentCondition } + : null this.refreshPreview() } @@ -416,6 +430,9 @@ export class ExportCreateComponent implements OnInit { private setEvent(event: FilterEvent | null) { this.exportEvent = event this.filter = null + this.currentCondition = undefined + this.conditionFilter?.clear() + this.keywordControl.setValue('', { emitEvent: false }) this.memberFilter = null this.locationMemberFilter = null this.showObservationPreview = false @@ -499,16 +516,6 @@ export class ExportCreateComponent implements OnInit { return `Members: ${parts.join(', ')}` } - private mapCurrentMemberFilter(): MemberFilterSelection | null { - const teamIds = this.filterService.getTeams().map(team => team.id) - const userIds = this.filterService.getUsers().map(user => user.id) - return (teamIds.length || userIds.length) ? { teamIds, userIds } : null - } - - private mapCurrentFilterTimeValue(): string | number { - return this.filterService.interval?.choice?.filter ?? 86400 - } - private exportInterval(filterValue: string | number, startDate?: Date, endDate?: Date): { start?: string, end?: string } { let start: string | undefined let end: string | undefined diff --git a/web-app/src/app/export/export-empty/export-empty.component.scss b/web-app/src/app/export/export-empty/export-empty.component.scss index a7e62434e..0cbd4017e 100644 --- a/web-app/src/app/export/export-empty/export-empty.component.scss +++ b/web-app/src/app/export/export-empty/export-empty.component.scss @@ -1,7 +1,7 @@ :host { display: flex; align-items: center; - background-color: white; + background-color: var(--mat-sys-surface-container-low); height: 100%; } @@ -13,7 +13,6 @@ align-items: center; justify-content: center; text-align: center; - gap: 4px; padding: 16px; } @@ -21,19 +20,24 @@ font-size: 96px; width: 96px; height: 96px; - margin-bottom: 4px; - color: var(--mat-sys-on-surface-variant); + margin-bottom: 16px; + font-variation-settings: 'fill' 0, 'wght' 300, 'grad' 0, 'opsz' 96; + color: var(--mat-sys-outline); } .empty-state-title { - margin: 0; - font-size: var(--mat-sys-title-large-size); - color: var(--mat-sys-on-surface); + margin: 0 0 8px; + font: 400 24px / 32px Roboto, "Helvetica Neue", sans-serif; + color: var(--mat-sys-on-surface-variant); } .empty-state-text { - margin: 4px 0 32px; - font-size: var(--mat-sys-body-medium-size); + margin: 0; + font: 400 14px / 20px Roboto, "Helvetica Neue", sans-serif; color: var(--mat-sys-on-surface-variant); max-width: 350px; } + +.empty-state button { + margin-top: 16px; +} diff --git a/web-app/src/app/export/export-list/export-list.component.scss b/web-app/src/app/export/export-list/export-list.component.scss index 3a805d318..400d05923 100644 --- a/web-app/src/app/export/export-list/export-list.component.scss +++ b/web-app/src/app/export/export-list/export-list.component.scss @@ -1,9 +1,11 @@ mat-toolbar { - background-color: white; + background-color: var(--mat-sys-surface-container-lowest); + color: var(--mat-sys-primary); } .export { height: 100%; + background-color: var(--mat-sys-surface-container-low); } .export__form { diff --git a/web-app/src/app/export/export-view/export-view.component.html b/web-app/src/app/export/export-view/export-view.component.html index 535db99ed..90cf806ee 100644 --- a/web-app/src/app/export/export-view/export-view.component.html +++ b/web-app/src/app/export/export-view/export-view.component.html @@ -4,7 +4,13 @@ - Export + Export + + download + + @@ -128,14 +134,6 @@
- - - - Download - -
diff --git a/web-app/src/app/export/export-view/export-view.component.scss b/web-app/src/app/export/export-view/export-view.component.scss index 2eeb8ab9d..5b598648c 100644 --- a/web-app/src/app/export/export-view/export-view.component.scss +++ b/web-app/src/app/export/export-view/export-view.component.scss @@ -1,6 +1,7 @@ mat-toolbar { - background-color: white; + background-color: var(--mat-sys-surface-container-lowest); color: var(--mat-sys-primary); + --mat-icon-button-icon-color: var(--mat-sys-primary); flex-shrink: 0; } @@ -35,6 +36,10 @@ mat-expansion-panel { gap: 8px; } +.toolbar__title { + flex: 1; +} + .chips { margin-bottom: 16px; } @@ -61,7 +66,7 @@ mat-expansion-panel { display: flex; height: 100%; flex-direction: column; - background-color: #e1e2e1; + background-color: var(--mat-sys-surface-container-low); } .card { diff --git a/web-app/src/app/export/export-view/export-view.component.ts b/web-app/src/app/export/export-view/export-view.component.ts index 52e70e275..e841afc39 100644 --- a/web-app/src/app/export/export-view/export-view.component.ts +++ b/web-app/src/app/export/export-view/export-view.component.ts @@ -89,7 +89,7 @@ export class ExportViewComponent implements OnInit, AfterViewInit { .map(team => ({ type: 'team' as const, name: team.name })) if (userIds.length) { - this.eventService.getMembers(event).pipe(takeUntilDestroyed(this.destroyRef)).subscribe(users => { + this.eventService.getMembers(event.id).pipe(takeUntilDestroyed(this.destroyRef)).subscribe(users => { const userLabels = users .filter(user => userIds.includes(user.id)) .map(user => ({ type: 'user' as const, name: user.displayName || user.username })) diff --git a/web-app/src/app/feed/feed-list/feed-list.component.scss b/web-app/src/app/feed/feed-list/feed-list.component.scss index 6481f9c12..3e7e5aaf3 100644 --- a/web-app/src/app/feed/feed-list/feed-list.component.scss +++ b/web-app/src/app/feed/feed-list/feed-list.component.scss @@ -5,11 +5,11 @@ right: 0; left: 0; overflow-y: auto; - background: var(--mat-sys-surface-container); + background: var(--mat-sys-surface-container-low); } mat-toolbar { - background-color: white; + background-color: var(--mat-sys-surface-container-lowest); } .feed { @@ -26,7 +26,7 @@ mat-toolbar { } .items { - background: var(--mat-sys-surface-container); + background: var(--mat-sys-surface-container-low); padding: 8px; flex: 1; overflow-y: auto; diff --git a/web-app/src/app/filter/entities.filter.ts b/web-app/src/app/filter/entities.filter.ts deleted file mode 100644 index 0153ae8f7..000000000 --- a/web-app/src/app/filter/entities.filter.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { MemberFilterSelection } from '../event/event-member-filter.component' -import { ObservationFieldFilter } from '../entities/observation/filter/entities.observation.filter' - -export interface IntervalChoice { - filter: string | number - label: string -} - -export interface IntervalOptions { - startDate?: Date - endDate?: Date - localTime?: boolean -} - -export interface TimeInterval { - choice: IntervalChoice - options?: IntervalOptions -} - -export type EventObservationFilter = { - timeInterval?: TimeInterval - memberFilter?: MemberFilterSelection | null - hasAttachments?: boolean - isUserFavorite?: boolean - isFlaggedImportant?: boolean - fieldFilter?: ObservationFieldFilter | null -} - -export type EventLocationFilter = { - timeInterval?: TimeInterval - memberFilter?: MemberFilterSelection | null -} - -function localUtcOffset(): string { - const off = -new Date().getTimezoneOffset() - const sign = off >= 0 ? '+' : '-' - const hour = String(Math.floor(Math.abs(off) / 60)).padStart(2, '0') - const minute = String(Math.abs(off) % 60).padStart(2, '0') - return `${sign}${hour}:${minute}` -} - -export const INTERVAL_CHOICES: IntervalChoice[] = [ - { filter: 'all', label: 'All' }, - { filter: 'today', label: `Today (Local GMT ${localUtcOffset()})` }, - { filter: 86400, label: 'Last 24 Hours' }, - { filter: 43200, label: 'Last 12 Hours' }, - { filter: 21600, label: 'Last 6 Hours' }, - { filter: 3600, label: 'Last Hour' }, - { filter: 'custom', label: 'Custom' } -] - -const DEFAULT_TIME_INTERVAL: TimeInterval = { - choice: INTERVAL_CHOICES[1] -} - -export const DEFAULT_OBSERVATION_FILTER: EventObservationFilter = { - timeInterval: DEFAULT_TIME_INTERVAL -} - -export const DEFAULT_LOCATION_FILTER: EventLocationFilter = { - timeInterval: DEFAULT_TIME_INTERVAL -} diff --git a/web-app/src/app/filter/filter.component.html b/web-app/src/app/filter/filter.component.html deleted file mode 100644 index c78c1c52c..000000000 --- a/web-app/src/app/filter/filter.component.html +++ /dev/null @@ -1,190 +0,0 @@ -
-
-

Filter

- -
- -
- - Teams - - - - {{ team.name }} - cancel - - - - - - - - {{ team.name }} - - - -
-
- - -
- - Users - - - - {{ user.displayName || user.username }} - cancel - - - - - - - - {{ user.displayName || user.username }} - - - -
-
- - -
- - Forms - - - - {{ form.name }} - cancel - - - - - - - - {{ form.name }} - - - -
-
- -
- - Time - - - {{ choice.label }} - - - - -
-
-
- - -
- -
- - -
-
- -
- -
-
-
-
- - -
-
-
-
-
-
-
-
-
- -
- - -
-
\ No newline at end of file diff --git a/web-app/src/app/filter/filter.component.scss b/web-app/src/app/filter/filter.component.scss deleted file mode 100644 index 6e8db05d6..000000000 --- a/web-app/src/app/filter/filter.component.scss +++ /dev/null @@ -1,106 +0,0 @@ -mat-form-field { - width: 100%; -} - -mat-button { - width: 100%; -} - -datetime-picker { - display: block; - width: 100%; -} - -.content { - height: 100%; - display: flex; - flex-direction: column; - justify-content: space-between; -} - -.mat-select-panel mat-option.mat-option { - height: 72px; -} - -.mat-option-text.mat-option-text { - display: flex; - flex-direction: column; - line-height: 24px; - margin: 8px 0; - white-space: nowrap; - text-overflow: ellipsis; -} - -.option-text { - overflow: hidden; - text-overflow: ellipsis; -} - -.option-description { - font: 400 12px / 16px Roboto, 'Helvetica Neue', sans-serif; - overflow: hidden; - text-overflow: ellipsis; - opacity: 0.6; -} - -.datetime { - width: 100%; - display: flex; - gap: 8px; - align-items: start; -} - -.datetime-fields { - min-width: 0; - display: flex; - flex-direction: column; -} - -.timezone { - display: flex; - align-items: flex-start; - justify-content: flex-start; - padding-top: 8px; -} - -.timezone__button { - white-space: nowrap; -} - -.filter-loading { - display: flex; - flex-direction: column; - gap: 16px; - min-width: 400px; -} - -.skeleton { - border-radius: 4px; - animation: pulse 1.4s ease-in-out infinite; - background: linear-gradient( - 90deg, - rgba(255, 255, 255, 0.06) 25%, - rgba(255, 255, 255, 0.12) 37%, - rgba(255, 255, 255, 0.06) 63% - ); - background-size: 400% 100%; -} - -.skeleton-title { - height: 24px; - width: 120px; -} - -.skeleton-field { - height: 56px; - width: 100%; -} - -@keyframes pulse { - 0% { - background-position: 100% 0; - } - 100% { - background-position: 0 0; - } -} diff --git a/web-app/src/app/filter/filter.component.spec.ts b/web-app/src/app/filter/filter.component.spec.ts deleted file mode 100644 index 33c3c6e61..000000000 --- a/web-app/src/app/filter/filter.component.spec.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { FilterComponent } from './filter.component'; -import { MatDialogModule as MatDialogModule, MatDialogRef as MatDialogRef } from '@angular/material/dialog'; -import { provideHttpClientTesting } from '@angular/common/http/testing'; -import { FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { MatAutocompleteModule as MatAutocompleteModule } from '@angular/material/autocomplete'; -import { MatFormFieldModule as MatFormFieldModule } from '@angular/material/form-field'; -import { MatChipsModule as MatChipsModule } from '@angular/material/chips'; -import { MatSelectModule as MatSelectModule } from '@angular/material/select'; -import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; - -describe('FilterComponent', () => { - let component: FilterComponent; - let fixture: ComponentFixture; - - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - declarations: [FilterComponent], - imports: [FormsModule, - ReactiveFormsModule, - MatAutocompleteModule, - MatDialogModule, - MatFormFieldModule, - MatChipsModule, - MatSelectModule], - providers: [{ - provide: MatDialogRef, useValue: {} - }, provideHttpClient(withInterceptorsFromDi()), provideHttpClientTesting()] -}).compileComponents(); - })); - - beforeEach(() => { - fixture = TestBed.createComponent(FilterComponent); - component = fixture.componentInstance; - }); - - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/web-app/src/app/filter/filter.component.ts b/web-app/src/app/filter/filter.component.ts deleted file mode 100644 index b93e96a64..000000000 --- a/web-app/src/app/filter/filter.component.ts +++ /dev/null @@ -1,289 +0,0 @@ -import { Component, OnInit, ViewEncapsulation } from '@angular/core'; -import { MatDialogRef as MatDialogRef } from '@angular/material/dialog'; -import { FilterService } from './filter.service'; -import { EventService } from '../event/event.service'; -import { FormControl } from '@angular/forms'; -import { - Observable, - firstValueFrom, - map, - startWith, - of -} from 'rxjs'; -import { COMMA, ENTER } from '@angular/cdk/keycodes'; -import { MatAutocompleteSelectedEvent as MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; -import { LocalStorageService } from '../http/local-storage.service'; -import moment from 'moment'; -import { User } from '@ngageoint/mage.web-core-lib/user'; -import { - FilterChoice, - Interval, - IntervalOptions -} from './filter.types'; -import { MageEvent, Form } from '../entities/event/entities.event'; -import { Team } from '../entities/team/entities.team'; - -@Component({ - selector: 'filter', - templateUrl: './filter.component.html', - styleUrls: ['./filter.component.scss'], - encapsulation: ViewEncapsulation.None, - standalone: false -}) -export class FilterComponent implements OnInit { - readonly separatorKeysCodes: number[] = [ENTER, COMMA]; - - event: MageEvent; - selectedTeams: Team[] = []; - - eventUsers: User[] = []; - selectedUsers: User[] = []; - - eventForms: Form[] = []; - selectedForms: Form[] = []; - - teamControl = new FormControl(); - userControl = new FormControl(); - formControl = new FormControl(); - - filteredTeams: Observable = of([]); - filteredUsers: Observable = of([]); - filteredForms: Observable = of([]); - - timeZone: string; - localOffset: string; - interval: Interval; - defaultStartDate: Date; - startDate: Date; - defaultEndDate: Date; - endDate: Date; - intervalChoice: FilterChoice; - intervalChoices: FilterChoice[]; - - isLoading = true; - - constructor( - public dialogRef: MatDialogRef, - private eventService: EventService, - private filterService: FilterService, - private localStorageService: LocalStorageService - ) {} - - async ngOnInit() { - this.isLoading = true; - - try { - const event: MageEvent = this.filterService.getEvent(); - this.event = event; - - const teamIds = this.localStorageService.getTeams() || []; - this.selectedTeams = teamIds - .map((teamId: string) => - event.teams?.find((team: Team) => team.id === teamId) - ) - .filter(Boolean); - - const users: User[] = this.localStorageService.getUsers() || []; - const forms: Form[] = this.localStorageService.getForms() || []; - this.selectedUsers = users; - this.selectedForms = forms; - - const eUsers = await this.getUsers(event); - this.eventUsers = eUsers; - - if (this.eventUsers.length > 0) { - this.userControl.enable({ emitEvent: false }); - } else { - this.userControl.disable({ emitEvent: false }); - } - - this.eventForms = event.forms || []; - - if (this.eventForms.length > 0) { - this.formControl.enable({ emitEvent: false }); - } else { - this.formControl.disable({ emitEvent: false }); - } - - if (event.teams?.length > 0) { - this.teamControl.enable({ emitEvent: false }); - } else { - this.teamControl.disable({ emitEvent: false }); - } - - this.interval = this.filterService.getInterval(); - this.timeZone = - this.interval?.options?.localTime === false ? 'gmt' : 'local'; - this.intervalChoice = this.interval.choice; - this.intervalChoices = this.filterService.intervalChoices; - - this.defaultStartDate = this.interval?.options?.startDate - ? this.interval.options.startDate - : moment().startOf('day').toDate(); - - this.defaultEndDate = this.interval?.options?.endDate - ? this.interval.options.endDate - : moment().endOf('day').toDate(); - - this.localOffset = moment().format('Z'); - - this.setFilteredValues(this.eventUsers); - } finally { - this.isLoading = false; - } - } - - async getUsers(event: MageEvent): Promise { - try { - const users = await firstValueFrom(this.eventService.getMembers(event)); - return users; - } catch (error) { - console.error('Error Fetching Members ', error); - return []; - } - } - - setFilteredValues(users: User[]): void { - this.filteredTeams = this.teamControl.valueChanges.pipe( - startWith(''), - map((value) => (typeof value === 'string' ? value : value.name)), - map((name) => this.event - ? this.filterAutocomplete(this.event.teams || [], this.selectedTeams, name, (team) => team.name || '') - : [] - ) - ); - - this.filteredUsers = this.userControl.valueChanges.pipe( - startWith(''), - map((value) => - typeof value === 'string' ? value : value.displayName || value.username - ), - map((name) => this.filterAutocomplete( - users, this.selectedUsers, name, (user) => user.displayName || user.username, (user) => user.id - )) - ); - - this.filteredForms = this.formControl.valueChanges.pipe( - startWith(''), - map((value) => (typeof value === 'string' ? value : value.name)), - map((name) => this.filterAutocomplete( - this.eventForms, this.selectedForms, name, (form) => form.name, (form) => form.id - )) - ); - } - - /** - * Narrows a picker's remaining options to those not already selected, and - * to those matching the current search text by name prefix. - */ - private filterAutocomplete( - items: T[], - selected: T[], - name: string, - getName: (item: T) => string, - getId: (item: T) => unknown = (item) => item - ): T[] { - const selectedIds = selected.map(getId); - const available = items.filter((item) => !selectedIds.includes(getId(item))); - const query = (name || '').toLowerCase(); - return query - ? available.filter((item) => getName(item).toLowerCase().startsWith(query)) - : available; - } - - onSelectTeam(event: MatAutocompleteSelectedEvent): void { - this.selectedTeams.push(event.option.value); - this.teamControl.setValue(''); - } - - onSelectUser(event: MatAutocompleteSelectedEvent): void { - this.selectedUsers.push(event.option.value); - this.userControl.setValue(''); - } - - onSelectForm(event: MatAutocompleteSelectedEvent): void { - this.selectedForms.push(event.option.value); - this.formControl.setValue(''); - } - - onRemoveTeam(team: Team): void { - this.selectedTeams = this.selectedTeams.filter( - (selectedTeam: Team) => team.name !== selectedTeam.name - ); - this.teamControl.setValue(''); - } - - onRemoveUser(user: User): void { - this.selectedUsers = this.selectedUsers.filter( - (selectedUser: User) => - (user.displayName || user.username) !== - (selectedUser.displayName || selectedUser.username) - ); - this.userControl.setValue(''); - } - - onRemoveForm(form: Form): void { - this.selectedForms = this.selectedForms.filter( - (selectedForm: Form) => form.name !== selectedForm.name - ); - this.formControl.setValue(''); - } - - onDisplayTeam(team: Team): string { - return team && team.name ? team.name : ''; - } - - onDisplayUser(user: User): string { - return user && user.displayName ? user.displayName || user.username : ''; - } - - onDisplayForm(form: Form): string { - return form && form.name ? form.name : ''; - } - - onStartDate(date: Date): void { - this.startDate = date; - } - - onEndDate(date: Date): void { - this.endDate = date; - } - - onTimezone(): void { - this.timeZone = this.timeZone === 'gmt' ? 'local' : 'gmt'; - } - - public compareIntervalChoices = function (option, value): boolean { - return option.label === value.label; - }; - - /** - * Filters Observation List using the FilterService - * @return {void} No Return - */ - - onFilter(): void { - const options: IntervalOptions = {}; - if (this.intervalChoice.filter === 'custom') { - options.startDate = this.startDate; - options.endDate = this.endDate; - options.localTime = this.timeZone === 'local'; - } - - this.filterService.setFilter({ - teams: this.selectedTeams, - timeInterval: { - choice: this.intervalChoice, - options: options - }, - users: this.selectedUsers, - forms: this.selectedForms - }); - - this.dialogRef.close(); - } - - onCancel(): void { - this.dialogRef.close(); - } -} diff --git a/web-app/src/app/filter/filter.service.spec.ts b/web-app/src/app/filter/filter.service.spec.ts index 656908cd8..c9528f858 100644 --- a/web-app/src/app/filter/filter.service.spec.ts +++ b/web-app/src/app/filter/filter.service.spec.ts @@ -1,188 +1,428 @@ -import { TestBed } from '@angular/core/testing'; -import { of } from 'rxjs'; -import { FilterService } from './filter.service'; -import { UserService } from '../user/user.service'; -import { LocalStorageService } from '../http/local-storage.service'; -import { SessionService } from 'mage-web-app/http/session.service'; -import { provideHttpClientTesting } from '@angular/common/http/testing'; -import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; - -describe('Filter Service Tests', () => { - - let service: FilterService; - let userService: jasmine.SpyObj; - let localStorageService: jasmine.SpyObj; +import { TestBed } from '@angular/core/testing' +import { FilterService } from './filter.service' +import { LocalStorageService } from '../http/local-storage.service' +import { DEFAULT_LOCATION_FILTER, DEFAULT_OBSERVATION_FILTER, EventLocationFilter, EventObservationFilter } from './filter.types' +import { BinaryCondition } from '../entities/observation/filter/entities.observation.filter' + +const mockEvent = { id: 1, name: 'Test Event', teams: [{ id: 't1', userIds: ['u1'] }] } +const mockObsFilter: EventObservationFilter = { hasAttachments: true, timeInterval: { choice: { filter: 86400, label: 'Last 24 Hours' } } } +const mockLocFilter: EventLocationFilter = { ...DEFAULT_LOCATION_FILTER, memberFilter: { teamIds: ['t1'], userIds: [] } } + +class MockLocalStorageService { + + getEventId = jasmine.createSpy('getEventId').and.returnValue(null) + setEventId = jasmine.createSpy('setEventId') + getObservationFilter = jasmine.createSpy('getObservationFilter').and.returnValue(null) + setObservationFilter = jasmine.createSpy('setObservationFilter') + getLocationFilter = jasmine.createSpy('getLocationFilter').and.returnValue(null) + setLocationFilter = jasmine.createSpy('setLocationFilter') +} + +describe('FilterService', () => { + let service: FilterService + let localStorage: MockLocalStorageService beforeEach(() => { - userService = jasmine.createSpyObj('UserService', ['addRecentEvent']); - userService.addRecentEvent.and.returnValue(of({})); - - localStorageService = jasmine.createSpyObj('LocalStorageService', [ - 'getTimeInterval', 'setTimeInterval', - 'getTeams', 'setTeams', - 'getUsers', 'setUsers', - 'getForms', 'setForms' - ]); - localStorageService.getTimeInterval.and.returnValue(null); - localStorageService.getTeams.and.returnValue([]); + localStorage = new MockLocalStorageService() TestBed.configureTestingModule({ - imports: [], providers: [ FilterService, - { provide: UserService, useValue: userService }, - { provide: LocalStorageService, useValue: localStorageService }, - { provide: SessionService, useValue: {} }, - provideHttpClient(withInterceptorsFromDi()), - provideHttpClientTesting() + { provide: LocalStorageService, useValue: localStorage } ] - }); - - service = TestBed.inject(FilterService); - }); - - it('should be created', () => { - expect(service).toBeTruthy(); - }); - - describe('event$', () => { - - it('emits the current value (null) to a new subscriber', (done) => { - service.event$.subscribe((event) => { - expect(event).toBeNull(); - done(); - }); - }); - - it('emits when setEvent is called with a new event', () => { - const emitted: any[] = []; - service.event$.subscribe((event) => emitted.push(event)); + }) + + service = TestBed.inject(FilterService) + }) + + describe('constructor', () => { + it('initializes with null event and filters', () => { + expect(service.getEvent()).toBeNull() + expect(service.getObservationFilter()).toBeNull() + expect(service.getLocationFilter()).toBeNull() + }) + }) + + describe('getSavedEventId', () => { + it('returns null when no event id is saved', () => { + expect(service.getSavedEventId()).toBeNull() + }) + + it('returns the saved event id from localStorage', () => { + localStorage.getEventId.and.returnValue(mockEvent.id) + expect(service.getSavedEventId()).toBe(mockEvent.id) + }) + }) + + describe('destroy', () => { + beforeEach(() => { + service.setEvent(mockEvent as any) + localStorage.setEventId.calls.reset() + localStorage.setObservationFilter.calls.reset() + localStorage.setLocationFilter.calls.reset() + }) + + it('clears the event and both filters', () => { + service.destroy() + + expect(service.getEvent()).toBeNull() + expect(service.getObservationFilter()).toBeNull() + expect(service.getLocationFilter()).toBeNull() + }) + + it('tells subscribers the event and filters are gone', () => { + const events: any[] = [] + const observationFilters: any[] = [] + const locationFilters: any[] = [] + service.event$.subscribe(e => events.push(e)) + service.observationFilter$.subscribe(f => observationFilters.push(f)) + service.locationFilter$.subscribe(f => locationFilters.push(f)) + + service.destroy() + + expect(events[events.length - 1]).toBeNull() + expect(observationFilters[observationFilters.length - 1]).toBeNull() + expect(locationFilters[locationFilters.length - 1]).toBeNull() + }) + + it('leaves the saved event and filters in storage', () => { + service.destroy() + + expect(localStorage.setEventId).not.toHaveBeenCalled() + expect(localStorage.setObservationFilter).not.toHaveBeenCalled() + expect(localStorage.setLocationFilter).not.toHaveBeenCalled() + }) + + it('lets the same event id be selected again with a fresh event object', () => { + const edited = { ...mockEvent, forms: [{ id: 9 }] } + + service.destroy() + service.setEvent(edited as any) + + expect(service.getEvent()).toBe(edited as any) + }) + }) + + describe('setEvent', () => { + it('updates event$ observable', (done) => { + service.event$.subscribe(event => { + if (event) { + expect(event).toEqual(mockEvent as any) + done() + } + }) + service.setEvent(mockEvent as any) + }) + + it('persists event id to localStorage', () => { + service.setEvent(mockEvent as any) + expect(localStorage.setEventId).toHaveBeenCalledWith(mockEvent.id) + }) + + it('loads per-event obs and loc filters from localStorage', () => { + localStorage.getObservationFilter.and.returnValue(mockObsFilter) + localStorage.getLocationFilter.and.returnValue(mockLocFilter) + + service.setEvent(mockEvent as any) + + expect(localStorage.getObservationFilter).toHaveBeenCalledWith(mockEvent.id) + expect(localStorage.getLocationFilter).toHaveBeenCalledWith(mockEvent.id) + expect(service.getObservationFilter()).toEqual(mockObsFilter) + expect(service.getLocationFilter()).toEqual(mockLocFilter) + }) + + it('falls back to defaults when no per-event filters are stored', () => { + service.setEvent(mockEvent as any) + + expect(service.getObservationFilter()).toEqual(DEFAULT_OBSERVATION_FILTER) + expect(service.getLocationFilter()).toEqual(DEFAULT_LOCATION_FILTER) + }) + + it('drops a saved team filter that is no longer on the event', () => { + const staleFilter = { ...DEFAULT_LOCATION_FILTER, memberFilter: { teamIds: ['stale-team'], userIds: [] } } + localStorage.getLocationFilter.and.returnValue(staleFilter) + + service.setEvent(mockEvent as any) + + expect(service.getLocationFilter()?.memberFilter).toBeNull() + }) + + it('keeps a saved team filter that is still on the event', () => { + localStorage.getLocationFilter.and.returnValue(mockLocFilter) + + service.setEvent(mockEvent as any) + + expect(service.getLocationFilter()?.memberFilter).toEqual(mockLocFilter.memberFilter!) + }) + + it('drops only the stale team id, keeping valid ones and any userIds', () => { + const mixedFilter = { + ...DEFAULT_LOCATION_FILTER, + memberFilter: { teamIds: ['t1', 'stale-team'], userIds: ['u2'] } + } + localStorage.getLocationFilter.and.returnValue(mixedFilter) + + service.setEvent(mockEvent as any) + + expect(service.getLocationFilter()?.memberFilter).toEqual({ teamIds: ['t1'], userIds: ['u2'] }) + }) + + it('clears obs and loc filters when event is null', () => { + service.setEvent(mockEvent as any) + service.setEvent(null) + + expect(service.getObservationFilter()).toBeNull() + expect(service.getLocationFilter()).toBeNull() + }) - const event: any = { id: 1, name: 'Event 1', teams: [] }; - service.setEvent(event); + it('persists null id to localStorage when clearing event', () => { + service.setEvent(mockEvent as any) + service.setEvent(null) + expect(localStorage.setEventId).toHaveBeenCalledWith(null) + }) - expect(emitted).toEqual([null, event]); - }); + it('emits updated obs and loc filters via observables', (done) => { + localStorage.getObservationFilter.and.returnValue(mockObsFilter) + localStorage.getLocationFilter.and.returnValue(mockLocFilter) - it('does not emit again for the same event id', () => { - const event: any = { id: 1, name: 'Event 1', teams: [] }; - service.setEvent(event); + let obsEmitted = false + let locEmitted = false - const emitted: any[] = []; - service.event$.subscribe((e) => emitted.push(e)); + service.observationFilter$.subscribe(f => { + if (f?.hasAttachments === mockObsFilter.hasAttachments) obsEmitted = true + if (obsEmitted && locEmitted) done() + }) + service.locationFilter$.subscribe(f => { + if (f?.memberFilter === mockLocFilter.memberFilter) locEmitted = true + if (obsEmitted && locEmitted) done() + }) - service.setEvent({ id: 1, name: 'Event 1 renamed', teams: [] } as any); + service.setEvent(mockEvent as any) + }) - expect(emitted).toEqual([event]); - }); + it('loads filters for the new event when switching events', () => { + const event2 = { id: 2, name: 'Event 2' } + const obsFilter2: EventObservationFilter = { ...DEFAULT_OBSERVATION_FILTER, isFlaggedImportant: true } - it('emits null when removeFilters is called', () => { - const event: any = { id: 1, name: 'Event 1', teams: [] }; - service.setEvent(event); + localStorage.getObservationFilter.and.callFake((id: number) => + id === event2.id ? obsFilter2 : mockObsFilter + ) - const emitted: any[] = []; - service.event$.subscribe((e) => emitted.push(e)); + service.setEvent(mockEvent as any) + expect(service.getObservationFilter()).toEqual(mockObsFilter) - service.removeFilters(); + service.setEvent(event2 as any) + expect(service.getObservationFilter()).toEqual(obsFilter2) + }) - expect(emitted).toEqual([event, null]); - }); - }); + it('does not re-emit or re-persist when reselecting the already-active event', () => { + service.setEvent(mockEvent as any) + localStorage.setEventId.calls.reset() - describe('teams$/users$/forms$', () => { + const emitted: any[] = [] + service.event$.subscribe(event => emitted.push(event)) - it('emits current teams when setTeams is called', () => { - const emitted: any[] = []; - service.teams$.subscribe((teams) => emitted.push(teams)); + service.setEvent({ ...mockEvent } as any) - const team: any = { id: 1, name: 'Team 1' }; - service.setTeams([team]); + expect(emitted).toEqual([mockEvent]) + expect(localStorage.setEventId).not.toHaveBeenCalled() + }) - expect(emitted).toEqual([[], [team]]); - }); + it('does not re-emit when both the current and new event are null', () => { + const emitted: any[] = [] + service.event$.subscribe(event => emitted.push(event)) - it('emits current users when setUsers is called', () => { - const emitted: any[] = []; - service.users$.subscribe((users) => emitted.push(users)); + service.setEvent(null) - const user: any = { id: 'u1', displayName: 'User 1' }; - service.setUsers([user]); + expect(emitted).toEqual([null]) + }) + }) - expect(emitted).toEqual([[], [user]]); - }); + describe('setObservationFilter', () => { + it('updates observationFilter$ observable', (done) => { + service.setEvent(mockEvent as any) - it('emits current forms when setForms is called', () => { - const emitted: any[] = []; - service.forms$.subscribe((forms) => emitted.push(forms)); + service.observationFilter$.subscribe(f => { + if (f?.hasAttachments === mockObsFilter.hasAttachments && f.timeInterval === mockObsFilter.timeInterval) { + done() + } + }) + service.setObservationFilter(mockObsFilter) + }) - const form: any = { id: 1, name: 'Form 1' }; - service.setForms([form]); + it('persists to localStorage with current event id', () => { + service.setEvent(mockEvent as any) + service.setObservationFilter(mockObsFilter) - expect(emitted).toEqual([[], [form]]); - }); - }); + expect(localStorage.setObservationFilter).toHaveBeenCalledWith( + mockEvent.id, + jasmine.objectContaining({ hasAttachments: mockObsFilter.hasAttachments, timeInterval: mockObsFilter.timeInterval }) + ) + }) - describe('interval$', () => { + it('does not call localStorage when no event is set', () => { + service.setObservationFilter(mockObsFilter) - it('emits when the interval actually changes', () => { - const emitted: any[] = []; - service.interval$.subscribe((interval) => emitted.push(interval)); + expect(localStorage.setObservationFilter).not.toHaveBeenCalled() + }) - const choice = { filter: 86400, label: 'Last 24 Hours' }; - service.setTimeInterval({ choice }); + it('preserves existing keyword when updating other filter state', () => { + service.setEvent(mockEvent as any) + service.setObservationKeyword('storm') - expect(emitted.length).toBe(2); - expect(emitted[1].choice).toEqual(choice); - }); + service.setObservationFilter({ ...DEFAULT_OBSERVATION_FILTER, hasAttachments: true }) - it('does not emit again for the same choice', () => { - const choice = service.getIntervalChoice(); + expect(service.getObservationFilter()?.fieldFilter?.keyword).toBe('storm') + }) - const emitted: any[] = []; - service.interval$.subscribe((interval) => emitted.push(interval)); + it('merges passed condition into fieldFilter alongside existing keyword', () => { + const condition: BinaryCondition = { formId: 1, field: 'f', operator: '=', value: 'x' } + service.setEvent(mockEvent as any) + service.setObservationKeyword('storm') - service.setTimeInterval({ choice }); + service.setObservationFilter({ ...DEFAULT_OBSERVATION_FILTER }, condition) - expect(emitted.length).toBe(1); - }); - }); + expect(service.getObservationFilter()?.fieldFilter).toEqual({ keyword: 'storm', condition }) + }) - describe('actionFilter$', () => { + it('sets fieldFilter to null when no keyword or condition', () => { + service.setEvent(mockEvent as any) + service.setObservationFilter({ ...DEFAULT_OBSERVATION_FILTER }) - it('emits when setFilter is called with an actionFilter', () => { - const emitted: string[] = []; - service.actionFilter$.subscribe((actionFilter) => emitted.push(actionFilter)); + expect(service.getObservationFilter()?.fieldFilter).toBeNull() + }) + }) - service.setFilter({ actionFilter: 'important' }); + describe('setObservationKeyword', () => { + it('sets keyword in fieldFilter', () => { + service.setEvent(mockEvent as any) + service.setObservationKeyword('flood') - expect(emitted).toEqual(['', 'important']); - }); - }); + expect(service.getObservationFilter()?.fieldFilter?.keyword).toBe('flood') + }) - describe('setFilter', () => { + it('preserves existing condition when setting keyword', () => { + const condition: BinaryCondition = { formId: 1, field: 'f', operator: '=', value: 'x' } + service.setEvent(mockEvent as any) + service.setObservationFilter({ ...DEFAULT_OBSERVATION_FILTER }, condition) - it('sets event, teams, users, and forms together', () => { - const event: any = { id: 1, name: 'Event 1', teams: [{ id: 5, name: 'Team 5' }] }; - const team: any = { id: 5, name: 'Team 5' }; - const user: any = { id: 'u1', displayName: 'User 1' }; - const form: any = { id: 1, name: 'Form 1' }; + service.setObservationKeyword('flood') - service.setFilter({ event, teams: [team], users: [user], forms: [form] }); + expect(service.getObservationFilter()?.fieldFilter).toEqual({ keyword: 'flood', condition }) + }) - expect(service.getEvent()).toEqual(event); - expect(service.getTeams()).toEqual([team]); - expect(service.getUsers()).toEqual([user]); - expect(service.getForms()).toEqual([form]); - }); + it('preserves other filter state when setting keyword', () => { + service.setEvent(mockEvent as any) + service.setObservationFilter({ ...DEFAULT_OBSERVATION_FILTER, hasAttachments: true }) - it('resets teams to the previously saved selection when event changes without an explicit teams filter', () => { - localStorageService.getTeams.and.returnValue([5]); - const team: any = { id: 5, name: 'Team 5' }; - const event: any = { id: 1, name: 'Event 1', teams: [team] }; + service.setObservationKeyword('flood') - service.setFilter({ event }); + expect(service.getObservationFilter()?.hasAttachments).toBe(true) + }) - expect(service.getTeams()).toEqual([team]); - }); - }); -}); + it('clears fieldFilter to null when keyword is undefined and no condition exists', () => { + service.setEvent(mockEvent as any) + service.setObservationKeyword('flood') + + service.setObservationKeyword(undefined) + + expect(service.getObservationFilter()?.fieldFilter).toBeNull() + }) + + it('preserves condition when clearing keyword', () => { + const condition: BinaryCondition = { formId: 1, field: 'f', operator: '=', value: 'x' } + service.setEvent(mockEvent as any) + service.setObservationFilter({ ...DEFAULT_OBSERVATION_FILTER }, condition) + service.setObservationKeyword('flood') + + service.setObservationKeyword(undefined) + + expect(service.getObservationFilter()?.fieldFilter?.condition).toEqual(condition) + expect(service.getObservationFilter()?.fieldFilter?.keyword).toBeUndefined() + }) + }) + + describe('setLocationFilter', () => { + it('updates locationFilter$ observable', (done) => { + service.setEvent(mockEvent as any) + + service.locationFilter$.subscribe(f => { + if (f === mockLocFilter) { + done() + } + }) + service.setLocationFilter(mockLocFilter) + }) + + it('persists to localStorage with current event id', () => { + service.setEvent(mockEvent as any) + service.setLocationFilter(mockLocFilter) + + expect(localStorage.setLocationFilter).toHaveBeenCalledWith(mockEvent.id, mockLocFilter) + }) + + it('does not call localStorage when no event is set', () => { + service.setLocationFilter(mockLocFilter) + + expect(localStorage.setLocationFilter).not.toHaveBeenCalled() + }) + }) + + describe('getEffectiveTimeInterval', () => { + it('returns empty object for "all"', () => { + const result = service.getEffectiveTimeInterval({ choice: { filter: 'all', label: 'All' } }) + expect(result).toEqual({}) + }) + + it('returns start/end of today for "today"', () => { + const result = service.getEffectiveTimeInterval({ choice: { filter: 'today', label: 'Today' } }) + + expect(result.start).toBeDefined() + expect(result.end).toBeDefined() + expect(result.start.getHours()).toBe(0) + expect(result.start.getMinutes()).toBe(0) + expect(result.end.getHours()).toBe(23) + expect(result.end.getMinutes()).toBe(59) + }) + + it('returns start/end for numeric seconds', () => { + const before = new Date() + const result = service.getEffectiveTimeInterval({ choice: { filter: 3600, label: 'Last Hour' } }) + const after = new Date() + + expect(result.start).toBeDefined() + expect(result.end).toBeDefined() + expect(result.end.getTime()).toBeGreaterThanOrEqual(before.getTime()) + expect(result.end.getTime()).toBeLessThanOrEqual(after.getTime()) + const diffSeconds = (result.end.getTime() - result.start.getTime()) / 1000 + expect(diffSeconds).toBeCloseTo(3600, -1) + }) + + it('returns provided dates for "custom"', () => { + const startDate = new Date('2024-01-01T00:00:00Z') + const endDate = new Date('2024-01-31T23:59:59Z') + + const result = service.getEffectiveTimeInterval({ + choice: { filter: 'custom', label: 'Custom' }, + options: { startDate, endDate } + }) + + expect(result.start).toEqual(startDate) + expect(result.end).toEqual(endDate) + }) + + it('returns undefined start/end for "custom" with no options', () => { + const result = service.getEffectiveTimeInterval({ choice: { filter: 'custom', label: 'Custom' } }) + expect(result.start).toBeUndefined() + expect(result.end).toBeUndefined() + }) + + it('defaults to "today" when timeInterval is undefined', () => { + const result = service.getEffectiveTimeInterval(undefined) + + expect(result.start).toBeDefined() + expect(result.end).toBeDefined() + expect(result.start.getHours()).toBe(0) + }) + }) +}) diff --git a/web-app/src/app/filter/filter.service.ts b/web-app/src/app/filter/filter.service.ts index f05abbd8a..72da183b6 100644 --- a/web-app/src/app/filter/filter.service.ts +++ b/web-app/src/app/filter/filter.service.ts @@ -1,423 +1,128 @@ import { Injectable } from "@angular/core"; import { BehaviorSubject, Observable } from "rxjs"; -import { UserService } from "../user/user.service"; import { LocalStorageService } from "../http/local-storage.service"; import moment from 'moment'; -import * as _ from "lodash"; -import { User } from "@ngageoint/mage.web-core-lib/user"; import { + DEFAULT_LOCATION_FILTER, + DEFAULT_OBSERVATION_FILTER, + EventLocationFilter, + EventObservationFilter, Interval, - FilterChoice, - INTERVAL_CHOICES, - Filter, - SearchInterval, } from "./filter.types"; -import { MageEvent, Form } from "../entities/event/entities.event"; -import { Team, TeamById } from "../entities/team/entities.team"; -import { Observation, FormProperties } from "../entities/observation/entities.observation"; -import { filterChanges } from "../event/event.types"; -import { SessionService } from "mage-web-app/http/session.service"; +import { Condition } from "../entities/observation/filter/entities.observation.filter"; +import { MageEvent } from "../entities/event/entities.event"; +import { MemberFilterSelection } from "../event/event-member-filter.component"; @Injectable({ providedIn: "root", }) - export class FilterService { - event: MageEvent = null; - teamsById: TeamById = {}; - users: User[] = []; - forms: Form[] = []; - - interval: Interval = {}; - actionFilter: string = ""; private eventSubject = new BehaviorSubject(null); - private teamsSubject = new BehaviorSubject([]); - private usersSubject = new BehaviorSubject([]); - private formsSubject = new BehaviorSubject([]); - private intervalSubject = new BehaviorSubject(this.interval); - private actionFilterSubject = new BehaviorSubject(""); + private observationFilterSubject = new BehaviorSubject(null); + private locationFilterSubject = new BehaviorSubject(null); readonly event$: Observable = this.eventSubject.asObservable(); - readonly teams$: Observable = this.teamsSubject.asObservable(); - readonly users$: Observable = this.usersSubject.asObservable(); - readonly forms$: Observable = this.formsSubject.asObservable(); - readonly interval$: Observable = this.intervalSubject.asObservable(); - readonly actionFilter$: Observable = this.actionFilterSubject.asObservable(); - - intervalChoices: FilterChoice[] = INTERVAL_CHOICES; - - constructor( - private userService: UserService, - private sessionService: SessionService, - private localStorageService: LocalStorageService - ) { - this.setTimeInterval( - localStorageService.getTimeInterval() || { - choice: this.intervalChoices[1], - } - ); - } - - /** - * Updates the Observation Filter With the Selected Items - * @param {Filter} filter Contains the Selected Filter Values - * @return {void} No Return - */ - - setFilter(filter: Filter): void { - if (filter.users) this.setUsers(filter.users); - - if (filter.forms) this.setForms(filter.forms); - - if (filter.teams) { - this.setTeams(filter.teams); - } + readonly observationFilter$: Observable = this.observationFilterSubject.asObservable(); + readonly locationFilter$: Observable = this.locationFilterSubject.asObservable(); - if (filter.event) { - this.setEvent(filter.event); + constructor(private localStorageService: LocalStorageService) { } - // if they changed the event, and didn't set teams filter - // then reset teams filter to empty array - if (!filter.teams) { - let oldTeamIds = this.localStorageService.getTeams() || []; - const teams = []; - for (let i = 0; i < filter.event.teams.length; i++) { - if (oldTeamIds.indexOf(this.event.teams[i].id) != -1) { - teams.push(this.event.teams[i]); - } - } - this.setTeams(teams); - } - } - - if (filter.actionFilter) { - this.actionFilter = filter.actionFilter; - this.actionFilterSubject.next(this.actionFilter); - } - - if (filter.timeInterval) { - this.setTimeInterval(filter.timeInterval); - } + getSavedEventId(): number | string | null { + return this.localStorageService.getEventId(); } - removeFilters() { - if (this.event) { - this.event = null; - this.eventSubject.next(this.event); - } - } - - /** - * Updates the Event Selected - * @param {Event} newEvent the new Event Selection - * @return {filterChanges} a List of events added/removed from the list - */ - - setEvent(newEvent: MageEvent): filterChanges { - if (!newEvent && this.event) { - const removed = [this.event]; - this.event = null; - this.eventSubject.next(this.event); - - return { - added: [], - removed: removed, - }; - } else if ((newEvent && !this.event) || this.event.id !== newEvent.id) { - const added = [newEvent]; - const removed = this.event ? [this.event] : []; + setEvent(event: MageEvent | null): void { + if (event?.id === this.eventSubject.value?.id) return; + this.eventSubject.next(event); + this.localStorageService.setEventId(event?.id ?? null); - this.userService.addRecentEvent(newEvent).subscribe({ - error: (e) => console.error("Error adding recent event", e), - }); - - this.event = newEvent; - this.eventSubject.next(this.event); - - return { - added: added, - removed: removed, - }; + if (event) { + const observationFilter = { ...DEFAULT_OBSERVATION_FILTER, ...this.localStorageService.getObservationFilter(event.id) }; + const locationFilter = { ...DEFAULT_LOCATION_FILTER, ...this.localStorageService.getLocationFilter(event.id) }; + if (observationFilter.memberFilter) observationFilter.memberFilter = this.pruneStaleTeamIds(observationFilter.memberFilter, event); + if (locationFilter.memberFilter) locationFilter.memberFilter = this.pruneStaleTeamIds(locationFilter.memberFilter, event); + this.applyObservationFilter(observationFilter); + this.locationFilterSubject.next(locationFilter); } else { - return null; + this.observationFilterSubject.next(null); + this.locationFilterSubject.next(null); } } - getEvent(): MageEvent { - return this.event; - } - - /** - * Updates the List of Users in the Selected Event - * @param {User[]} newUser the new List of Users that are in the events - * @return {filterChanges} a List of users added and removed from the list - */ - - setUsers(newUsers: User[]): filterChanges { - const added = []; - const removed = []; - - newUsers.forEach((user: User) => { - if (this.users.findIndex((u) => u.id === user.id) < 0) added.push(user); - }); - - this.users.forEach((user: User) => { - if (newUsers.findIndex((u) => u.id === user.id) < 0) removed.push(user); + destroy(): void { + this.eventSubject.next(null); + this.observationFilterSubject.next(null); + this.locationFilterSubject.next(null); + } + + // Drops team ids that are no longer on the event so a saved filter doesn't silently + // keep scoping results to a team that was removed + private pruneStaleTeamIds( + memberFilter: MemberFilterSelection | null | undefined, + event: MageEvent + ): MemberFilterSelection | null { + if (!memberFilter?.teamIds?.length) return memberFilter ?? null; + const eventTeamIds = new Set((event.teams ?? []).map(team => team.id)); + const teamIds = memberFilter.teamIds.filter(id => eventTeamIds.has(id)); + if (teamIds.length === memberFilter.teamIds.length) return memberFilter; + return teamIds.length || memberFilter.userIds.length ? { ...memberFilter, teamIds } : null; + } + + setObservationFilter( + base: Omit, + condition?: Condition + ): void { + const keyword = this.observationFilterSubject.value?.fieldFilter?.keyword; + this.applyObservationFilter({ + ...base, + fieldFilter: keyword || condition ? { keyword, condition } : null }); - - this.users = newUsers; - this.localStorageService.setUsers(this.users); - this.usersSubject.next(this.users); - - return { - added: added, - removed: removed, - }; - } - - /** - * Updates the List of Forms in the Selected Event - * @param {Form[]} newForms the new List of Forms that are in the events - * @return {filterChanges} a List of forms added and removed from the list - */ - - setForms(newForms: Form[]): filterChanges { - const added = []; - const removed = []; - - newForms.forEach((form: Form) => { - if (this.forms.findIndex((f) => f.id === form.id) < 0) added.push(form); - }); - - this.forms.forEach((form: Form) => { - if (newForms.findIndex((f) => f.id === form.id) < 0) removed.push(form); - }); - - this.forms = newForms; - this.localStorageService.setForms(this.forms); - this.formsSubject.next(this.forms); - - return { - added: added, - removed: removed, - }; } - /** - * Updates the List of Teams in the Selected Event - * @param {Team[]} newTeams the new List of Teams that are in the events - * @return {filterChanges} a List of teams added and removed from the list - */ - - setTeams(newTeams: Team[]): filterChanges { - const added = []; - const removed = []; - - newTeams.forEach((team: Team) => { - if (!this.teamsById[team.id]) { - added.push(team); - } + setObservationKeyword(keyword: string | undefined): void { + const current = this.observationFilterSubject.value ?? DEFAULT_OBSERVATION_FILTER; + const condition = current.fieldFilter?.condition; + this.applyObservationFilter({ + ...current, + fieldFilter: keyword || condition ? { keyword, condition } : null }); - - let newTeamsById = _.keyBy(newTeams, "id"); - Object.values(this.teamsById).forEach((team: Team) => { - if (!newTeamsById[team.id]) { - removed.push(team); - } - }); - - this.teamsById = newTeamsById; - this.localStorageService.setTeams(Object.keys(this.teamsById)); - this.teamsSubject.next(this.getTeams()); - - return { - added: added, - removed: removed, - }; - } - - getTeams() { - return Object.values(this.teamsById); - } - - getUsers() { - return this.users; } - getForms() { - return this.forms; + private applyObservationFilter(filter: EventObservationFilter): void { + this.observationFilterSubject.next(filter); + const eventId = this.eventSubject.value?.id; + if (eventId != null) this.localStorageService.setObservationFilter(eventId, filter); } - getTeamsById() { - return this.teamsById; + setLocationFilter(update: EventLocationFilter): void { + this.locationFilterSubject.next(update); + const eventId = this.eventSubject.value?.id; + if (eventId != null) this.localStorageService.setLocationFilter(eventId, update); } - getIntervalChoice() { - return this.interval.choice; + getEvent(): MageEvent | null { + return this.eventSubject.value; } - getInterval() { - return this.interval; + getObservationFilter(): EventObservationFilter | null { + return this.observationFilterSubject.value; } - /** - * Sets the Time Interval if a change was made - * @param {Interval} newInterval Contains the Selected Interval Type and Custom Information - * @return {boolean} If a change was made, returns True, else False - */ - - setTimeInterval(newInterval: Interval): boolean { - if (newInterval.choice.filter === "custom") { - if ( - this.interval.options?.startDate && - newInterval.options.startDate === this.interval.options?.startDate && - this.interval.options?.endDate && - newInterval.options.endDate === this.interval.options?.endDate - ) { - return false; - } - } else if (this.interval.choice === newInterval.choice) { - return false; - } - this.localStorageService.setTimeInterval(newInterval); - this.interval = newInterval; - this.intervalSubject.next(this.interval); - return true; + getLocationFilter(): EventLocationFilter | null { + return this.locationFilterSubject.value; } - /** - * Checks to see if the ID is within the list of filters Users - * @param {Observation} o The Current Observation - * @return {boolean} Returns True if Observation is Allowed, or False if Not - */ - - observationInFilter(o: Observation): boolean { - if (!this.isObservationInTimeFilter(o)) return false; - - if (!this.isUserInTeamFilter(o.userId)) return false; - - if (this.forms.length > 0 && !this.hasFormInList(o.properties.forms)) - return false; - - if (this.users.length > 0 && !this.isUserInList(o.userId)) return false; - - // remove observations that are not part of action filter - if (this.actionFilter === "important" && !o.important) return false; - - if ( - this.actionFilter === "favorite" && - !o.favoriteUserIds.includes(this.sessionService.user.id) - ) - return false; - - if (this.actionFilter === "attachments" && !o.attachments.length) - return false; - - return true; - } - - /** - * Checks to see if the ID is within the list of filters Users - * @param {string} observationUserId Observations User ID - * @return {boolean} Returns True if Observation is Allowed, or False if Not - */ - - isUserInList(observationUserId?: string): boolean { - if (this.users.length <= 0) return true; - return this.users.findIndex((u) => u.id === observationUserId) >= 0; - } - - /** - * Checks Incoming Observation to see if it has Forms within the Filter List - * @param {FormProperties[]} observationForms The Current Observations Forms - * @return {boolean} Returns True if Observation is Allowed, or False if Not - */ - - hasFormInList(observationForms: FormProperties[]): boolean { - if (this.forms.length <= 0) return true; - const intersection = this.forms - .map((x) => x.id) - .filter((fID) => observationForms.map((y) => y.formId).includes(fID)); - return intersection.length > 0; - } - - /** - * Checks Incoming Observation to Make Sure it is Within the Time Constraints - * @param {Observation} o The Current Observation - * @return {boolean} Returns True if Observation is Allowed, or False if Not - */ - - isObservationInTimeFilter(o: Observation): boolean { - const time: SearchInterval = this.formatInterval(this.interval); - if (time) { - const properties = o.properties; - if (time?.start && time?.end) { - return moment(properties.timestamp).isBetween(time.start, time.end); - } else if (time?.start) { - return moment(properties.timestamp).isAfter(time.start); - } else if (time?.end) { - return moment(properties.timestamp).isBefore(time.start); - } - } - return true; - } - - /** - * Checks to see if the declared username is filtered Team selections - * @param {string} userId the unique ID of the User who Created the Observation - * @return {boolean} Returns True if Observation is Allowed, or False if Not - */ - - isUserInTeamFilter(userId?: string): boolean { - if (Object.keys(this.teamsById).length === 0) return true; - return Object.values(this.teamsById).some((team: Team) => - team.userIds.includes(userId) - ); - } - - /** - * Calculates and Returns the Start and End Time for Selected Interval - * @param {Interval} interval Contains the Selected Interval Type and Custom Information - * @return {SearchInterval} A Start and End Value to Compare DateTimes to - */ - - formatInterval(interval: Interval): SearchInterval { - if (!interval) return null; - const choice = interval.choice; - const options = interval.options; - let start: string = null; - let end: string = null; - - if (choice.filter === "all") { - return null; - } else if (choice.filter === "today") { - start = moment().startOf("day").toISOString(); - end = moment().endOf("day").toISOString(); - } else if (choice.filter === "custom") { - let startDate = moment(options.startDate); - if (startDate) { - startDate = options.localTime ? startDate.utc() : startDate; - start = startDate.utc().toISOString(); - } - let endDate = moment(options.endDate); - if (endDate) { - endDate = options.localTime ? endDate.utc() : endDate; - end = endDate.utc().toISOString(); - } + getEffectiveTimeInterval(timeInterval?: Interval): { start?: Date, end?: Date } { + const choice = timeInterval?.choice?.filter ?? 'today'; + if (choice === 'all') return {}; + if (choice === 'today') { + return { start: moment().startOf('day').toDate(), end: moment().endOf('day').toDate() }; + } else if (choice === 'custom') { + return { start: timeInterval?.options?.startDate, end: timeInterval?.options?.endDate }; } else { - start = moment() - .utc() - .subtract( - typeof choice.filter === "string" - ? parseInt(choice.filter) - : choice.filter, - "seconds" - ) - .toISOString(); - end = moment().toISOString(); + return { start: moment().subtract(Number(choice), 'seconds').toDate(), end: new Date() }; } - - return { start: start, end: end }; } + } diff --git a/web-app/src/app/filter/filter.types.ts b/web-app/src/app/filter/filter.types.ts index 0dc8f1f0e..145882e79 100644 --- a/web-app/src/app/filter/filter.types.ts +++ b/web-app/src/app/filter/filter.types.ts @@ -1,7 +1,5 @@ -import { User } from "@ngageoint/mage.web-core-lib/user" -import { filterChanges } from "../event/event.types" -import { MageEvent, Form } from "../entities/event/entities.event" -import { Team } from "../entities/team/entities.team" +import { MemberFilterSelection } from "../event/event-member-filter.component" +import { ObservationFieldFilter } from "../entities/observation/filter/entities.observation.filter" export type FilterChoice = { filter: string | number @@ -32,32 +30,33 @@ export type IntervalOptions = { localTime?: Boolean } -export type SearchInterval = { - start: string - end: string -} - export type Interval = { choice?: FilterChoice options?: IntervalOptions } -export type Filter = { - event?: MageEvent - teams?: Team[] - users?: User[] - forms?: Form[] - intervalChoice?: FilterChoice - timeInterval?: Interval - actionFilter?: string -} - -export type Changes = { - event?: filterChanges - teams?: filterChanges - users?: filterChanges - forms?: filterChanges - timeInterval?: Interval - actionFilter?: string - intervalChoice?: FilterChoice +export type EventObservationFilter = { + timeInterval: Interval + memberFilter?: MemberFilterSelection | null + hasAttachments?: boolean + isUserFavorite?: boolean + isFlaggedImportant?: boolean + fieldFilter?: ObservationFieldFilter | null +} + +export type EventLocationFilter = { + timeInterval: Interval + memberFilter?: MemberFilterSelection | null +} + +const DEFAULT_TIME_INTERVAL: Interval = { + choice: INTERVAL_CHOICES[1] +} + +export const DEFAULT_OBSERVATION_FILTER: EventObservationFilter = { + timeInterval: DEFAULT_TIME_INTERVAL +} + +export const DEFAULT_LOCATION_FILTER: EventLocationFilter = { + timeInterval: DEFAULT_TIME_INTERVAL } diff --git a/web-app/src/app/home/home.component.scss b/web-app/src/app/home/home.component.scss index 9e2e0353d..d38375a7b 100644 --- a/web-app/src/app/home/home.component.scss +++ b/web-app/src/app/home/home.component.scss @@ -23,8 +23,9 @@ mat-sidenav-container { } .feed { - background: #e1e2e1; + background: var(--mat-sys-surface-container); order: 1; + width: 100%; max-width: 100%; height: 100%; display: flex; @@ -36,5 +37,6 @@ mat-sidenav-container { .feeds-nav { --mat-sidenav-container-width: 500px; --mat-sidenav-container-shape: 0; + --mat-sidenav-container-background-color: var(--mat-sys-surface-container); max-width: 100%; } \ No newline at end of file diff --git a/web-app/src/app/home/home.module.ts b/web-app/src/app/home/home.module.ts index f2782b304..bb3f16eae 100644 --- a/web-app/src/app/home/home.module.ts +++ b/web-app/src/app/home/home.module.ts @@ -65,7 +65,10 @@ import { ExportListItemComponent } from '../export/export-list/export-list-item. import { ExportViewComponent } from '../export/export-view/export-view.component'; import { ExportEmptyComponent } from '../export/export-empty/export-empty.component'; import { ObservationFieldFilterComponent } from '../observation/observation-filter/observation-field-filter.component'; +import { ObservationFilterDialogComponent } from '../observation/observation-filter/observation-filter.component'; import { EventMemberFilterComponent } from '../event/event-member-filter.component'; +import { LocationFilterDialogComponent } from '../user/location/location-filter.component'; +import { SearchBarComponent } from '../search-bar/search-bar.component'; import { MapClipModule } from '../map/clip/map-clip.module'; import { GeometryModule } from '../geometry/geometry.module'; @@ -118,7 +121,6 @@ import { ObservationViewPasswordComponent } from '../observation/observation-vie import { PasswordPipe } from '../observation/observation-view/observation-view-password/password.pipe'; import { ContactDialogComponent } from '../contact/contact-dialog.component'; import { NavigationComponent } from '../navigation/navigation.component'; -import { FilterComponent } from '../filter/filter.component'; import { PreferencesComponent } from '../preferences/preferences.component'; import { PollingIntervalComponent } from '../preferences/polling-interval/polling-interval.component'; import { TimeFormatComponent } from '../preferences/time-format/time-format.component'; @@ -131,7 +133,6 @@ import { UserResolver } from '../ingress/user.resolver'; import { HomeGuard } from '../ingress/home-guard.service'; import { UserAvatarModule } from '../user/user-avatar/user-avatar.module'; import { IngressModule } from '../ingress/ingress.module'; -import { FilterControlComponent } from '../map/controls/filter.component'; import { MatMenuModule as MatMenuModule } from '@angular/material/menu'; import { MatDividerModule } from '@angular/material/divider'; @@ -204,11 +205,9 @@ class AngularModule { } AddObservationComponent, ContactDialogComponent, CoordinateSystemComponent, - DatetimePickerComponent, DMSValidatorDirective, ExportComponent, ExportCreateComponent, - ObservationFieldFilterComponent, ExportEmptyComponent, ExportListComponent, ExportListItemComponent, @@ -216,8 +215,6 @@ class AngularModule { } FeedItemComponent, FeedItemMapPopupComponent, FeedListComponent, - FilterComponent, - FilterControlComponent, HomeComponent, LayerContentComponent, LayerHeaderComponent, @@ -225,7 +222,6 @@ class AngularModule { } LayersControlComponent, LocationComponent, MapComponent, - MapComponent, MGRSValidatorDirective, NavigationComponent, @@ -266,7 +262,12 @@ class AngularModule { } imports: [ AngularModule, ColorPickerModule, + DatetimePickerComponent, EventMemberFilterComponent, + LocationFilterDialogComponent, + ObservationFieldFilterComponent, + ObservationFilterDialogComponent, + SearchBarComponent, FeedIconModule, FeedItemSummaryModule, GeometryModule, diff --git a/web-app/src/app/http/local-storage.service.spec.ts b/web-app/src/app/http/local-storage.service.spec.ts index 20abc6c27..fd00ab5ac 100644 --- a/web-app/src/app/http/local-storage.service.spec.ts +++ b/web-app/src/app/http/local-storage.service.spec.ts @@ -1,22 +1,236 @@ import { TestBed } from '@angular/core/testing'; import { LocalStorageService } from './local-storage.service'; -import { provideHttpClientTesting } from '@angular/common/http/testing'; -import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; +import { EventObservationFilter, EventLocationFilter, DEFAULT_OBSERVATION_FILTER, DEFAULT_LOCATION_FILTER } from '../filter/filter.types'; -describe('Local Storage Service Tests', () => { +describe('LocalStorageService', () => { + let service: LocalStorageService; beforeEach(() => { TestBed.configureTestingModule({ - imports: [], - providers: [LocalStorageService, provideHttpClient(withInterceptorsFromDi()), provideHttpClientTesting()] -}); + providers: [LocalStorageService] + }); + service = TestBed.inject(LocalStorageService); + localStorage.clear(); }); afterEach(() => { + localStorage.clear(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('event id', () => { + it('returns null when nothing is saved', () => { + expect(service.getEventId()).toBeNull(); + }); + + it('round-trips a numeric event id', () => { + service.setEventId(1); + expect(service.getEventId()).toBe(1); + }); + + it('round-trips a string event id', () => { + service.setEventId('event-1'); + expect(service.getEventId()).toBe('event-1'); + }); + + it('removes the saved id when set to null', () => { + service.setEventId(1); + service.setEventId(null); + expect(service.getEventId()).toBeNull(); + }); + }); + + describe('observation filter', () => { + const filter: EventObservationFilter = { ...DEFAULT_OBSERVATION_FILTER, hasAttachments: true }; + + it('returns null when nothing is saved for the event', () => { + expect(service.getObservationFilter(1)).toBeNull(); + }); + + it('round-trips a filter for a given event', () => { + service.setObservationFilter(1, filter); + expect(service.getObservationFilter(1)).toEqual(filter); + }); + + it('keeps filters for different events independent', () => { + service.setObservationFilter(1, filter); + service.setObservationFilter(2, { ...DEFAULT_OBSERVATION_FILTER, isUserFavorite: true }); + + expect(service.getObservationFilter(1)).toEqual(filter); + expect(service.getObservationFilter(2)?.isUserFavorite).toBeTrue(); + }); + + it('removes only the given event when set to null', () => { + service.setObservationFilter(1, filter); + service.setObservationFilter(2, filter); + + service.setObservationFilter(1, null); + + expect(service.getObservationFilter(1)).toBeNull(); + expect(service.getObservationFilter(2)).toEqual(filter); + }); + + it('revives Date objects in a custom time interval', () => { + const startDate = new Date('2024-01-01T00:00:00Z'); + const endDate = new Date('2024-01-02T00:00:00Z'); + service.setObservationFilter(1, { + ...DEFAULT_OBSERVATION_FILTER, + timeInterval: { choice: { filter: 'custom', label: 'Custom' }, options: { startDate, endDate } } + }); + + const result = service.getObservationFilter(1); + + expect(result?.timeInterval.options?.startDate instanceof Date).toBeTrue(); + expect(result?.timeInterval.options?.startDate?.getTime()).toBe(startDate.getTime()); + expect(result?.timeInterval.options?.endDate?.getTime()).toBe(endDate.getTime()); + }); + }); + + describe('location filter', () => { + const filter: EventLocationFilter = { ...DEFAULT_LOCATION_FILTER, memberFilter: { teamIds: ['t1'], userIds: [] } }; + + it('returns null when nothing is saved for the event', () => { + expect(service.getLocationFilter(1)).toBeNull(); + }); + + it('round-trips a filter for a given event', () => { + service.setLocationFilter(1, filter); + expect(service.getLocationFilter(1)).toEqual(filter); + }); + + it('removes only the given event when set to null', () => { + service.setLocationFilter(1, filter); + service.setLocationFilter(2, filter); + + service.setLocationFilter(2, null); + + expect(service.getLocationFilter(1)).toEqual(filter); + expect(service.getLocationFilter(2)).toBeNull(); + }); + + it('revives Date objects in a custom time interval', () => { + const startDate = new Date('2024-03-01T00:00:00Z'); + const endDate = new Date('2024-03-02T00:00:00Z'); + service.setLocationFilter(1, { + ...DEFAULT_LOCATION_FILTER, + timeInterval: { choice: { filter: 'custom', label: 'Custom' }, options: { startDate, endDate } } + }); + + const result = service.getLocationFilter(1); + + expect(result?.timeInterval.options?.startDate instanceof Date).toBeTrue(); + }); }); - it('should be created', () => { - const service: LocalStorageService = TestBed.inject(LocalStorageService); - expect(service).toBeTruthy(); - }); -}); \ No newline at end of file + describe('polling interval', () => { + it('returns null when nothing is saved', () => { + expect(service.getPollingInterval()).toBeNull(); + }); + + it('round-trips the interval as a number', () => { + service.setPollingInterval(30000); + expect(service.getPollingInterval()).toBe(30000); + }); + + it('returns null when the saved value is not a number', () => { + localStorage.setItem('pollingInterval', 'not-a-number'); + expect(service.getPollingInterval()).toBeNull(); + }); + }); + + describe('map position', () => { + it('returns undefined when nothing is saved', () => { + expect(service.getMapPosition()).toBeUndefined(); + }); + + it('round-trips the map position', () => { + const position = { center: [0, 0], zoom: 5 }; + service.setMapPosition(position); + expect(service.getMapPosition()).toEqual(position); + }); + }); + + describe('display preferences', () => { + it('defaults coordinate system view to wgs84', () => { + expect(service.getCoordinateSystemView()).toBe('wgs84'); + }); + + it('falls back the edit coordinate system to the view setting', () => { + service.setCoordinateSystemView('mgrs'); + expect(service.getCoordinateSystemEdit()).toBe('mgrs'); + }); + + it('uses its own edit coordinate system when set', () => { + service.setCoordinateSystemView('mgrs'); + service.setCoordinateSystemEdit('gars'); + expect(service.getCoordinateSystemEdit()).toBe('gars'); + }); + + it('defaults time zone view to local', () => { + expect(service.getTimeZoneView()).toBe('local'); + }); + + it('falls back the edit time zone to the view setting', () => { + service.setTimeZoneView('gmt'); + expect(service.getTimeZoneEdit()).toBe('gmt'); + }); + + it('defaults time format to absolute', () => { + expect(service.getTimeFormat()).toBe('absolute'); + }); + }); + + describe('values that are not valid JSON', () => { + beforeEach(() => { + spyOn(console, 'warn'); + }); + + it('treats a corrupt event id as none and warns about the key', () => { + localStorage.setItem('event', 'abc'); + + expect(service.getEventId()).toBeNull(); + expect(console.warn).toHaveBeenCalledWith(jasmine.stringContaining('"event"')); + }); + + it('treats corrupt saved observation filters as none', () => { + localStorage.setItem('observationFilters', '{bad'); + + expect(service.getObservationFilter(1)).toBeNull(); + }); + + it('treats corrupt saved location filters as none', () => { + localStorage.setItem('locationFilters', '{bad'); + + expect(service.getLocationFilter(1)).toBeNull(); + }); + + it('treats a corrupt map position as none', () => { + localStorage.setItem('mapPosition', '{bad'); + + expect(service.getMapPosition()).toBeUndefined(); + }); + + it('can save a filter over corrupt saved filters', () => { + localStorage.setItem('observationFilters', '{bad'); + const filter: EventObservationFilter = { ...DEFAULT_OBSERVATION_FILTER, hasAttachments: true }; + + service.setObservationFilter(1, filter); + + expect(service.getObservationFilter(1)).toEqual(filter); + }); + + it('treats saved filters that are valid JSON but not an object as none', () => { + localStorage.setItem('locationFilters', '5'); + expect(service.getLocationFilter(1)).toBeNull(); + + localStorage.setItem('locationFilters', '[]'); + expect(service.getLocationFilter(1)).toBeNull(); + + service.setLocationFilter(1, DEFAULT_LOCATION_FILTER); + expect(service.getLocationFilter(1)).toEqual(DEFAULT_LOCATION_FILTER); + }); + }); +}); diff --git a/web-app/src/app/http/local-storage.service.ts b/web-app/src/app/http/local-storage.service.ts index 5c10c22bc..a59df6157 100644 --- a/web-app/src/app/http/local-storage.service.ts +++ b/web-app/src/app/http/local-storage.service.ts @@ -1,92 +1,85 @@ import { Injectable } from "@angular/core"; -import { User } from "@ngageoint/mage.web-core-lib/user"; -import { Interval } from "../filter/filter.types"; -import { Form } from "../entities/event/entities.event"; +import { EventLocationFilter, EventObservationFilter, Interval } from "../filter/filter.types"; @Injectable({ providedIn: "root", }) export class LocalStorageService { pollingIntervalKey = "pollingInterval"; - timeIntervalKey = "timeInterval"; - teamsKey = "teams"; - usersKey = "users"; - formsKey = "forms"; mapPositionKey = "mapPosition"; coordinateSystemViewKey = "coordinateSystemView"; coordinateSystemEditKey = "coordinateSystemEdit"; timeZoneViewKey = "timeZoneView"; timeZoneEditKey = "timeZoneEdit"; timeFormatKey = "timeFormat"; + eventKey = "event"; + observationFiltersKey = "observationFilters"; + locationFiltersKey = "locationFilters"; - setPollingInterval(pollingInterval) { - return this.setLocalItem(this.pollingIntervalKey, pollingInterval); + getEventId(): number | string | null { + return this.parseJson(this.eventKey, this.getLocalItem(this.eventKey)); } - getPollingInterval() { - return this.getLocalItem(this.pollingIntervalKey); + setEventId(id: number | string | null) { + if (id == null) return this.removeLocalItem(this.eventKey); + return this.setLocalItem(this.eventKey, JSON.stringify(id)); } - setTimeInterval(timeInterval: Interval) { - return this.setLocalItem( - this.timeIntervalKey, - JSON.stringify(timeInterval) - ); - } - - getTimeInterval() { - const item = this.getLocalItem(this.timeIntervalKey); - if (item) { - const time = JSON.parse(item); - - if (time && time.options) { - if (time.options.startDate) { - time.options.startDate = new Date(time.options.startDate); - } - if (time.options.endDate) { - time.options.endDate = new Date(time.options.endDate); - } - } - - return time; - } else return undefined; + getObservationFilter(eventId: string | number): EventObservationFilter | null { + const map = this.readFilterMap(this.observationFiltersKey); + return this.hydrateDates(map[eventId]); } - getTeams() { - const item = this.getLocalItem(this.teamsKey); - if (item) { - return JSON.parse(item); - } else return undefined; + setObservationFilter(eventId: string | number, filter: EventObservationFilter | null): void { + const map = this.readFilterMap(this.observationFiltersKey); + if (filter == null) { + delete map[eventId]; + } else { + map[eventId] = filter; + } + this.setLocalItem(this.observationFiltersKey, JSON.stringify(map)); } - setTeams(teams: any) { - return this.setLocalItem(this.teamsKey, JSON.stringify(teams)); + getLocationFilter(eventId: string | number): EventLocationFilter | null { + const map = this.readFilterMap(this.locationFiltersKey); + return this.hydrateDates(map[eventId]); } - getUsers(): User[] { - const item = this.getLocalItem(this.usersKey); - if (item) { - return JSON.parse(item) as User[]; - } else return undefined; + setLocationFilter(eventId: string | number, filter: EventLocationFilter | null): void { + const map = this.readFilterMap(this.locationFiltersKey); + if (filter == null) { + delete map[eventId]; + } else { + map[eventId] = filter; + } + this.setLocalItem(this.locationFiltersKey, JSON.stringify(map)); } - setUsers(users: User[]) { - return this.setLocalItem(this.usersKey, JSON.stringify(users)); + private readFilterMap(key: string): Record { + const parsed = this.parseJson(key, this.getLocalItem(key)); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record : {}; } - getForms(): Form[] { - const item = this.getLocalItem(this.formsKey); - if (item) { - return JSON.parse(item) as Form[]; - } else return undefined; + private hydrateDates(filter: T | null | undefined): T | null { + if (!filter) return null; + if (filter?.timeInterval?.options) { + if (filter.timeInterval.options.startDate) { + filter.timeInterval.options.startDate = new Date(filter.timeInterval.options.startDate); + } + if (filter.timeInterval.options.endDate) { + filter.timeInterval.options.endDate = new Date(filter.timeInterval.options.endDate); + } + } + return filter; } - setForms(forms: Form[]) { - return this.setLocalItem(this.formsKey, JSON.stringify(forms)); + setPollingInterval(pollingInterval: number) { + return this.setLocalItem(this.pollingIntervalKey, String(pollingInterval)); } - removeTeams() { - return this.removeLocalItem(this.teamsKey); + getPollingInterval(): number | null { + const interval = parseInt(this.getLocalItem(this.pollingIntervalKey) ?? ''); + return Number.isNaN(interval) ? null : interval; } setMapPosition(mapPosition: any) { @@ -94,10 +87,7 @@ export class LocalStorageService { } getMapPosition() { - const item = this.getLocalItem(this.mapPositionKey); - if (item) { - return JSON.parse(item); - } else return undefined; + return this.parseJson(this.mapPositionKey, this.getLocalItem(this.mapPositionKey)) ?? undefined; } getCoordinateSystemView() { @@ -143,23 +133,34 @@ export class LocalStorageService { return this.setLocalItem(this.timeFormatKey, timeFormat); } - getLocalItem(key: any) { + private parseJson(key: string, item: string | null): T | null { + if (!item) return null; + try { + return JSON.parse(item) as T; + } catch { + console.warn(`Ignoring invalid JSON in localStorage key "${key}"`); + return null; + } + } + + getLocalItem(key: any): string | null { try { if ("localStorage" in window && window["localStorage"] !== null) { return localStorage.getItem(key); } } catch (e) { - return undefined; + console.error('Failed to get local storage item', key, e) } + return null; } setLocalItem(key: any, value: any) { try { if ("localStorage" in window && window.localStorage !== null) { - return localStorage.setItem(key, value); + localStorage.setItem(key, value); } } catch (e) { - return undefined; + console.error('Failed to set local storage item', key, e) } } diff --git a/web-app/src/app/layer/layer.service.spec.ts b/web-app/src/app/layer/layer.service.spec.ts index 920e122a8..ed97c7a83 100644 --- a/web-app/src/app/layer/layer.service.spec.ts +++ b/web-app/src/app/layer/layer.service.spec.ts @@ -1,9 +1,11 @@ import { TestBed } from '@angular/core/testing'; import { LayerService } from './layer.service'; -import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; +import { MageEvent } from '../entities/event/entities.event'; describe('Layer Service Tests', () => { + const event = { id: 7 } as MageEvent; beforeEach(() => { TestBed.configureTestingModule({ @@ -19,4 +21,28 @@ describe('Layer Service Tests', () => { const service: LayerService = TestBed.inject(LayerService); expect(service).toBeTruthy(); }); + + describe('getLayersForEvent', () => { + it('asks for available layers only by sending no includeUnavailable param', () => { + const service: LayerService = TestBed.inject(LayerService); + const http = TestBed.inject(HttpTestingController); + + service.getLayersForEvent(event).subscribe(); + + const req = http.expectOne(r => r.method === 'GET'); + expect(req.request.urlWithParams).toBe('/api/events/7/layers'); + req.flush([]); + }); + + it('sends includeUnavailable=true when unavailable layers are requested', () => { + const service: LayerService = TestBed.inject(LayerService); + const http = TestBed.inject(HttpTestingController); + + service.getLayersForEvent(event, true).subscribe(); + + const req = http.expectOne(r => r.method === 'GET'); + expect(req.request.urlWithParams).toBe('/api/events/7/layers?includeUnavailable=true'); + req.flush([]); + }); + }); }); \ No newline at end of file diff --git a/web-app/src/app/layer/layer.service.ts b/web-app/src/app/layer/layer.service.ts index ad381c4b3..1cdf9a6e9 100644 --- a/web-app/src/app/layer/layer.service.ts +++ b/web-app/src/app/layer/layer.service.ts @@ -1,7 +1,11 @@ import { HttpClient } from "@angular/common/http"; import { Injectable } from "@angular/core"; import { Observable } from "rxjs"; -import { FilterService } from "../filter/filter.service"; +import { LatLng } from "leaflet"; +import { MageEvent } from "../entities/event/entities.event"; +import { Layer, LayerId } from "../entities/layer/entities.layer"; + +export type ClosestFeature = GeoJSON.Feature & { layerId: LayerId; gp_table: string; feature_count?: number; coverage?: number }; @Injectable({ providedIn: 'root' @@ -9,24 +13,23 @@ import { FilterService } from "../filter/filter.service"; export class LayerService { constructor( - private httpClient: HttpClient, - private filterService: FilterService + private httpClient: HttpClient ) { } - getLayersForEvent(event, includeUnavailable?: any): Observable { - return this.httpClient.get(`/api/events/${event.id}/layers`, { params: { includeUnavailable } } ) + getLayersForEvent(event: MageEvent, includeUnavailable = false): Observable { + const params: Record = includeUnavailable ? { includeUnavailable: true } : {} + return this.httpClient.get(`/api/events/${event.id}/layers`, { params }) } - getClosestFeaturesForLayers(layerIds, latlng, tile): Observable { - const event = this.filterService.getEvent(); - return this.httpClient.post(`/api/events/${event.id}/features`, { layerIds: layerIds, latlng: latlng, tile: tile }) + getClosestFeaturesForLayers(event: MageEvent, layerIds: { id: LayerId, table: string }[], latlng: LatLng, tile: { z: number, x: number, y: number }): Observable { + return this.httpClient.post(`/api/events/${event.id}/features`, { layerIds: layerIds, latlng: latlng, tile: tile }) } - makeAvailable(layerId) { + makeAvailable(layerId: LayerId) { return this.httpClient.get(`/api/layers/${layerId}/available`) } - uploadGeopackage(data) { + uploadGeopackage(data: Record) { const formData = new FormData(); for (const property in data) { if (data[property] != null) { diff --git a/web-app/src/app/map/clip/clip.component.spec.ts b/web-app/src/app/map/clip/clip.component.spec.ts index 71bdb7e76..ad68efd42 100644 --- a/web-app/src/app/map/clip/clip.component.spec.ts +++ b/web-app/src/app/map/clip/clip.component.spec.ts @@ -1,27 +1,98 @@ -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TileLayer } from 'leaflet'; import { MapClipComponent } from './clip.component'; +import { MapService } from '../map.service'; +import { LocalStorageService } from '../../http/local-storage.service'; +import { RasterLayer, RenderedRasterLayer } from '../entities.map-layer'; -/* TODO test MUST include at least 1 test describe('MapClipComponent', () => { let component: MapClipComponent; let fixture: ComponentFixture; + let mapService: jasmine.SpyObj; - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - declarations: [MapClipComponent] - }) - .compileComponents(); - })); + function imagery(id: number): RasterLayer { + return { type: 'Imagery', id, name: `base${id}`, url: `http://example.com/${id}/{z}/{x}/{y}.png`, format: 'XYZ' }; + } + + function rendered(id: number): RenderedRasterLayer { + return { ...imagery(id), layer: new TileLayer('') } as RenderedRasterLayer; + } + + function tileLayerCount(): number { + let count = 0; + component.map.eachLayer(layer => { + if (layer instanceof TileLayer) count++; + }); + return count; + } beforeEach(() => { + mapService = jasmine.createSpyObj('MapService', ['addListener', 'removeListener']); + + TestBed.configureTestingModule({ + declarations: [MapClipComponent], + providers: [ + { provide: MapService, useValue: mapService }, + { provide: LocalStorageService, useValue: jasmine.createSpyObj('LocalStorageService', { getMapPosition: { center: { lat: 0, lng: 0 } } }) } + ] + }); + fixture = TestBed.createComponent(MapClipComponent); component = fixture.componentInstance; fixture.detectChanges(); }); - // it('should create', () => { - // expect(component).toBeTruthy(); - // }); + it('listens for base layer changes and stops when destroyed', () => { + expect(mapService.addListener).toHaveBeenCalledWith(component.mapListener); + + fixture.destroy(); + + expect(mapService.removeListener).toHaveBeenCalledWith(component.mapListener); + }); + + it('shows the selected base layer', () => { + component.onBaseLayerSelected(rendered(1)); + + expect(tileLayerCount()).toBe(1); + }); + + it('replaces the previous base layer when a different one is selected', () => { + component.onBaseLayerSelected(rendered(1)); + component.onBaseLayerSelected(rendered(2)); + + expect(tileLayerCount()).toBe(1); + }); + + it('ignores a missing base layer', () => { + component.onBaseLayerSelected(null); + + expect(tileLayerCount()).toBe(0); + }); + + describe('createRasterLayer', () => { + it('builds a tile layer for XYZ imagery', () => { + const layer = component.createRasterLayer(imagery(1)) as TileLayer; + + expect(layer.options.tms).toBeFalse(); + }); + + it('builds a TMS tile layer for TMS imagery', () => { + const layer = component.createRasterLayer({ ...imagery(1), format: 'TMS' }) as TileLayer; + + expect(layer.options.tms).toBeTrue(); + }); + + it('builds a WMS layer with the WMS options', () => { + const layer = component.createRasterLayer({ + ...imagery(1), + format: 'WMS', + wms: { layers: 'a,b', version: '1.3.0', format: 'image/png', transparent: true, styles: 's' } + }) as TileLayer.WMS; + + expect(layer instanceof TileLayer.WMS).toBeTrue(); + expect(layer.wmsParams.layers).toBe('a,b'); + expect(layer.wmsParams.styles).toBe('s'); + }); + }); }); -*/ diff --git a/web-app/src/app/map/clip/clip.component.ts b/web-app/src/app/map/clip/clip.component.ts index aa71fbaeb..6ddb566f8 100644 --- a/web-app/src/app/map/clip/clip.component.ts +++ b/web-app/src/app/map/clip/clip.component.ts @@ -4,6 +4,7 @@ import { Map, GeoJSON, PathOptions, Layer, control, TileLayer, WMSOptions, Circl import { MapService } from '../map.service' import { LocalStorageService } from '../../http/local-storage.service' import { fixedWidthMarker } from '../marker/FixedWidthMarker' +import { RasterLayer, RenderedRasterLayer } from '../entities.map-layer' interface FeatureWithStyle extends Feature { style?: any @@ -12,7 +13,7 @@ interface FeatureWithStyle extends Feature { export interface PointAccuracy { latlng: LatLng, radius: number, - color: string, + color?: string, zoomTo: boolean } @@ -31,7 +32,7 @@ export class MapClipComponent implements OnInit, OnChanges, OnDestroy { map: Map layer: GeoJSON accuracyLayer: Circle - layers = {} + baseLayer?: Layer zoomControl = control.zoom() mapListener = { onBaseLayerSelected: this.onBaseLayerSelected.bind(this) @@ -78,22 +79,17 @@ export class MapClipComponent implements OnInit, OnChanges, OnDestroy { this.mapService.removeListener(this.mapListener) } - onBaseLayerSelected(baseLayer): void { - let layer = this.layers[baseLayer.name] - if (layer) this.map.removeLayer(layer.layer) + onBaseLayerSelected(selected: RenderedRasterLayer | null): void { + if (!selected) return - layer = this.createRasterLayer(baseLayer) - this.layers[baseLayer.name] = { type: 'tile', layer: baseLayer, rasterLayer: layer } + if (this.baseLayer) this.map.removeLayer(this.baseLayer) - layer.addTo(this.map) + this.baseLayer = this.createRasterLayer(selected) + this.baseLayer.addTo(this.map) } - createRasterLayer(layer): Layer { - let baseLayer: Layer = null - if (layer.format === 'XYZ' || layer.format === 'TMS') { - const options = { tms: layer.format === 'TMS', maxZoom: 18 } - baseLayer = new TileLayer(layer.url, options) - } else if (layer.format === 'WMS') { + createRasterLayer(layer: RasterLayer): Layer { + if (layer.format === 'WMS') { const options: WMSOptions = { layers: layer.wms.layers, version: layer.wms.version, @@ -102,10 +98,10 @@ export class MapClipComponent implements OnInit, OnChanges, OnDestroy { } if (layer.wms.styles) options.styles = layer.wms.styles - baseLayer = new TileLayer.WMS(layer.url, options) + return new TileLayer.WMS(layer.url, options) } - return baseLayer + return new TileLayer(layer.url, { tms: layer.format === 'TMS', maxZoom: 18 }) } addFeature(): void { diff --git a/web-app/src/app/map/controls/filter.component.html b/web-app/src/app/map/controls/filter.component.html deleted file mode 100644 index a0c28ba09..000000000 --- a/web-app/src/app/map/controls/filter.component.html +++ /dev/null @@ -1,10 +0,0 @@ - \ No newline at end of file diff --git a/web-app/src/app/map/controls/filter.component.scss b/web-app/src/app/map/controls/filter.component.scss deleted file mode 100644 index 18ce62450..000000000 --- a/web-app/src/app/map/controls/filter.component.scss +++ /dev/null @@ -1,4 +0,0 @@ -.map-control-fab { - --mat-fab-small-container-color: #ffffff; - --mat-fab-small-foreground-color: var(--mat-sys-primary); -} \ No newline at end of file diff --git a/web-app/src/app/map/controls/filter.component.spec.ts b/web-app/src/app/map/controls/filter.component.spec.ts deleted file mode 100644 index 63b1fc4a8..000000000 --- a/web-app/src/app/map/controls/filter.component.spec.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; - -import { MatButtonModule as MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; -import { FilterControlComponent } from './filter.component'; -import { MatFormFieldModule as MatFormFieldModule } from '@angular/material/form-field'; -import { MatChipsModule as MatChipsModule } from '@angular/material/chips'; -import { MatSelectModule as MatSelectModule } from '@angular/material/select'; - -describe('Filter Control Component', () => { - let component: FilterControlComponent; - let fixture: ComponentFixture; - - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - imports: [ - MatIconModule, - MatButtonModule, - MatFormFieldModule, - MatChipsModule, - MatSelectModule, - ], - declarations: [FilterControlComponent] - }).compileComponents(); - })); - - beforeEach(() => { - fixture = TestBed.createComponent(FilterControlComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/web-app/src/app/map/controls/filter.component.ts b/web-app/src/app/map/controls/filter.component.ts deleted file mode 100644 index f0b55b3e3..000000000 --- a/web-app/src/app/map/controls/filter.component.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Component, EventEmitter, Output, AfterViewInit, ElementRef, ViewChild } from '@angular/core'; -import { DomEvent } from 'leaflet'; - -@Component({ - selector: 'map-control-filter', - templateUrl: './filter.component.html', - styleUrls: ['./filter.component.scss'], - standalone: false -}) -export class FilterControlComponent implements AfterViewInit { - @ViewChild('filterButton') button!: ElementRef; - - @Output() click = new EventEmitter(); - - ngAfterViewInit(): void { - if (this.button?.nativeElement) { - DomEvent.disableClickPropagation(this.button.nativeElement); - } - } - - onClick($event: MouseEvent): void { - $event.stopPropagation(); - this.click.emit(); - } -} \ No newline at end of file diff --git a/web-app/src/app/map/controls/search.component.html b/web-app/src/app/map/controls/search.component.html index 7ce1216e5..58f3f57bf 100644 --- a/web-app/src/app/map/controls/search.component.html +++ b/web-app/src/app/map/controls/search.component.html @@ -1,48 +1,35 @@ -
- - -
- - - Address / Place Name - - - - - - - - -
- - - place - {{ result.name }} - -
+
+ +
+
+ + + + place + {{result.name}} + +
diff --git a/web-app/src/app/map/controls/search.component.scss b/web-app/src/app/map/controls/search.component.scss index 8aa714952..3eb0b0528 100644 --- a/web-app/src/app/map/controls/search.component.scss +++ b/web-app/src/app/map/controls/search.component.scss @@ -2,162 +2,120 @@ display: contents; } -.search-control { +.search-wrapper { position: relative; - display: inline-flex; - flex-direction: row; - align-items: flex-start; - font-family: Roboto, 'Helvetica Neue', sans-serif; - font-size: 16px; + display: flex; + flex-direction: column; + align-items: flex-end; } -.map-control-fab { - --mat-fab-small-container-color: #ffffff; - --mat-fab-small-foreground-color: var(--mat-sys-primary); - flex: 0 0 auto; -} - -.map-control-fab.is-active { - --mat-fab-small-container-color: var(--mat-sys-primary); - --mat-fab-small-foreground-color: #ffffff; -} +// Collapsed: just the mini FAB, matching the other map controls. +// Expanded: the FAB sits inside a white pill that carries the elevation. +.search-pill { + display: flex; + align-items: center; + border-radius: var(--mat-fab-small-container-shape, var(--mat-sys-corner-medium)); + background: transparent; + transition: background-color 200ms ease-in-out, box-shadow 200ms ease-in-out, border-radius 200ms ease-in-out; -.search-field { - position: absolute; - top: -4px; - left: calc(100% + 16px); - padding: 0; - overflow: visible; - width: 360px; - max-width: min(360px, calc(100vw - 140px)); - border-radius: 0 0 8px 8px; - box-sizing: border-box; -} + &.expanded { + background: #ffffff; + box-shadow: var(--mat-sys-level3); -.search-field__form { - width: 360px; - max-width: min(360px, calc(100vw - 140px)); - margin: 0; -} + .search-toggle { + --mat-fab-small-container-color: transparent; + --mat-fab-small-container-elevation-shadow: none; + --mat-fab-small-focus-container-elevation-shadow: none; + --mat-fab-small-hover-container-elevation-shadow: none; + --mat-fab-small-pressed-container-elevation-shadow: none; + } + } -:host ::ng-deep .search-field.mat-mdc-card { - overflow: visible; + &.results { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + } } -:host ::ng-deep .search-field__form .mat-mdc-form-field-subscript-wrapper, -:host ::ng-deep .search-field__form .mat-form-field-wrapper { - display: none; -} - -:host ::ng-deep .search-field__form .mat-mdc-text-field-wrapper { - min-height: 56px; - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; - padding-left: 0; - padding-right: 0; +.map-control-fab { + --mat-fab-small-container-color: #ffffff; + --mat-fab-small-foreground-color: var(--mat-sys-primary); } -:host ::ng-deep .search-field__form .mat-mdc-form-field-flex, -:host ::ng-deep .search-field__form .mat-form-field-flex { - min-height: 56px; +.search-expand { + width: 0; + height: 40px; + overflow: hidden; + display: flex; align-items: center; -} - -:host ::ng-deep .search-field__form .mat-mdc-form-field-infix, -:host ::ng-deep .search-field__form .mat-form-field-infix { - min-height: 56px; - padding-top: 16px !important; - padding-bottom: 16px !important; - padding-left: 18px !important; - padding-right: 18px !important; -} - -:host ::ng-deep .search-field__form input.mat-mdc-input-element, -:host ::ng-deep .search-field__form .mat-input-element { - font-size: 18px; -} + transition: width 250ms ease-in-out; -:host ::ng-deep .search-field__form .mdc-floating-label, -:host ::ng-deep .search-field__form .mat-form-field-label { - font-size: 18px; + &.expanded { + width: 300px; + } } -:host ::ng-deep .search-field__form .mdc-floating-label { - top: 24px !important; - left: 4px !important; +.search-toggle { + flex-shrink: 0; } -:host ::ng-deep .search-field__form .mdc-floating-label--float-above { - transform: translateY(-26px) scale(0.75) !important; -} - -:host ::ng-deep .search-field__form button[matIconButton] { +.search-input { + flex: 1; + min-width: 0; + border: none; + outline: none; + padding: 0 0 0 20px; + background: transparent; + color: var(--mat-sys-on-surface); + font: var(--mat-sys-body-large); + + &::placeholder { + color: var(--mat-sys-on-surface-variant); + } +} + +// Fixed-size slot for the clear button / spinner, so the input keeps the same +// width (and its text doesn't shift) when a search starts or ends. +.search-action { + flex-shrink: 0; width: 40px; height: 40px; - padding: 0; - margin-right: 8px; + display: flex; + align-items: center; + justify-content: center; } -:host ::ng-deep .search-field__form button[matIconButton] .mat-icon, -:host ::ng-deep .search-field__form button[matIconButton] mat-icon { - font-size: 28px; - width: 28px; - height: 28px; - line-height: 28px; +.search-clear { + --mat-icon-button-icon-color: var(--mat-sys-on-surface-variant); } -.search-results { - width: max-content; - min-width: 360px; - max-width: min(720px, calc(100vw - 140px)); - background: #ffffff; - border-radius: 0 0 8px 8px; - overflow: hidden; +.search-results-wrapper { + position: absolute; + top: 100%; + right: 0; + // Above the sibling map controls: a mini FAB's icon is z-index 1, which would + // otherwise paint over this panel. + z-index: 2; + opacity: 1; + visibility: visible; + transition: opacity 200ms ease-in-out, visibility 200ms ease-in-out; + + &.results-hidden { + opacity: 0; + visibility: hidden; + } } -mat-list { - width: max-content; - min-width: 360px; - max-width: min(720px, calc(100vw - 140px)); - max-height: 320px; +.search-results { + width: 340px; + max-height: 400px; overflow-y: auto; - overflow-x: hidden; - padding-top: 0; + padding: 0; + border-top-left-radius: 0; + border-top-right-radius: 0; + box-shadow: var(--mat-sys-level3); } mat-list-item { cursor: pointer; - width: max-content; - min-width: 360px; - max-width: min(720px, calc(100vw - 140px)); } - -mat-list-item:hover { - background: rgba(0, 0, 0, 0.04); -} - -:host ::ng-deep mat-list-item .mdc-list-item__content { - width: max-content; - min-width: 0; - max-width: calc(min(720px, calc(100vw - 140px)) - 64px); -} - -:host ::ng-deep mat-list-item [matListItemTitle], -:host ::ng-deep mat-list-item .mdc-list-item__primary-text { - display: block; - width: max-content; - max-width: calc(min(720px, calc(100vw - 140px)) - 64px); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -:host ::ng-deep .mat-mdc-list-item-icon, -:host ::ng-deep .mat-list-icon { - opacity: 0.6; -} - -.mat-form-field .mat-spinner, -:host ::ng-deep .mat-mdc-form-field .mat-mdc-progress-spinner { - margin-right: 8px; -} \ No newline at end of file diff --git a/web-app/src/app/map/controls/search.component.spec.ts b/web-app/src/app/map/controls/search.component.spec.ts index ab4d76c06..d1c9a67c2 100644 --- a/web-app/src/app/map/controls/search.component.spec.ts +++ b/web-app/src/app/map/controls/search.component.spec.ts @@ -66,7 +66,7 @@ describe('SearchComponent', () => { }); it('should toggle search on', () => { - const button = fixture.debugElement.query(By.css('button')); + const button = fixture.debugElement.query(By.css('.search-toggle')); button.nativeElement.click(); expect(component.searchState as SearchState).toBe(SearchState.ON); @@ -75,7 +75,7 @@ describe('SearchComponent', () => { it('should toggle search off', () => { component.searchState = SearchState.ON; - const button = fixture.debugElement.query(By.css('button')); + const button = fixture.debugElement.query(By.css('.search-toggle')); button.nativeElement.click(); expect(component.searchState as SearchState).toBe(SearchState.OFF); @@ -151,7 +151,7 @@ describe('SearchComponent', () => { expect(input.value).toEqual('test'); - const clearButton = fixture.debugElement.queryAll(By.css('button'))[1]; + const clearButton = fixture.debugElement.query(By.css('.search-clear')); clearButton.nativeElement.click(); fixture.detectChanges(); diff --git a/web-app/src/app/map/controls/search.component.ts b/web-app/src/app/map/controls/search.component.ts index 3b02cd9bd..4401fd10a 100644 --- a/web-app/src/app/map/controls/search.component.ts +++ b/web-app/src/app/map/controls/search.component.ts @@ -79,18 +79,18 @@ export class SearchComponent implements AfterViewInit { search(value: string): void { this.searching = true; - this.searchService.search(this.mapSettings, value).subscribe( - (results: PlacenameSearchResult[]) => { + this.searchService.search(this.mapSettings, value).subscribe({ + next: (results: PlacenameSearchResult[]) => { this.searching = false; this.searchResults = results; }, - () => { + error: () => { this.searching = false; - this.snackBar.open('Error accessing place name server ', undefined, { + this.snackBar.open('Error accessing place name server', undefined, { duration: 2000 }); } - ); + }); } clear($event: MouseEvent, input: HTMLInputElement): void { @@ -104,7 +104,6 @@ export class SearchComponent implements AfterViewInit { } searchResultClick(result: PlacenameSearchResult): void { - this.searchToggle(); this.onSearch.emit({ result }); } } diff --git a/web-app/src/app/map/entities.map-layer.ts b/web-app/src/app/map/entities.map-layer.ts new file mode 100644 index 000000000..4c04d798d --- /dev/null +++ b/web-app/src/app/map/entities.map-layer.ts @@ -0,0 +1,82 @@ +import 'leaflet.markercluster' +import { TileLayer, GeoJSON as GeoJSONLayer, MarkerClusterGroup, Polyline, Polygon } from 'leaflet' +import { LayerId } from '../entities/layer/entities.layer' +import { LocationMarker } from './marker/LocationMarker' +import { ObservationMarker } from './marker/ObservationMarker' +import { GeoPackageRasterLayer } from './geopackage/GeoPackageLayer' +import { GARSLayer } from './layers/gars/GARSLayer' +import { MGRSLayer } from './layers/mgrs/MGRSLayer' + +export type MapFeature = GeoJSON.Feature & { id: string | number, style?: { iconUrl?: string } } +export type MapFeatureLayer = LocationMarker | ObservationMarker | Polyline | Polygon + +export type RasterLayer = { + type: 'Imagery' + id: LayerId + name: string + url: string + base?: boolean + selectedByDefault?: boolean +} & ( + | { format: 'XYZ' | 'TMS' } + | { format: 'WMS', wms: { layers: string, version: string, format: string, transparent: boolean, styles?: string } } +) + +export interface VectorLayer { + type: 'vector' + id: string + name: string + group: string + selectedByDefault?: boolean + hidden?: boolean + cluster?: boolean + iconUrl?: string + iconWidth?: number + temporal?: { property: string, colorBuckets: any[] } + renderHooks: { + popup?: ((layer: MapFeatureLayer, feature: MapFeature) => void) | { html: (feature: MapFeature) => string } + onLayer?: (layer: MapFeatureLayer, feature: MapFeature) => void + } +} + +export interface GeoPackageLayer { + type: 'GeoPackage' + /** + * unique per table, e.g. `${layerId}-${name}` + */ + id: string + /** + * the source GeoPackage layer's id - GeoPackageLayers' click-to-query-features handling looks features up by this, not by `id` + */ + layerId: LayerId + name: string + renderAs: 'tile' | 'feature' + url: string + minZoom?: number + maxZoom?: number + bbox?: number[] + selectedByDefault?: boolean +} + +export interface GridOverlay { + type: 'grid' + id: 'gars' | 'mgrs' + name: string + selectedByDefault?: boolean +} + +export type MapLayer = RasterLayer | GeoPackageLayer | VectorLayer | GridOverlay +export type MapLayerId = MapLayer['id'] + +export type RenderedMapLayer = ( + | (RasterLayer & { layer: TileLayer | TileLayer.WMS }) + | (GeoPackageLayer & { layer: GeoPackageRasterLayer }) + | (VectorLayer & { layer: GeoJSONLayer | MarkerClusterGroup, featureIdToLayer: Record }) + | (GridOverlay & { layer: GARSLayer | MGRSLayer }) +) & { + layer: { options: { pane: string } } + selected?: boolean + zIndex?: number +} + +export type RenderedRasterLayer = Extract diff --git a/web-app/src/app/map/geopackage/GeoPackageLayer.ts b/web-app/src/app/map/geopackage/GeoPackageLayer.ts index 6659d4ef5..3c2f0ac40 100644 --- a/web-app/src/app/map/geopackage/GeoPackageLayer.ts +++ b/web-app/src/app/map/geopackage/GeoPackageLayer.ts @@ -1,21 +1,21 @@ -import { TileLayer, TileLayerOptions, Util } from 'leaflet'; +import { Coords, TileLayer, TileLayerOptions, Util } from 'leaflet'; import { SimpleStyle } from '../layers/layer.service'; -export interface GeoPackageLayerOptions extends TileLayerOptions { - token: string; +export interface GeoPackageRasterLayerOptions extends TileLayerOptions { + token: string | null; layerId: number; table: any; style?: SimpleStyle } -export class GeoPackageLayer extends TileLayer { +export class GeoPackageRasterLayer extends TileLayer { layerId: number; pane: any; table: any; style?: SimpleStyle; type = 'GeoPackage'; - constructor(urlTemplate: string, options: GeoPackageLayerOptions) { + constructor(urlTemplate: string, options: GeoPackageRasterLayerOptions) { super(urlTemplate, options); this.layerId = options.layerId; @@ -24,15 +24,15 @@ export class GeoPackageLayer extends TileLayer { this.style = options.style || {}; } - getTileUrl(coords): string { - // @ts-ignore + getTileUrl(coords: Coords): string { const url = super.getTileUrl(coords); - const options = this.options as GeoPackageLayerOptions; + const options = this.options as GeoPackageRasterLayerOptions; - const params: any = { - access_token: options.token - }; + const params: any = {}; + if (options.token) { + params.access_token = options.token; + } const style = this.style || {} if (style.stroke) { @@ -50,7 +50,7 @@ export class GeoPackageLayer extends TileLayer { setStyle(style: SimpleStyle): void { if (style) { - this.style = Object.assign(this.style, style); + this.style = { ...this.style, ...style }; } else { this.style = {}; } diff --git a/web-app/src/app/map/geopackage/GeoPackageLayers.ts b/web-app/src/app/map/geopackage/GeoPackageLayers.ts index 46631b0a4..55276e7a3 100644 --- a/web-app/src/app/map/geopackage/GeoPackageLayers.ts +++ b/web-app/src/app/map/geopackage/GeoPackageLayers.ts @@ -1,15 +1,17 @@ -import { Layer, geoJSON, Map, LatLng, LeafletMouseEvent } from 'leaflet'; -import { GeoPackageLayer } from './GeoPackageLayer'; -import { Feature, Geometry } from 'geojson'; +import { Layer, LayerEvent, geoJSON, Map, LatLng, LeafletMouseEvent } from 'leaflet'; +import { GeoPackageRasterLayer } from './GeoPackageLayer'; import { SessionService } from 'src/app/http/session.service'; +import { ClosestFeature, LayerService } from '../../layer/layer.service'; +import { FilterService } from '../../filter/filter.service'; +import { GeoPackageLayer } from '../entities.map-layer'; export default class GeoPackageLayers { - visibleGeoPackageLayers: GeoPackageLayer[]; - closestLayer: Layer & { feature?: { layerId?: number; gp_table?: string } }; + visibleGeoPackageLayers: GeoPackageRasterLayer[]; + closestLayer?: Layer & { feature?: { layerId?: number; gp_table?: string } }; constructor( public map: Map, - public layerService: any, - public filterService: any, + public layerService: LayerService, + public filterService: FilterService, public sessionService: SessionService ) { this.map = map; @@ -23,39 +25,45 @@ export default class GeoPackageLayers { this.map.on('layerremove', this.mapLayerRemoved.bind(this)); } - createGeoPackageLayer(table, id, pane): Layer { - const filteredEvent = this.filterService.getEvent(); - const layer = new GeoPackageLayer('api/events/' + filteredEvent.id + '/layers/' + id + '/' + table.name + '/{z}/{x}/{y}.png', { + createGeoPackageLayer(layerInfo: GeoPackageLayer, pane: string): Layer { + const layer = new GeoPackageRasterLayer(layerInfo.url, { token: this.sessionService.getToken(), - minZoom: table.minZoom, - maxZoom: table.maxZoom, - layerId: id, + minZoom: layerInfo.minZoom, + maxZoom: layerInfo.maxZoom, + layerId: layerInfo.layerId, pane: pane, - table: table + table: { + name: layerInfo.name, + type: layerInfo.renderAs, + minZoom: layerInfo.minZoom, + maxZoom: layerInfo.maxZoom, + bbox: layerInfo.bbox + } }); return layer; } - mapLayerAdded(event): void { - if (event.layer.type === 'GeoPackage') { + mapLayerAdded(event: LayerEvent): void { + if (event.layer instanceof GeoPackageRasterLayer) { this.visibleGeoPackageLayers.push(event.layer); } } - mapLayerRemoved(event): void { - if (event.layer.type === 'GeoPackage') { + mapLayerRemoved(event: LayerEvent): void { + const removed = event.layer; + if (removed instanceof GeoPackageRasterLayer) { if ( this.closestLayer && this.closestLayer.feature && - event.layer.layerId === this.closestLayer.feature.layerId && - event.layer.table.name === this.closestLayer.feature.gp_table + removed.layerId === this.closestLayer.feature.layerId && + removed.table.name === this.closestLayer.feature.gp_table ) { this.map.removeLayer(this.closestLayer); } this.visibleGeoPackageLayers = this.visibleGeoPackageLayers.filter(layer => { - return event.layer.layerId !== layer.layerId; + return removed.layerId !== layer.layerId; }); } } @@ -94,54 +102,50 @@ export default class GeoPackageLayers { }; }); - this.layerService.getClosestFeaturesForLayers(layers, event.latlng, this.getTileFromPoint(event.latlng)).then(features => { + const mageEvent = this.filterService.getEvent() + if (!mageEvent) return + this.layerService.getClosestFeaturesForLayers(mageEvent, layers, event.latlng, this.getTileFromPoint(event.latlng)).subscribe((features: ClosestFeature[]) => { if (this.closestLayer) { this.map.removeLayer(this.closestLayer); } if (!features.length) return; - let popup; const layer = this.visibleGeoPackageLayers.find(layer => { return layer.layerId === features[0].layerId && layer.table.name === features[0].gp_table; }); if (!layer) { throw new Error(`no layer found for id ${features[0].layerId}`) } - this.closestLayer = geoJSON(features[0], { - pane: layer.pane, - onEachFeature( - feature: Feature & { gp_table: string; feature_count: number; coverage: number }, - layer - ) { - let geojsonPopupHtml = '
' + feature.gp_table + '
'; - if (feature.coverage) { - geojsonPopupHtml += 'There are ' + feature.feature_count + ' features in this area.'; - } else { - geojsonPopupHtml += ''; - for (const key in feature.properties) { - if (feature.properties.hasOwnProperty(key) && feature.properties[key] !== Object(feature.properties[key])) { - geojsonPopupHtml += - ''; - } - } - geojsonPopupHtml += '
' + - key + - '' + - feature.properties[key] + - '
'; - } - geojsonPopupHtml += '
'; - popup = layer.bindPopup(geojsonPopupHtml, { - maxHeight: 300 - }); - } - }).getLayers()[0] as Layer; - this.map.addLayer(this.closestLayer); - if (popup) { - popup.openPopup(event.latlng); - } + + const closestLayer = geoJSON(features[0], { pane: layer.pane }).getLayers()[0] + closestLayer.bindPopup(this.popupHtml(features[0]), { maxHeight: 300 }) + this.closestLayer = closestLayer + this.map.addLayer(closestLayer); + closestLayer.openPopup(event.latlng); }); } } + + private popupHtml(feature: ClosestFeature): string { + let html = '
' + feature.gp_table + '
'; + if (feature.coverage) { + html += 'There are ' + feature.feature_count + ' features in this area.'; + } else { + html += ''; + for (const key in feature.properties) { + if (feature.properties.hasOwnProperty(key) && feature.properties[key] !== Object(feature.properties[key])) { + html += + ''; + } + } + html += '
' + + key + + '' + + feature.properties[key] + + '
'; + } + html += '
'; + return html; + } } diff --git a/web-app/src/app/map/map-popup.service.ts b/web-app/src/app/map/map-popup.service.ts index 6bb919bec..44eeab082 100644 --- a/web-app/src/app/map/map-popup.service.ts +++ b/web-app/src/app/map/map-popup.service.ts @@ -1,5 +1,5 @@ import { Injectable, ComponentFactoryResolver, Injector, ApplicationRef } from '@angular/core'; -import { LeafletMouseEvent, Layer, Marker } from 'leaflet'; +import { LeafletMouseEvent, Layer } from 'leaflet'; import { UserPopupComponent } from '../user/user-popup/user-popup.component'; import { ObservationPopupComponent } from '../observation/observation-popup/observation-popup.component'; import { Feature } from 'geojson'; @@ -48,11 +48,11 @@ export class MapPopupService { .openPopup(); } - public registerFeedItem(marker: Marker, feed: Feed, item: Feature): void { + public registerFeedItem(marker: Layer, feed: Feed, item: Feature): void { marker.on('click', ($event: LeafletMouseEvent) => this.popupFeedItem($event.target, feed, item)); } - public popupFeedItem(marker: Marker, feed: Feed, item: Feature): void { + public popupFeedItem(marker: Layer, feed: Feed, item: Feature): void { const componentFactory = this.componentFactoryResolver.resolveComponentFactory(FeedItemMapPopupComponent); const component = componentFactory.create(this.injector); component.instance.item = item; diff --git a/web-app/src/app/map/map.component.html b/web-app/src/app/map/map.component.html index 27f74e886..1c7598c83 100644 --- a/web-app/src/app/map/map.component.html +++ b/web-app/src/app/map/map.component.html @@ -1,4 +1,5 @@
+
@@ -20,10 +21,6 @@
-
- - -
diff --git a/web-app/src/app/map/map.component.scss b/web-app/src/app/map/map.component.scss index b6900dc14..99a950b54 100644 --- a/web-app/src/app/map/map.component.scss +++ b/web-app/src/app/map/map.component.scss @@ -22,7 +22,7 @@ display: flex; flex-direction: column; align-items: flex-end; - justify-content: space-between; + gap: 16px; z-index: 1000; } @@ -31,18 +31,11 @@ z-index: 1000; display: flex; flex-direction: column; - justify-content: space-between; - top: 16px; + justify-content: flex-end; left: 16px; bottom: 16px; } -.map-controls-left__top { - display: flex; - flex-direction: column; - gap: 16px; -} - .map-controls-right { display: flex; flex-direction: column; diff --git a/web-app/src/app/map/map.component.spec.ts b/web-app/src/app/map/map.component.spec.ts index e6a8ac4be..b6acf2bd6 100644 --- a/web-app/src/app/map/map.component.spec.ts +++ b/web-app/src/app/map/map.component.spec.ts @@ -1,62 +1,74 @@ -// TODO need to figure out how to fix this test as it relies on -// an upgraded angularjs component - -// import 'angular'; -// import { async, ComponentFixture, TestBed } from '@angular/core/testing'; - -// import { LeafletComponent } from './leaflet.component'; -// import { LayersComponent } from './layers/layers.component'; -// import { LayersControlComponent } from './controls/layers-control.component'; -// import { MatSidenavModule, MatExpansionModule, MatIconModule, MatCheckboxModule, MatRadioModule, MatFormFieldModule, MatSliderModule, MatCardModule } from '@angular/material'; -// import { LeafletDirective } from './leaflet.upgrade.component'; -// import { LayerHeaderComponent } from './layers/layer-header.component'; -// import { LayerContentComponent } from './layers/layer-content.component'; -// import { ColorPickerComponent } from '../color-picker/color-picker.component'; -// import { FormsModule } from '@angular/forms'; -// import { CheckboardModule, HueModule, SaturationModule, AlphaModule } from 'ngx-color'; -// import { NoopAnimationsModule } from '@angular/platform-browser/animations'; - -// describe('LeafletComponent', () => { -// let component: LeafletComponent; -// let fixture: ComponentFixture; - -// beforeEach(async(() => { -// TestBed.configureTestingModule({ -// imports: [ -// NoopAnimationsModule, -// FormsModule, -// MatCardModule, -// MatFormFieldModule, -// MatIconModule, -// MatSliderModule, -// MatCheckboxModule, -// MatRadioModule, -// MatSidenavModule, -// MatExpansionModule, -// CheckboardModule, -// HueModule, -// SaturationModule, -// AlphaModule -// ], -// declarations: [ -// LeafletComponent, -// LeafletDirective, -// LayersComponent, -// LayersControlComponent, -// LayerHeaderComponent, -// LayerContentComponent, -// ColorPickerComponent] -// }) -// .compileComponents(); -// })); - -// beforeEach(() => { -// fixture = TestBed.createComponent(LeafletComponent); -// component = fixture.componentInstance; -// fixture.detectChanges(); -// }); - -// it('should create', () => { -// expect(component).toBeTruthy(); -// }); -// }); +import { TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { MapComponent } from './map.component'; +import { MapService } from './map.service'; +import { EventService } from '../event/event.service'; +import { FilterService } from '../filter/filter.service'; +import { LocalStorageService } from '../http/local-storage.service'; +import { SessionService } from '../http/session.service'; + +describe('MapComponent', () => { + let mapService: jasmine.SpyObj; + + beforeEach(() => { + mapService = jasmine.createSpyObj('MapService', ['addListener', 'removeListener', 'setDelegate']); + + TestBed.configureTestingModule({ + declarations: [MapComponent], + providers: [ + { provide: MatDialog, useValue: {} }, + { provide: MapService, useValue: mapService }, + { provide: SessionService, useValue: { getToken: () => 'token' } }, + { provide: EventService, useValue: {} }, + { provide: FilterService, useValue: { getEvent: () => null } }, + { + provide: LocalStorageService, + useValue: jasmine.createSpyObj('LocalStorageService', { + getMapPosition: { center: { lat: 0, lng: 0 }, zoom: 3 }, + setMapPosition: undefined + }) + }, + provideHttpClient(), + provideHttpClientTesting() + ], + schemas: [NO_ERRORS_SCHEMA] + }); + }); + + it('registers as the map delegate and listener once the view is ready', fakeAsync(() => { + const fixture = TestBed.createComponent(MapComponent); + fixture.detectChanges(); + tick(); + + expect(mapService.setDelegate).toHaveBeenCalledWith(fixture.componentInstance); + expect(mapService.addListener).toHaveBeenCalledWith(fixture.componentInstance); + + fixture.destroy(); + })); + + it('removes itself as a listener and removes the Leaflet map when destroyed', fakeAsync(() => { + const fixture = TestBed.createComponent(MapComponent); + fixture.detectChanges(); + tick(); + const removeMap = spyOn(fixture.componentInstance.map, 'remove').and.callThrough(); + + fixture.destroy(); + + expect(mapService.removeListener).toHaveBeenCalledWith(fixture.componentInstance); + expect(removeMap).toHaveBeenCalled(); + })); + + it('does not register as a listener when destroyed before the deferred registration runs', fakeAsync(() => { + const fixture = TestBed.createComponent(MapComponent); + fixture.detectChanges(); + + fixture.destroy(); + tick(); + + expect(mapService.setDelegate).not.toHaveBeenCalled(); + expect(mapService.addListener).not.toHaveBeenCalled(); + })); +}); diff --git a/web-app/src/app/map/map.component.ts b/web-app/src/app/map/map.component.ts index 3b7cc6aa4..5909a0701 100644 --- a/web-app/src/app/map/map.component.ts +++ b/web-app/src/app/map/map.component.ts @@ -1,17 +1,18 @@ import { AfterViewInit, Component, ElementRef, EventEmitter, OnDestroy, Output, ViewChild } from '@angular/core' import { LocationEvent, LocationState } from '../../app/map/controls/location.component' -import { ZoomDirection } from '../../app/map/controls/zoom.component' +import { SearchEvent } from '../../app/map/controls/search.component' +import { ZoomDirection, ZoomEvent as ZoomControlEvent } from '../../app/map/controls/zoom.component' import { LayerService } from '../layer/layer.service' import { MapLayerService, StyleEvent, ToggleEvent } from './layers/layer.service' import { MapService } from './map.service' import { LocalStorageService } from '../http/local-storage.service' import { EventService } from '../event/event.service' -import { map, latLng, popup, tileLayer, Icon, Util, marker, TileLayer, geoJSON, latLngBounds, LatLng, markerClusterGroup, Layer, Map, DomEvent } from "leaflet" -import { OpacityEvent, ZoomEvent } from './layers/layer.service' +import { latLng, popup, tileLayer, Icon, Util, marker, TileLayer, geoJSON, GeoJSON as GeoJSONLayer, latLngBounds, LatLng, markerClusterGroup, MarkerClusterGroup, Map, DomEvent, LocationEvent as LeafletLocationEvent } from "leaflet" +import { OpacityEvent, ZoomEvent as LayerZoomEvent } from './layers/layer.service' import { ReorderEvent } from './layers/layers.component' import { moveItemInArray } from '@angular/cdk/drag-drop' -import { locationMarker } from './marker/LocationMarker' -import { observationMarker } from './marker/ObservationMarker' +import { locationMarker, LocationMarker } from './marker/LocationMarker' +import { observationMarker, ObservationMarker } from './marker/ObservationMarker' import { COUNTRIES as countries } from './layers/static/layers' import 'leaflet-editable' import 'leaflet.markercluster' @@ -20,12 +21,11 @@ import GeoPackageLayers from './geopackage/GeoPackageLayers' import { FilterService } from '../filter/filter.service' import { GARSLayer } from './layers/gars/GARSLayer' import { MGRSLayer } from './layers/mgrs/MGRSLayer' -import { UserService } from '../user/user.service' -import { FilterComponent } from '../filter/filter.component' +import { GeoPackageLayer, GridOverlay, MapFeature, MapFeatureLayer, MapLayer, MapLayerId, RasterLayer, RenderedMapLayer, VectorLayer } from './entities.map-layer' import { MatDialog as MatDialog } from '@angular/material/dialog' -import _ from 'underscore' +import * as _ from 'lodash' import moment from 'moment'; -import { Subscription } from 'rxjs' +import { Subscription, interval } from 'rxjs' import { ContactDialogComponent } from '../contact/contact-dialog.component' import { SessionService } from 'mage-web-app/http/session.service' @@ -49,13 +49,14 @@ export class MapComponent implements OnDestroy, AfterViewInit { @Output() addObservation = new EventEmitter() - @ViewChild('map') mapElement: ElementRef - @ViewChild('mapControlsLeft') mapControlsLeft: ElementRef - @ViewChild('mapControlsRight') mapControlsRight: ElementRef + @ViewChild('map') mapElement!: ElementRef + @ViewChild('mapControlsLeft') mapControlsLeft!: ElementRef + @ViewChild('mapControlsRight') mapControlsRight!: ElementRef mapReizeObserver?: ResizeObserver + private listenerTimeout?: ReturnType - map: Map - groups: any = { + map!: Map + groups: Record = { 'base': { offset: MapComponent.BASE_PANE_Z_INDEX_OFFSET, layers: [] @@ -81,37 +82,40 @@ export class MapComponent implements OnDestroy, AfterViewInit { layers: [] } } - layers: any = {} + layers: Record = {} geoPackageLayers: any - temporalLayers: any = [] - spiderfyState: any = null + temporalLayers: Extract[] = [] currentLocation: any = null locationLayer = locationMarker([0, 0], { color: '#136AEC', radius: 16 }) locationState = LocationState.Off searchMarker: any - featurePanes = [] + featurePanes: string[] = [] BASE_LAYER_PANE = 'baseLayerPane' - listener: any - pollListener: any toggleSubscription: Subscription zoomSubscription: Subscription opacitySubscription: Subscription styleSubscription: Subscription + temporalLayerSubscription: Subscription + + private static readonly TEMPORAL_LAYER_REFRESH_INTERVAL_MS = 60000 constructor( public dialog: MatDialog, private mapService: MapService, private sessionService: SessionService, - private layerService: MapLayerService, + mapLayerService: MapLayerService, + private layerService: LayerService, private eventService: EventService, private filterService: FilterService, private localStorageService: LocalStorageService ) { - this.toggleSubscription = layerService.toggle$.subscribe(event => this.layerTogged(event)); - this.zoomSubscription = layerService.zoom$.subscribe(event => this.zoom(event)); - this.opacitySubscription = layerService.opacity$.subscribe(event => this.opacityChanged(event)); - this.styleSubscription = layerService.style$.subscribe(event => this.styleChanged(event)); + this.toggleSubscription = mapLayerService.toggle$.subscribe(event => this.layerTogged(event)); + this.zoomSubscription = mapLayerService.zoom$.subscribe(event => this.zoom(event)); + this.opacitySubscription = mapLayerService.opacity$.subscribe(event => this.opacityChanged(event)); + this.styleSubscription = mapLayerService.style$.subscribe(event => this.styleChanged(event)); + this.temporalLayerSubscription = interval(MapComponent.TEMPORAL_LAYER_REFRESH_INTERVAL_MS) + .subscribe(() => this.adjustTemporalLayers()); this.mapReizeObserver = new ResizeObserver(() => { this.map.invalidateSize({ pan: false, debounceMoveend: true }) @@ -133,7 +137,7 @@ export class MapComponent implements OnDestroy, AfterViewInit { mapPosition = this.localStorageService.getMapPosition() } - this.map = map('map', { + this.map = new Map('map', { center: mapPosition.center, zoom: mapPosition.zoom, zoomControl: false, @@ -147,20 +151,21 @@ export class MapComponent implements OnDestroy, AfterViewInit { // Spread out map panes // To easily adjust zIndex across all types of layers each feature group, // overlay map, etc, will be placed in its own map pane - this.map.createPane(this.BASE_LAYER_PANE) - this.map.getPane(this.BASE_LAYER_PANE).style.zIndex = `${100 * 100}` + const baseLayerPane = this.map.createPane(this.BASE_LAYER_PANE) + baseLayerPane.style.zIndex = `${100 * 100}` - this.map.getPane('tilePane').style.zIndex = `${200 * 100}` - this.map.getPane('overlayPane').style.zIndex = `${400 * 100}` - this.map.getPane('shadowPane').style.zIndex = `${500 * 100}` - this.map.getPane('markerPane').style.zIndex = `${600 * 100}` - this.map.getPane('tooltipPane').style.zIndex = `${700 * 100}` - this.map.getPane('popupPane').style.zIndex = `${800 * 100}` + const panes = this.map.getPanes() + panes.tilePane.style.zIndex = `${200 * 100}` + panes.overlayPane.style.zIndex = `${400 * 100}` + panes.shadowPane.style.zIndex = `${500 * 100}` + panes.markerPane.style.zIndex = `${600 * 100}` + panes.tooltipPane.style.zIndex = `${700 * 100}` + panes.popupPane.style.zIndex = `${800 * 100}` // Add in a base layer of styled GeoJSON in case the tiles do not load const FALLBACK_LAYER_PANE = 'fallbackLayerPane' - this.map.createPane(FALLBACK_LAYER_PANE) - this.map.getPane(FALLBACK_LAYER_PANE).style.zIndex = '1' + const fallbackLayerPane = this.map.createPane(FALLBACK_LAYER_PANE) + fallbackLayerPane.style.zIndex = '1' this.map.addLayer( geoJSON(countries, { @@ -183,14 +188,14 @@ export class MapComponent implements OnDestroy, AfterViewInit { console.log('LOCATION ERROR', err) }) - function saveMapPosition() { + const saveMapPosition = () => { const center = this.map.getCenter() this.localStorageService.setMapPosition({ center: latLng(Util.formatNum(center.lat), Util.formatNum(center.lng)), zoom: this.map.getZoom() }) } - this.map.on('moveend', saveMapPosition, this) + this.map.on('moveend', saveMapPosition) this.geoPackageLayers = new GeoPackageLayers( this.map, @@ -199,20 +204,16 @@ export class MapComponent implements OnDestroy, AfterViewInit { this.sessionService ) - this.pollListener = { - onPoll: this.onPoll.bind(this) - } - // Ensure that any changes made by listener callbacks are processed in a digest cycle // TODO this can be removed when the services use rxjs rather than custom change detection w/ callbacks - setTimeout(() => { + this.listenerTimeout = setTimeout(() => { this.mapService.setDelegate(this) this.mapService.addListener(this) - this.eventService.addPollListener(this.pollListener) }) } ngOnDestroy(): void { + clearTimeout(this.listenerTimeout) this.mapService.removeListener(this) this.mapReizeObserver?.unobserve(this.mapElement.nativeElement) @@ -220,6 +221,9 @@ export class MapComponent implements OnDestroy, AfterViewInit { this.zoomSubscription.unsubscribe() this.opacitySubscription.unsubscribe() this.styleSubscription.unsubscribe() + this.temporalLayerSubscription.unsubscribe() + + this.map?.remove() } saveMapPosition() { @@ -230,11 +234,6 @@ export class MapComponent implements OnDestroy, AfterViewInit { }) } - $onDestroy() { - this.mapService.removeListener(this.listener) - this.eventService.removePollListener(this.pollListener) - } - opacityChanged(event: OpacityEvent): void { const pane = this.map.getPanes()[event.layer.layer.pane] pane.style.opacity = `${event.opacity}` @@ -261,7 +260,7 @@ export class MapComponent implements OnDestroy, AfterViewInit { }) } - zoom($event: ZoomEvent): void { + zoom($event: LayerZoomEvent): void { const layer = $event.layer.layer if (layer.getBounds) { const bounds = layer.getBounds() @@ -274,7 +273,7 @@ export class MapComponent implements OnDestroy, AfterViewInit { } } - onZoom($event) { + onZoom($event: ZoomControlEvent) { if ($event.direction === ZoomDirection.IN) { this.map.zoomIn(1) } else { @@ -282,7 +281,7 @@ export class MapComponent implements OnDestroy, AfterViewInit { } } - onSearch($event) { + onSearch($event: SearchEvent) { this.onSearchClear() this.map.fitBounds( @@ -305,17 +304,6 @@ export class MapComponent implements OnDestroy, AfterViewInit { } } - onFilter() { - this.dialog.open(FilterComponent, { - height: '580px', - width: '675px' - }); - } - - onPoll() { - this.adjustTemporalLayers() - } - onAddObservation() { this.addObservation.emit({ latLng: this.map.getCenter() @@ -357,7 +345,7 @@ export class MapComponent implements OnDestroy, AfterViewInit { } } - onLocation(location) { + onLocation(location: LeafletLocationEvent) { // skip if the location has not changed if ( this.locationState === LocationState.Off || @@ -381,7 +369,7 @@ export class MapComponent implements OnDestroy, AfterViewInit { } } - onLayersChanged({ id, added = [], removed = [] }) { + onLayersChanged({ added = [] }: { added?: MapLayer[] }) { added.forEach(added => { switch (added.type) { case 'GeoPackage': @@ -390,7 +378,7 @@ export class MapComponent implements OnDestroy, AfterViewInit { case 'Imagery': this.createRasterLayer(added) break - case 'geojson': + case 'vector': this.createGeoJsonLayer(added) break case 'grid': @@ -398,32 +386,19 @@ export class MapComponent implements OnDestroy, AfterViewInit { break } }) - - removed.forEach(removed => { - const layer = this.layers[id] - if (layer) { - this.map.removeLayer(layer.layer) - delete layer.layer - delete this.layers[removed.layerId] - this.removeLayer(layer) - } - }) } // TODO move into leaflet service, this and map clip both use it - createRasterLayer(layerInfo) { + createRasterLayer(layerInfo: RasterLayer) { let paneName = this.BASE_LAYER_PANE if (!layerInfo.base) { paneName = `pane-${layerInfo.id}` this.map.createPane(paneName) } - let options: any = {} - if (layerInfo.format === 'XYZ' || layerInfo.format === 'TMS') { - options = { tms: layerInfo.format === 'TMS', maxZoom: 18, pane: paneName } - layerInfo.layer = tileLayer(layerInfo.url, options) - } else if (layerInfo.format === 'WMS') { - options = { + let layer: TileLayer | TileLayer.WMS + if (layerInfo.format === 'WMS') { + const options: any = { layers: layerInfo.wms.layers, version: layerInfo.wms.version, format: layerInfo.wms.format, @@ -432,129 +407,121 @@ export class MapComponent implements OnDestroy, AfterViewInit { } if (layerInfo.wms.styles) options.styles = layerInfo.wms.styles - layerInfo.layer = new TileLayer.WMS(layerInfo.url, options) + layer = new TileLayer.WMS(layerInfo.url, options) + } else { + const options = { tms: layerInfo.format === 'TMS', maxZoom: 18, pane: paneName } + layer = tileLayer(layerInfo.url, options) } - layerInfo.layer.pane = paneName - this.layers[layerInfo.id] = layerInfo - this.addLayer(layerInfo) + const rendered = { ...layerInfo, layer } as RenderedMapLayer + this.layers[layerInfo.id] = rendered + this.addLayer(rendered) } - createGeoPackageLayer(layerInfo) { - layerInfo.tables.forEach(table => { - const pane = `pane-${layerInfo.id}-${table.name}` - this.map.createPane(pane) - if (table.type === 'feature') { - this.featurePanes.push(pane) - } - - table.layer = this.geoPackageLayers.createGeoPackageLayer(table, layerInfo.id, pane) - this.layers[layerInfo.id + table.name] = table + createGeoPackageLayer(layerInfo: GeoPackageLayer) { + const pane = `pane-${layerInfo.id}` + this.map.createPane(pane) + if (layerInfo.renderAs === 'feature') { + this.featurePanes.push(pane) + } - this.addLayer({ - type: 'GeoPackage', - name: table.name, - table: table, - layer: table.layer - }) - }) + const layer = this.geoPackageLayers.createGeoPackageLayer(layerInfo, pane) + const rendered = { ...layerInfo, layer } as RenderedMapLayer + this.layers[layerInfo.id] = rendered + this.addLayer(rendered) } - createGeoJsonLayer(layerInfo) { + createGeoJsonLayer(layerInfo: VectorLayer) { const pane = `pane-${layerInfo.id}` this.map.createPane(pane) this.featurePanes.push(pane) - layerInfo.featureIdToLayer = {} - const geojson = this.createGeoJsonForLayer(layerInfo.geojson, layerInfo, pane) + const withFeatures = { ...layerInfo, featureIdToLayer: {} } + const geojson = this.createGeoJsonForLayer(null, withFeatures, pane) - if (layerInfo.options.cluster) { - layerInfo.layer = markerClusterGroup({ + let layer: GeoJSONLayer | MarkerClusterGroup + if (layerInfo.cluster) { + layer = markerClusterGroup({ pane: pane, clusterPane: pane }).addLayer(geojson) - - layerInfo.layer.on('spiderfied', function () { - if (this.spiderfyState) { - this.spiderfyState.layer.openPopup() - } - }) } else { - layerInfo.layer = geojson + layer = geojson } - layerInfo.layer.pane = pane - this.layers[layerInfo.id] = layerInfo + const rendered = { ...withFeatures, layer } as Extract + this.layers[layerInfo.id] = rendered - if (layerInfo.options.temporal) { - this.temporalLayers.push(layerInfo) + if (layerInfo.temporal) { + this.temporalLayers.push(rendered) } - if (!layerInfo.options.hidden) { - this.addLayer(layerInfo) + if (!layerInfo.hidden) { + this.addLayer(rendered) } } - createGeoJsonForLayer(json, layerInfo, pane, editMode?: any) { - const popup = layerInfo.options.popup - const geojson = geoJSON(json, { + createGeoJsonForLayer(json: GeoJSON.GeoJsonObject | null, layerInfo: VectorLayer & { featureIdToLayer: Record }, pane: string, editMode?: any) { + const popup = layerInfo.renderHooks.popup + const geojson = geoJSON(json ?? undefined, { pane: pane, - onEachFeature: function (feature, layer) { + onEachFeature: (feature: MapFeature, layer) => { if (popup) { - if (_.isFunction(popup.html)) { + if (_.isFunction((popup as any).html)) { const options: any = { autoPan: false, maxWidth: 400 } - if (popup.closeButton !== undefined) options.closeButton = popup.closeButton - layer.bindPopup(popup.html(feature, layer), options) + if ((popup as any).closeButton !== undefined) options.closeButton = (popup as any).closeButton + layer.bindPopup((popup as any).html(feature, layer), options) } - if (_.isFunction(popup.onOpen)) { + if (_.isFunction((popup as any).onOpen)) { layer.on('popupopen', function () { - popup.onOpen(feature) + (popup as any).onOpen(feature) }) } - if (_.isFunction(popup.onClose)) { + if (_.isFunction((popup as any).onClose)) { layer.on('popupclose', function () { - popup.onClose(feature) + (popup as any).onClose(feature) }) } } - if (layerInfo.options.onLayer) { - layerInfo.options.onLayer(layer, feature) + if (layerInfo.renderHooks.onLayer) { + layerInfo.renderHooks.onLayer(layer as any, feature) } - layerInfo.featureIdToLayer[feature.id] = layer + layerInfo.featureIdToLayer[feature.id] = layer as any }, - pointToLayer: (feature: any, latlng: LatLng) => { - let layer: Layer + pointToLayer: (geoJsonPoint: GeoJSON.Feature, latlng: LatLng) => { + const feature = geoJsonPoint as MapFeature + let layer: MapFeatureLayer - if (layerInfo.options.temporal) { + if (layerInfo.temporal) { const temporalOptions: any = { pane: pane, - accuracy: feature.properties.accuracy, - color: this.colorForFeature(feature, layerInfo.options.temporal) + accuracy: (feature.properties as any)?.accuracy, + color: this.colorForFeature(feature, layerInfo.temporal) } - if (feature.style && feature.style.iconUrl) { + if (feature.style?.iconUrl) { temporalOptions.iconUrl = feature.style.iconUrl } layer = locationMarker(latlng, temporalOptions) } else { const options: any = { pane: pane, - accuracy: feature.properties.accuracy + accuracy: (feature.properties as any)?.accuracy } - if (layerInfo.options.iconUrl) { - options.iconUrl = layerInfo.options.iconUrl - } else if (feature.style && feature.style.iconUrl) { + if (layerInfo.iconUrl) { + options.iconUrl = layerInfo.iconUrl + } else if (feature.style?.iconUrl) { options.iconUrl = feature.style.iconUrl } - if (layerInfo.options.iconWidth) { + if (layerInfo.iconWidth) { options.iconWidth = 24 } options.tooltip = editMode layer = observationMarker(latlng, options) } - if (layerInfo.options.onLayer) { - layerInfo.options.onLayer(layer, feature) + if (layerInfo.renderHooks.onLayer) { + layerInfo.renderHooks.onLayer(layer, feature) } return layer @@ -567,23 +534,17 @@ export class MapComponent implements OnDestroy, AfterViewInit { return geojson } - createGridLayer(layerInfo) { + createGridLayer(layerInfo: GridOverlay) { const pane = `pane-${layerInfo.id}` this.map.createPane(pane) - this.layers[layerInfo.id] = layerInfo - if (layerInfo.id === 'gars') { - layerInfo.layer = new GARSLayer() - layerInfo.layer.pane = pane - } else if (layerInfo.id === 'mgrs') { - layerInfo.layer = new MGRSLayer() - layerInfo.layer.pane = pane - } - - this.addLayer(layerInfo) + const layer = layerInfo.id === 'gars' ? new GARSLayer({ pane }) : new MGRSLayer({ pane }) + const rendered = { ...layerInfo, layer } as RenderedMapLayer + this.layers[layerInfo.id] = rendered + this.addLayer(rendered) } - addLayer(layerInfo: any) { + addLayer(layerInfo: RenderedMapLayer) { if (this.isSelected(layerInfo)) { layerInfo.selected = true const toggleEvent: ToggleEvent = { @@ -596,53 +557,55 @@ export class MapComponent implements OnDestroy, AfterViewInit { const groupName = this.getGroup(layerInfo) const group = this.groups[groupName] layerInfo.zIndex = group.offset + MapComponent.PANE_Z_INDEX_BUCKET_SIZE - (group.layers.length + 1) - const pane = this.map.getPanes()[layerInfo.layer.pane] - pane.style.zIndex = layerInfo.zIndex + const pane = this.map.getPanes()[layerInfo.layer.options.pane] + pane.style.zIndex = `${layerInfo.zIndex}` group.layers.push(layerInfo) } - onFeaturesChanged({ id, added = [], updated = [], removed = [] }) { - const featureLayer = this.layers[id] - const pane = featureLayer.layer.pane + onFeaturesChanged({ id, added = [], updated = [], removed = [] }: { id: string, added?: MapFeature[], updated?: MapFeature[], removed?: Pick[] }) { + const featureLayer = this.layers[id] as Extract + const layer = featureLayer.layer + const pane = layer.options.pane added.forEach(feature => { - if (featureLayer.options.cluster) { - const layer = this.createGeoJsonForLayer(feature, featureLayer, pane) - featureLayer.layer.addLayer(layer) + if (featureLayer.cluster) { + const created = this.createGeoJsonForLayer(feature, featureLayer, pane) + layer.addLayer(created) } else { - featureLayer.layer.addData(feature) + (layer as GeoJSONLayer).addData(feature) } }) updated.forEach(feature => { - const layer = featureLayer.featureIdToLayer[feature.id] - if (layer) { - featureLayer.layer.removeLayer(layer) + const existing = featureLayer.featureIdToLayer[feature.id] + if (existing) { + layer.removeLayer(existing) } - if (featureLayer.options.cluster) { - featureLayer.layer.addLayer(this.createGeoJsonForLayer(feature, featureLayer, pane)) + if (featureLayer.cluster) { + layer.addLayer(this.createGeoJsonForLayer(feature, featureLayer, pane)) } else { - featureLayer.layer.addData(feature) + (layer as GeoJSONLayer).addData(feature) } }) removed.forEach(feature => { - const layer = featureLayer.featureIdToLayer[feature.id] - if (layer) { + const existing = featureLayer.featureIdToLayer[feature.id] + if (existing) { delete featureLayer.featureIdToLayer[feature.id] - featureLayer.layer.removeLayer(layer) + layer.removeLayer(existing) } }) } - onFeatureZoom(zoom) { - const featureLayer = this.layers[zoom.id] + onFeatureZoom(zoom: { id: string, feature: Pick }) { + const featureLayer = this.layers[zoom.id] as Extract const layer = featureLayer.featureIdToLayer[zoom.feature.id] - if (!this.map.hasLayer(featureLayer.layer)) return + if (!layer || !this.map.hasLayer(featureLayer.layer)) return - if (featureLayer.options.cluster) { + if (featureLayer.cluster) { + const clusterLayer = featureLayer.layer as MarkerClusterGroup if (this.map.getZoom() < 17) { - if (layer.getBounds) { + if ('getBounds' in layer) { // Zoom and center polyline/polygon this.map.fitBounds(layer.getBounds(), { maxZoom: 17 @@ -651,20 +614,20 @@ export class MapComponent implements OnDestroy, AfterViewInit { } else { // Zoom and center point this.map.once('zoomend', () => { - featureLayer.layer.zoomToShowLayer(layer, () => { + clusterLayer.zoomToShowLayer(layer, () => { this.openPopup(layer, { zoomToLocation: false }) }) }) this.map.setView(layer.getLatLng(), 17) } } else { - if (layer.getBounds) { + if ('getBounds' in layer) { this.map.fitBounds(layer.getBounds(), { maxZoom: 17 }) this.openPopup(layer, {}) } else { - featureLayer.layer.zoomToShowLayer(layer, () => { + clusterLayer.zoomToShowLayer(layer, () => { this.openPopup(layer, { zoomToLocation: false }) }) } @@ -674,90 +637,68 @@ export class MapComponent implements OnDestroy, AfterViewInit { } } - onFeatureDeselect(deselected) { - const featureLayer = this.layers[deselected.id] + onFeatureDeselect(deselected: { id: string, feature: Pick }) { + const featureLayer = this.layers[deselected.id] as Extract const layer = featureLayer.featureIdToLayer[deselected.feature.id] - if (!this.map.hasLayer(featureLayer.layer)) return + if (!layer || !this.map.hasLayer(featureLayer.layer)) return layer.closePopup() } - onFeedRemoved(feed) { - const layerInfo = this.layers[feed.id] - if (layerInfo) { - this.removeLayer(layerInfo) + onFeedRemoved(feed: { id: string }) { + if (this.layers[feed.id]) { + this.removeLayer(feed.id) } } - onLayerRemoved(layer) { - switch (layer.type) { - case 'GeoPackage': - this.removeGeoPackage(layer) - break - default: - this.removeLayer(layer) - } + onLayerRemoved(layer: MapLayer) { + this.removeLayer(layer.id) } - removeLayer(layer) { - const layerInfo = this.layers[layer.id] + removeLayer(id: MapLayerId) { + const layerInfo = this.layers[id] if (layerInfo) { this.map.removeLayer(layerInfo.layer) - delete this.layers[layer.id] + delete this.layers[id] - Object.values(this.groups).forEach((group: any) => { + Object.values(this.groups).forEach((group) => { group.layers = group.layers.filter(groupLayer => { - return layer.layer !== groupLayer.layer; + return layerInfo.layer !== groupLayer.layer; }); }); } } - removeGeoPackage(layer) { - layer.tables.forEach(table => { - const id = layer.id + table.name - const layerInfo = this.layers[id] - if (layerInfo) { - this.map.removeLayer(table.layer) - delete this.layers[id] - this.removeLayer(layerInfo) - } - }) - } - - onHideFeed() { - this.map.invalidateSize({ pan: false }) - } - adjustTemporalLayers() { this.temporalLayers.forEach(temporalLayer => { - Object.values(temporalLayer.featureIdToLayer).forEach((layer: any) => { - const color = this.colorForFeature(layer.feature, temporalLayer.options.temporal) - layer.setColor(color) + Object.values(temporalLayer.featureIdToLayer).forEach((layer) => { + if (temporalLayer.temporal && 'feature' in layer && 'setColor' in layer) { + const color = this.colorForFeature(layer.feature as MapFeature, temporalLayer.temporal) + layer.setColor(color) + } }) }) } - openPopup(layer, options) { - options = options || {} + openPopup(layer: MapFeatureLayer, options: { zoomToLocation?: boolean } = {}) { if (options.zoomToLocation) { this.map.once('moveend', function () { layer.fire('click') }) - this.map.setView(layer.getLatLng(), options.zoomToLocation ? 17 : this.map.getZoom()) + this.map.setView((layer as LocationMarker | ObservationMarker).getLatLng(), 17) } else { layer.fire('click') } } - colorForFeature(feature, options) { - const age = Date.now() - moment(feature.properties[options.property]).valueOf() - const bucket = _.find(options.colorBuckets, function (bucket) { + colorForFeature(feature: MapFeature, options: { property: string, colorBuckets: any[] }) { + const age = Date.now() - moment((feature.properties as any)?.[options.property]).valueOf() + const bucket = _.find(options.colorBuckets, function (bucket: any) { return age > bucket.min && age <= bucket.max }) return bucket ? bucket.color : null } - createFeature(feature, delegate) { + createFeature(feature: MapFeature, delegate: { geometryChanged?: (geometry: any) => void, vertexClick?: (vertex: any) => void }) { // TODO put Observations in its own pane maybe?? // TODO pass in layer collection id 'Observations' let editor = new FeatureEditor(this.map, feature, delegate) @@ -765,26 +706,27 @@ export class MapComponent implements OnDestroy, AfterViewInit { // TODO save feature pane opacitis const featurePaneOpacities = this.featurePanes.map(pane => { const mapPane = this.map.getPanes()[pane] - return mapPane.style.opacity || 1 + return mapPane.style.opacity || "1" }) this.setPaneOpacity(this.featurePanes, 0.5) - const layer = this.layers['observations'].featureIdToLayer[feature.id] + const observations = this.layers['observations'] as Extract + const layer = observations.featureIdToLayer[feature.id] if (layer) { this.map.removeLayer(layer) } return { - update: feature => { + update: (feature: MapFeature) => { editor.stopEdit() editor = new FeatureEditor(this.map, feature, delegate) }, cancel: () => { editor.stopEdit() - if (layer) { + if (layer?.feature) { this.onFeaturesChanged({ id: 'observations', - updated: [layer.feature] + updated: [layer.feature as MapFeature] }) } @@ -792,11 +734,11 @@ export class MapComponent implements OnDestroy, AfterViewInit { }, save: () => { const newFeature = editor.stopEdit() - if (layer) { - layer.feature.geometry = newFeature.geometry + if (layer?.feature) { + (layer.feature as MapFeature).geometry = newFeature.geometry this.onFeaturesChanged({ id: 'observations', - updated: [layer.feature] + updated: [layer.feature as MapFeature] }) } @@ -805,15 +747,15 @@ export class MapComponent implements OnDestroy, AfterViewInit { } } - setPaneOpacity(panes, opacityFactor) { + setPaneOpacity(panes: string[], opacityFactor: number) { panes.forEach(pane => { const mapPane = this.map.getPanes()[pane] const opacity = mapPane.style.opacity || "1" - mapPane.style.opacity = `${parseInt(opacity)} * ${opacityFactor}` + mapPane.style.opacity = `${parseFloat(opacity) * opacityFactor}` }) } - resetPaneOpacity(panes, opacities) { + resetPaneOpacity(panes: string[], opacities: string[]) { panes.forEach((pane, index) => { const mapPane = this.map.getPanes()[pane] mapPane.style.opacity = opacities[index] @@ -850,20 +792,20 @@ export class MapComponent implements OnDestroy, AfterViewInit { } } - private getGroup(layer): string { + private getGroup(layer: RenderedMapLayer): string { switch (layer.type) { case 'GeoPackage': - return layer.layer.table.type === 'tile' ? 'tile' : 'feature' + return layer.renderAs === 'tile' ? 'tile' : 'feature' case 'Imagery': return layer.base ? 'base' : 'tile' - case 'geojson': + case 'vector': return layer.group case 'grid': - return layer.group + return 'grid' } } - private isSelected(layer): boolean { - return layer.options && layer.options.selected + private isSelected(layer: RenderedMapLayer): boolean { + return layer.selectedByDefault ?? false } } \ No newline at end of file diff --git a/web-app/src/app/map/map.service.spec.ts b/web-app/src/app/map/map.service.spec.ts index 170f5da61..d7a71ea72 100644 --- a/web-app/src/app/map/map.service.spec.ts +++ b/web-app/src/app/map/map.service.spec.ts @@ -1,5 +1,14 @@ import { TestBed } from '@angular/core/testing'; +import { Subject, of } from 'rxjs'; +import { LocationEvent } from 'leaflet'; +import { Feed } from '@ngageoint/mage.web-core-lib/feed'; import { MapService } from './map.service'; +import { MapFeature, MapLayer, RasterLayer, VectorLayer } from './entities.map-layer'; +import { EventService } from '../event/event.service'; +import { FeatureService } from '../layer/feature.service'; +import { SessionService } from '../http/session.service'; +import { Layer } from '../entities/layer/entities.layer'; +import { MageEvent } from '../entities/event/entities.event'; import { provideHttpClientTesting } from '@angular/common/http/testing'; import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; @@ -37,7 +46,7 @@ describe('Map Service Tests', () => { const layer = addFeedItems(service, feed); - expect(layer.options.selected).toEqual(true); + expect(layer.selectedByDefault).toEqual(true); }); it('does not select the layer by default when the feed has showOnMapByDefault false', () => { @@ -46,7 +55,7 @@ describe('Map Service Tests', () => { const layer = addFeedItems(service, feed); - expect(layer.options.selected).toEqual(false); + expect(layer.selectedByDefault).toEqual(false); }); it('does not select the layer by default when the feed does not specify showOnMapByDefault', () => { @@ -55,7 +64,7 @@ describe('Map Service Tests', () => { const layer = addFeedItems(service, feed); - expect(layer.options.selected).toEqual(false); + expect(layer.selectedByDefault).toEqual(false); }); it('ignores feeds without a spatial dimension', () => { @@ -67,4 +76,678 @@ describe('Map Service Tests', () => { expect(layer).toBeUndefined(); }); }); + + describe('init - mapObservations$ diffing', () => { + let service: MapService; + let mapObservationsSubject: Subject; + let locationsSubject: Subject; + let listener: jasmine.SpyObj; + + function mapObservation(id: string, lastModified: string) { + return { id, type: 'Feature', geometry: { type: 'Point', coordinates: [0, 0] }, lastModified, properties: {} }; + } + + beforeEach(() => { + mapObservationsSubject = new Subject(); + locationsSubject = new Subject(); + const eventService = jasmine.createSpyObj('EventService', ['addLayersChangedListener', 'addFeedItemsChangedListener'], { + mapObservations$: mapObservationsSubject.asObservable(), + locations$: locationsSubject.asObservable() + }); + + TestBed.overrideProvider(EventService, { useValue: eventService }); + service = TestBed.inject(MapService); + listener = jasmine.createSpyObj('listener', ['onFeaturesChanged', 'onLayersChanged']); + service.listeners.push(listener); + service.init(); + + mapObservationsSubject.next([]); + listener.onFeaturesChanged.calls.reset(); + }); + + it('adds a new observation to the observations layer and tracks it', () => { + const obs = mapObservation('o1', '2024-01-01'); + + mapObservationsSubject.next([obs]); + + expect(service.observationsById['o1']).toEqual(obs); + expect(listener.onFeaturesChanged).toHaveBeenCalledWith(jasmine.objectContaining({ id: 'observations', added: [obs] })); + }); + + it('updates an observation whose lastModified changed', () => { + const obs = mapObservation('o1', '2024-01-01'); + mapObservationsSubject.next([obs]); + listener.onFeaturesChanged.calls.reset(); + + const updated = mapObservation('o1', '2024-01-02'); + mapObservationsSubject.next([updated]); + + expect(service.observationsById['o1']).toEqual(updated); + expect(listener.onFeaturesChanged).toHaveBeenCalledWith(jasmine.objectContaining({ id: 'observations', updated: [updated] })); + }); + + it('does not notify for an observation with an unchanged lastModified', () => { + const obs = mapObservation('o1', '2024-01-01'); + mapObservationsSubject.next([obs]); + listener.onFeaturesChanged.calls.reset(); + + mapObservationsSubject.next([obs]); + + expect(listener.onFeaturesChanged).not.toHaveBeenCalled(); + }); + + it('removes an observation no longer present and stops tracking it', () => { + const obs = mapObservation('o1', '2024-01-01'); + mapObservationsSubject.next([obs]); + listener.onFeaturesChanged.calls.reset(); + + mapObservationsSubject.next([]); + + expect(service.observationsById['o1']).toBeUndefined(); + expect(listener.onFeaturesChanged).toHaveBeenCalledWith(jasmine.objectContaining({ id: 'observations', removed: [obs] })); + }); + }); + + describe('init - locations$ diffing', () => { + let service: MapService; + let mapObservationsSubject: Subject; + let locationsSubject: Subject; + let listener: jasmine.SpyObj; + + function userLocation(id: string, timestamp: string) { + return { id, location: { id, properties: { timestamp } } }; + } + + beforeEach(() => { + mapObservationsSubject = new Subject(); + locationsSubject = new Subject(); + const eventService = jasmine.createSpyObj('EventService', ['addLayersChangedListener', 'addFeedItemsChangedListener'], { + mapObservations$: mapObservationsSubject.asObservable(), + locations$: locationsSubject.asObservable() + }); + + TestBed.overrideProvider(EventService, { useValue: eventService }); + service = TestBed.inject(MapService); + listener = jasmine.createSpyObj('listener', ['onFeaturesChanged', 'onLayersChanged', 'onFeatureZoom']); + service.listeners.push(listener); + service.init(); + + locationsSubject.next({ data: [] }); + listener.onFeaturesChanged.calls.reset(); + }); + + it('adds a new user location to the people layer and tracks it', () => { + const user = userLocation('u1', '2024-01-01T00:00:00Z'); + + locationsSubject.next({ data: [user] }); + + expect(service.usersById['u1']).toEqual(user); + expect(listener.onFeaturesChanged).toHaveBeenCalledWith(jasmine.objectContaining({ id: 'people', added: [user.location] })); + }); + + it('updates a user whose location timestamp changed', () => { + const user = userLocation('u1', '2024-01-01T00:00:00Z'); + locationsSubject.next({ data: [user] }); + listener.onFeaturesChanged.calls.reset(); + + const updated = userLocation('u1', '2024-01-02T00:00:00Z'); + locationsSubject.next({ data: [updated] }); + + expect(service.usersById['u1']).toEqual(updated); + expect(listener.onFeaturesChanged).toHaveBeenCalledWith(jasmine.objectContaining({ id: 'people', updated: [updated.location] })); + }); + + it('zooms to the followed user when their location updates', () => { + const user = userLocation('u1', '2024-01-01T00:00:00Z'); + locationsSubject.next({ data: [user] }); + service.followedFeature = { id: 'u1', layer: 'people' }; + + const updated = userLocation('u1', '2024-01-02T00:00:00Z'); + locationsSubject.next({ data: [updated] }); + + expect(listener.onFeatureZoom).toHaveBeenCalledWith({ id: 'people', feature: updated }); + }); + + it('does not notify for a user location with an unchanged timestamp', () => { + const user = userLocation('u1', '2024-01-01T00:00:00Z'); + locationsSubject.next({ data: [user] }); + listener.onFeaturesChanged.calls.reset(); + + locationsSubject.next({ data: [user] }); + + expect(listener.onFeaturesChanged).not.toHaveBeenCalled(); + }); + + it('removes a user location no longer present and stops tracking it', () => { + const user = userLocation('u1', '2024-01-01T00:00:00Z'); + locationsSubject.next({ data: [user] }); + listener.onFeaturesChanged.calls.reset(); + + locationsSubject.next({ data: [] }); + + expect(service.usersById['u1']).toBeUndefined(); + expect(listener.onFeaturesChanged).toHaveBeenCalledWith(jasmine.objectContaining({ id: 'people', removed: [user.location] })); + }); + }); + + describe('init/destroy lifecycle', () => { + let service: MapService; + let mapObservationsSubject: Subject; + let locationsSubject: Subject; + let listener: jasmine.SpyObj; + + function mapObservation(id: string, lastModified: string) { + return { id, type: 'Feature', geometry: { type: 'Point', coordinates: [0, 0] }, lastModified, properties: {} }; + } + + beforeEach(() => { + mapObservationsSubject = new Subject(); + locationsSubject = new Subject(); + const eventService = jasmine.createSpyObj('EventService', ['addLayersChangedListener', 'addFeedItemsChangedListener', 'removeLayersChangedListener', 'removeFeedItemsChangedListener'], { + mapObservations$: mapObservationsSubject.asObservable(), + locations$: locationsSubject.asObservable() + }); + + TestBed.overrideProvider(EventService, { useValue: eventService }); + service = TestBed.inject(MapService); + listener = jasmine.createSpyObj('listener', ['onFeaturesChanged', 'onLayersChanged']); + service.listeners.push(listener); + }); + + it('tears down subscriptions from a second init/destroy cycle', () => { + service.init(); + service.destroy(); + service.listeners.push(listener); + service.init(); + service.destroy(); + + mapObservationsSubject.next([]); + listener.onFeaturesChanged.calls.reset(); + + mapObservationsSubject.next([mapObservation('o1', '2024-01-01')]); + + expect(listener.onFeaturesChanged).not.toHaveBeenCalled(); + }); + }); + + describe('layers, features and listeners', () => { + let service: MapService; + let featureService: jasmine.SpyObj; + let listener: jasmine.SpyObj; + + const LISTENER_METHODS = [ + 'onLayersChanged', 'onFeaturesChanged', 'onLayerRemoved', 'onFeedRemoved', 'onFeatureZoom', + 'onFeatureDeselect', 'onBaseLayerSelected', 'onLocation', 'onLocationStop' + ]; + + function vectorLayer(id: string): VectorLayer { + return { type: 'vector', id, name: id, group: 'feature', renderHooks: {} }; + } + + function rasterLayer(id: number): RasterLayer { + return { type: 'Imagery', id, name: String(id), url: 'http://example.com', format: 'XYZ' }; + } + + function feedFeature(id: string): MapFeature { + return { id, type: 'Feature', geometry: { type: 'Point', coordinates: [0, 0] }, properties: {} }; + } + + beforeEach(() => { + featureService = jasmine.createSpyObj('FeatureService', ['getFeatureCollection']); + featureService.getFeatureCollection.and.returnValue(of({ type: 'FeatureCollection', features: [] } as GeoJSON.FeatureCollection)); + const sessionService = jasmine.createSpyObj('SessionService', ['getToken']); + sessionService.getToken.and.returnValue('test-token'); + + TestBed.overrideProvider(FeatureService, { useValue: featureService }); + TestBed.overrideProvider(SessionService, { useValue: sessionService }); + service = TestBed.inject(MapService); + + listener = jasmine.createSpyObj('listener', LISTENER_METHODS); + service.addListener(listener); + LISTENER_METHODS.forEach(method => listener[method].calls.reset()); + }); + + describe('addListener', () => { + it('replays existing layers, their features and the base layer to a late listener', () => { + service.createVectorLayer(vectorLayer('v1')); + service.addFeaturesToLayer([feedFeature('f1')], 'v1'); + service.createRasterLayer(rasterLayer(1)); + + const late = jasmine.createSpyObj('late', LISTENER_METHODS); + service.addListener(late); + + const added = late.onLayersChanged.calls.mostRecent().args[0].added.map((l: MapLayer) => l.id); + expect(added).toEqual(jasmine.arrayContaining([1, 'v1'])); + expect(late.onFeaturesChanged).toHaveBeenCalledWith({ id: 'v1', added: [feedFeature('f1')] }); + expect(late.onBaseLayerSelected).toHaveBeenCalledWith(null); + }); + + it('tolerates listeners that implement none of the callbacks', () => { + expect(() => service.addListener({})).not.toThrow(); + expect(() => service.featuresChanged({ id: 'v1' })).not.toThrow(); + }); + }); + + describe('removeListener', () => { + it('stops notifying the removed listener', () => { + service.removeListener(listener); + service.onLocationStop(); + + expect(listener.onLocationStop).not.toHaveBeenCalled(); + }); + }); + + describe('createRasterLayer', () => { + it('stores the layer in rasterLayers', () => { + service.createRasterLayer(rasterLayer(1)); + expect(service.rasterLayers['1']).toBeDefined(); + }); + + it('notifies listeners of the added layer', () => { + service.createRasterLayer(rasterLayer(1)); + + expect(listener.onLayersChanged).toHaveBeenCalledWith({ added: [jasmine.objectContaining({ id: 1 })] }); + }); + }); + + describe('createVectorLayer', () => { + it('stores the layer in vectorLayers', () => { + service.createVectorLayer(vectorLayer('v1')); + expect(service.vectorLayers['v1']).toBeDefined(); + }); + + it('notifies listeners of the added layer', () => { + service.createVectorLayer(vectorLayer('v1')); + + expect(listener.onLayersChanged).toHaveBeenCalledWith({ added: [jasmine.objectContaining({ id: 'v1' })] }); + }); + }); + + describe('createGridLayer', () => { + it('stores the layer in gridLayers', () => { + service.createGridLayer({ type: 'grid', id: 'gars', name: 'GARS' }); + expect(service.gridLayers['gars']).toBeDefined(); + }); + }); + + describe('createFeedLayer', () => { + it('stores the layer in feedLayers', () => { + service.createFeedLayer(vectorLayer('feed-1')); + expect(service.feedLayers['feed-1']).toBeDefined(); + }); + }); + + describe('addFeaturesToLayer', () => { + it('replays the added features to a late listener', () => { + service.createVectorLayer(vectorLayer('v1')); + service.addFeaturesToLayer([feedFeature('f1'), feedFeature('f2')], 'v1'); + + const late = jasmine.createSpyObj('late', LISTENER_METHODS); + service.addListener(late); + + const change = late.onFeaturesChanged.calls.mostRecent().args[0]; + expect(change.added.map((f: MapFeature) => f.id)).toEqual(['f1', 'f2']); + }); + + it('notifies listeners with a flat list of the added features', () => { + service.createVectorLayer(vectorLayer('v1')); + listener.onFeaturesChanged.calls.reset(); + + service.addFeaturesToLayer([feedFeature('f1')], 'v1'); + + expect(listener.onFeaturesChanged).toHaveBeenCalledWith({ id: 'v1', added: [feedFeature('f1')] }); + }); + }); + + describe('updateFeatureForLayer', () => { + it('notifies listeners of the updated feature', () => { + service.updateFeatureForLayer(feedFeature('f1'), 'v1'); + + expect(listener.onFeaturesChanged).toHaveBeenCalledWith({ id: 'v1', updated: [feedFeature('f1')] }); + }); + }); + + describe('removeFeatureFromLayer', () => { + it('stops replaying the feature to late listeners', () => { + service.createVectorLayer(vectorLayer('v1')); + service.addFeaturesToLayer([feedFeature('f1')], 'v1'); + + service.removeFeatureFromLayer({ id: 'f1' }, 'v1'); + + const late = jasmine.createSpyObj('late', LISTENER_METHODS); + service.addListener(late); + expect(late.onFeaturesChanged).toHaveBeenCalledWith({ id: 'v1', added: [] }); + }); + + it('notifies listeners of the removed feature', () => { + service.removeFeatureFromLayer({ id: 'f1' }, 'v1'); + + expect(listener.onFeaturesChanged).toHaveBeenCalledWith({ id: 'v1', removed: [{ id: 'f1' }] }); + }); + }); + + describe('deselectFeatureInLayer', () => { + it('calls onFeatureDeselect on listeners', () => { + service.deselectFeatureInLayer({ id: 'f1' }, 'v1'); + + expect(listener.onFeatureDeselect).toHaveBeenCalledWith({ id: 'v1', feature: { id: 'f1' } }); + }); + + it('ignores a feature with no id', () => { + service.deselectFeatureInLayer({}, 'v1'); + + expect(listener.onFeatureDeselect).not.toHaveBeenCalled(); + }); + }); + + describe('zoomToFeatureInLayer', () => { + it('calls onFeatureZoom on listeners', () => { + service.zoomToFeatureInLayer({ id: 'f1' }, 'v1'); + + expect(listener.onFeatureZoom).toHaveBeenCalledWith({ id: 'v1', feature: { id: 'f1' } }); + }); + + it('ignores a feature with no id', () => { + service.zoomToFeatureInLayer({}, 'v1'); + + expect(listener.onFeatureZoom).not.toHaveBeenCalled(); + }); + }); + + describe('followFeatureInLayer', () => { + it('sets followedFeature and zooms when following a new feature', () => { + service.followFeatureInLayer({ id: 'f1' }, 'v1'); + + expect(service.followedFeature).toEqual({ id: 'f1', layer: 'v1' }); + expect(listener.onFeatureZoom).toHaveBeenCalled(); + }); + + it('clears followedFeature when called again with the same feature', () => { + service.followFeatureInLayer({ id: 'f1' }, 'v1'); + service.followFeatureInLayer({ id: 'f1' }, 'v1'); + + expect(service.followedFeature).toEqual({ id: undefined, layer: undefined }); + }); + + it('does nothing when called with no feature', () => { + service.followFeatureInLayer(null as unknown as { id: string }, 'v1'); + + expect(service.followedFeature).toEqual({ id: undefined, layer: undefined }); + }); + }); + + describe('onLocation', () => { + it('calls onLocation on listeners', () => { + const location = { latlng: { lat: 1, lng: 2 } } as LocationEvent; + + service.onLocation(location); + + expect(listener.onLocation).toHaveBeenCalledWith(location); + }); + }); + + describe('onLocationStop', () => { + it('calls onLocationStop on listeners', () => { + service.onLocationStop(); + + expect(listener.onLocationStop).toHaveBeenCalled(); + }); + }); + + describe('createFeature', () => { + it('returns undefined when no delegate is set', () => { + expect(service.createFeature(feedFeature('f1'), {})).toBeUndefined(); + }); + + it('delegates to the map delegate', () => { + const edit = jasmine.createSpyObj('FeatureEdit', ['update', 'cancel', 'save']); + const delegate = jasmine.createSpyObj('MapDelegate', ['createFeature']); + delegate.createFeature.and.returnValue(edit); + service.setDelegate(delegate); + + const editDelegate = {}; + const feature = feedFeature('f1'); + + expect(service.createFeature(feature, editDelegate)).toBe(edit); + expect(delegate.createFeature).toHaveBeenCalledWith(feature, editDelegate); + }); + }); + + describe('selectBaseLayer', () => { + it('stores the base layer and notifies listeners', () => { + const layer = { ...rasterLayer(2), layer: {} } as unknown as Parameters[0]; + + service.selectBaseLayer(layer); + + expect(service.baseLayer).toBe(layer); + expect(listener.onBaseLayerSelected).toHaveBeenCalledWith(layer); + }); + }); + + describe('removeLayer', () => { + it('removes a vector layer and notifies listeners', () => { + service.createVectorLayer(vectorLayer('v1')); + listener.onLayerRemoved.calls.reset(); + + service.removeLayer('v1'); + + expect(service.vectorLayers['v1']).toBeUndefined(); + expect(listener.onLayerRemoved).toHaveBeenCalledWith(jasmine.objectContaining({ id: 'v1' })); + }); + + it('removes a raster layer and notifies listeners', () => { + service.createRasterLayer(rasterLayer(1)); + listener.onLayerRemoved.calls.reset(); + + service.removeLayer(1); + + expect(service.rasterLayers['1']).toBeUndefined(); + expect(listener.onLayerRemoved).toHaveBeenCalledWith(jasmine.objectContaining({ id: 1 })); + }); + + it('does nothing for an unknown layer id', () => { + service.removeLayer('unknown'); + + expect(listener.onLayerRemoved).not.toHaveBeenCalled(); + }); + }); + + describe('removeFeed', () => { + it('removes the feed layer and notifies listeners', () => { + const feed = vectorLayer('feed-1'); + service.createFeedLayer(feed); + + service.removeFeed(feed); + + expect(service.feedLayers['feed-1']).toBeUndefined(); + expect(listener.onFeedRemoved).toHaveBeenCalledWith(jasmine.objectContaining({ id: 'feed-1' })); + }); + + it('does nothing for an unknown feed id', () => { + service.removeFeed(vectorLayer('unknown')); + + expect(listener.onFeedRemoved).not.toHaveBeenCalled(); + }); + }); + + describe('onLayersChanged', () => { + const event = { id: 42 } as MageEvent; + + function imagery(id: number, overrides: Partial = {}): Layer { + return { id, type: 'Imagery', name: `img${id}`, url: 'http://example.com', format: 'XYZ', base: false, ...overrides } as Layer; + } + + it('creates a raster layer for Imagery type', () => { + service.onLayersChanged({ added: [imagery(1)] }, event); + + expect(service.rasterLayers['1']).toEqual(jasmine.objectContaining({ type: 'Imagery', format: 'XYZ', url: 'http://example.com' })); + }); + + it('skips Imagery layers with no format', () => { + spyOn(console, 'error'); + + service.onLayersChanged({ added: [imagery(1, { format: undefined } as Partial)] }, event); + + expect(service.rasterLayers['1']).toBeUndefined(); + }); + + it('appends the access token to private Imagery layer urls', () => { + service.onLayersChanged({ added: [imagery(1, { url: 'private/tiles' } as Partial)] }, event); + + expect((service.rasterLayers['1'] as RasterLayer).url).toBe('private/tiles?access_token=test-token'); + }); + + it('carries the WMS options on WMS Imagery layers', () => { + const wms = { layers: 'a,b', version: '1.3.0', format: 'image/jpeg', transparent: false, styles: 's' }; + + service.onLayersChanged({ added: [imagery(1, { format: 'WMS', wms } as Partial)] }, event); + + expect(service.rasterLayers['1']).toEqual(jasmine.objectContaining({ format: 'WMS', wms })); + }); + + it('selects only the first base Imagery layer by default', () => { + service.onLayersChanged({ added: [imagery(1, { base: true }), imagery(2, { base: true })] }, event); + + expect((service.rasterLayers['1'] as RasterLayer).selectedByDefault).toBe(true); + expect((service.rasterLayers['2'] as RasterLayer).selectedByDefault).toBe(false); + }); + + it('explodes a GeoPackage layer into one raster layer per table', () => { + const layer = { + id: 7, type: 'GeoPackage', name: 'gp', + tables: [ + { name: 'tiles', type: 'tile', minZoom: 1, maxZoom: 5, bbox: [0, 0, 1, 1] }, + { name: 'feats', type: 'feature' } + ] + } as unknown as Layer; + + service.onLayersChanged({ added: [layer] }, event); + + expect(service.rasterLayers['7-tiles']).toEqual(jasmine.objectContaining({ + type: 'GeoPackage', layerId: 7, name: 'tiles', renderAs: 'tile', + url: 'api/events/42/layers/7/tiles/{z}/{x}/{y}.png', minZoom: 1, maxZoom: 5 + })); + expect(service.rasterLayers['7-feats']).toEqual(jasmine.objectContaining({ renderAs: 'feature' })); + }); + + it('removes every exploded GeoPackage table when the original layer is removed', () => { + const layer = { + id: 7, type: 'GeoPackage', name: 'gp', + tables: [{ name: 'a', type: 'tile' }, { name: 'b', type: 'tile' }] + } as unknown as Layer; + service.onLayersChanged({ added: [layer] }, event); + + service.onLayersChanged({ removed: [7] }, event); + + expect(service.rasterLayers['7-a']).toBeUndefined(); + expect(service.rasterLayers['7-b']).toBeUndefined(); + }); + + it('creates a vector layer for Feature type via featureService', () => { + const layer = { id: 3, type: 'Feature', name: 'Features' } as unknown as Layer; + featureService.getFeatureCollection.and.returnValue(of({ type: 'FeatureCollection', features: [feedFeature('f1')] } as GeoJSON.FeatureCollection)); + + service.onLayersChanged({ added: [layer] }, event); + + expect(featureService.getFeatureCollection).toHaveBeenCalledWith(event, layer); + expect(service.vectorLayers['3']).toEqual(jasmine.objectContaining({ type: 'vector', group: 'feature', name: 'Features' })); + expect(listener.onFeaturesChanged).toHaveBeenCalledWith({ id: '3', added: [feedFeature('f1')] }); + }); + + it('builds popup html from the feature name and description', () => { + const layer = { id: 3, type: 'Feature', name: 'Features' } as unknown as Layer; + service.onLayersChanged({ added: [layer] }, event); + + const popup = service.vectorLayers['3'].renderHooks.popup as { html: (feature: MapFeature) => string }; + const html = popup.html({ ...feedFeature('f1'), properties: { name: 'N', description: 'D' } }); + + expect(html).toContain('N'); + expect(html).toContain('D'); + }); + + it('removes layers listed in removed', () => { + service.createVectorLayer(vectorLayer('v1')); + + service.onLayersChanged({ removed: ['v1'] }, event); + + expect(service.vectorLayers['v1']).toBeUndefined(); + }); + }); + + describe('onFeedItemsChanged', () => { + function feed(overrides: Record = {}): Feed { + return { id: 'f1', title: 'Feed 1', itemsHaveSpatialDimension: true, ...overrides } as unknown as Feed; + } + + it('creates a feed layer for geospatial feeds and adds the items', () => { + service.onFeedItemsChanged({ added: [{ feed: feed(), items: [feedFeature('i1')] }] }); + + expect(service.feedLayers['feed-f1']).toEqual(jasmine.objectContaining({ group: 'feed', name: 'Feed 1' })); + expect(listener.onFeaturesChanged).toHaveBeenCalledWith({ id: 'feed-f1', added: [feedFeature('i1')] }); + }); + + it('does not create a feed layer for non-geospatial feeds', () => { + service.onFeedItemsChanged({ added: [{ feed: feed({ itemsHaveSpatialDimension: false }), items: [] }] }); + + expect(service.feedLayers['feed-f1']).toBeUndefined(); + }); + + it('uses the default marker icon when the feed has no icon', () => { + service.onFeedItemsChanged({ added: [{ feed: feed(), items: [] }] }); + + expect(service.feedLayers['feed-f1'].iconUrl).toBe('/assets/images/default_marker.png'); + }); + + it('uses the feed map style icon url when available', () => { + service.onFeedItemsChanged({ added: [{ feed: feed({ mapStyle: { icon: { id: 'icon1' } } }), items: [] }] }); + + expect(service.feedLayers['feed-f1'].iconUrl).toBe('/api/icons/icon1/content?access_token=test-token'); + }); + + it('removes geospatial feed layers in the removed list', () => { + service.createFeedLayer(vectorLayer('feed-f1')); + + service.onFeedItemsChanged({ removed: [{ feed: feed() }] }); + + expect(service.feedLayers['feed-f1']).toBeUndefined(); + expect(listener.onFeedRemoved).toHaveBeenCalled(); + }); + + it('notifies listeners of updated feed items', () => { + service.createFeedLayer(vectorLayer('feed-f1')); + listener.onFeaturesChanged.calls.reset(); + + service.onFeedItemsChanged({ updated: [{ feed: feed(), items: [feedFeature('i1')] }] }); + + expect(listener.onFeaturesChanged).toHaveBeenCalledWith({ id: 'feed-f1', updated: [feedFeature('i1')] }); + }); + }); + + describe('destroy', () => { + it('notifies listeners of the removed layers and clears the layer maps', () => { + service.init(); + service.createRasterLayer(rasterLayer(1)); + listener.onLayerRemoved.calls.reset(); + + service.destroy(); + + expect(listener.onLayerRemoved).toHaveBeenCalledWith(jasmine.objectContaining({ id: 1 })); + expect(listener.onLayerRemoved).toHaveBeenCalledWith(jasmine.objectContaining({ id: 'observations' })); + expect(service.rasterLayers).toEqual({}); + expect(service.vectorLayers).toEqual({}); + expect(service.listeners).toEqual([]); + }); + + it('drops the map delegate so a destroyed map is not asked to create features', () => { + const delegate = jasmine.createSpyObj('MapDelegate', ['createFeature']); + service.setDelegate(delegate); + service.init(); + + service.destroy(); + + expect(service.createFeature(feedFeature('f1'), {})).toBeUndefined(); + expect(delegate.createFeature).not.toHaveBeenCalled(); + }); + }); + }); }); \ No newline at end of file diff --git a/web-app/src/app/map/map.service.ts b/web-app/src/app/map/map.service.ts index 4bdefe1a7..dd47abc52 100644 --- a/web-app/src/app/map/map.service.ts +++ b/web-app/src/app/map/map.service.ts @@ -1,9 +1,17 @@ import { Injectable } from "@angular/core"; +import { Subject } from "rxjs"; +import { filter, map, pairwise, takeUntil } from "rxjs/operators"; +import * as _ from "lodash"; import { EventService } from "../event/event.service"; import { MapPopupService } from "./map-popup.service"; import { LocationService } from "../user/location/location.service"; import { SessionService } from "../http/session.service"; import { FeatureService } from "../layer/feature.service"; +import { GeoPackageLayer, GridOverlay, MapFeature, MapLayer, MapLayerId, RasterLayer, RenderedRasterLayer, VectorLayer } from "./entities.map-layer"; +import { Layer } from "../entities/layer/entities.layer"; +import { MageEvent } from "../entities/event/entities.event"; +import { Feed } from "@ngageoint/mage.web-core-lib/feed"; +import { LocationEvent as LeafletLocationEvent } from "leaflet"; export interface FeatureEdit { update(feature: any): void; @@ -11,25 +19,72 @@ export interface FeatureEdit { save(): void; } +interface FeatureEditDelegate { + geometryChanged?: (geometry: any) => void; + vertexClick?: (vertex: any) => void; +} + +interface MapDelegate { + createFeature(feature: MapFeature, delegate: FeatureEditDelegate): FeatureEdit | undefined; +} + +interface FeaturesChanged { + id: string; + added?: MapFeature[]; + updated?: MapFeature[]; + removed?: Pick[]; +} + +interface FeedItemsChanged { + added?: { feed: Feed, items: MapFeature[] }[]; + updated?: { feed: Feed, items: MapFeature[] }[]; + removed?: { feed: Feed }[]; +} + +function hasId>>(feature: T): feature is T & Pick { + return feature.id !== undefined; +} + +interface MapListener { + onLayersChanged?(changed: { added: MapLayer[] }): void; + onFeaturesChanged?(changed: FeaturesChanged): void; + onLayerRemoved?(layer: MapLayer): void; + onFeedRemoved?(feed: VectorLayer): void; + onFeatureZoom?(zoom: { id: string, feature: Pick }): void; + onFeatureDeselect?(deselected: { id: string, feature: Pick }): void; + onBaseLayerSelected?(layer: RenderedRasterLayer | null): void; + onLocation?(location: LeafletLocationEvent): void; + onLocationStop?(): void; +} + @Injectable({ providedIn: 'root' }) export class MapService { - followedFeature = { + private destroy$ = new Subject(); + + followedFeature: { id: string | number | undefined, layer: string | undefined } = { id: undefined, layer: undefined }; - delegate = null; - baseLayer = null; - feedLayers = {}; - rasterLayers = {}; - vectorLayers = {}; - gridLayers = {}; - listeners = []; - observationsById = {}; - usersById = {}; + delegate: MapDelegate | null = null; + baseLayer: RenderedRasterLayer | null = null; + feedLayers: Record = {}; + rasterLayers: Record = {}; + vectorLayers: Record = {}; + gridLayers: Record = {}; + // Features currently in each vector/feed layer, keyed by layer id then feature id - + // MapService's own bookkeeping, kept separate from the VectorLayer definitions above. + private featuresByLayerId: Record> = {}; + // One server GeoPackage Layer explodes into one rasterLayers entry per table - + // this tracks which exploded ids came from which original layer id, so removal + // (which only ever gets the original id) can find and remove them all. + private explodedLayerIds: Record = {}; + listeners: MapListener[] = []; + observationsById: Record = {}; + usersById: Record = {}; constructor( private eventService: EventService, @@ -40,22 +95,16 @@ export class MapService { ) {} init () { - const observationLayer = { + this.destroy$ = new Subject(); + + const observationLayer: VectorLayer = { id: 'observations', name: 'Observations', group: 'mage', - type: 'geojson', - featureIdToLayer: {}, - options: { - selected: true, - cluster: true, - showAccuracy: true, - style: function () { - return {}; - }, - onEachFeature: function (feature, layer) { - observationLayer.featureIdToLayer[feature.id] = layer; - }, + type: 'vector', + selectedByDefault: true, + cluster: true, + renderHooks: { popup: (layer, feature) => { this.mapPopupService.popupObservation(layer, feature); }, @@ -67,19 +116,18 @@ export class MapService { this.createVectorLayer(observationLayer); - const peopleLayer = { + const peopleLayer: VectorLayer = { id: 'people', name: 'People', group: 'mage', - type: 'geojson', - options: { - selected: true, - cluster: false, - showAccuracy: true, - temporal: { - property: 'timestamp', - colorBuckets: this.locationService.colorBuckets - }, + type: 'vector', + selectedByDefault: true, + cluster: false, + temporal: { + property: 'timestamp', + colorBuckets: this.locationService.colorBuckets + }, + renderHooks: { popup: (layer, feature) => { this.mapPopupService.popupUser(layer, feature); }, @@ -90,96 +138,166 @@ export class MapService { }; this.createVectorLayer(peopleLayer); - const garsOverlay = { + const garsOverlay: GridOverlay = { id: 'gars', name: 'GARS', - group: 'grid', type: 'grid', - options: { - selected: false - } + selectedByDefault: false } this.createGridLayer(garsOverlay); - const mgrsOverlay = { + const mgrsOverlay: GridOverlay = { id: 'mgrs', name: 'MGRS', - group: 'grid', type: 'grid', - options: { - selected: false - } + selectedByDefault: false } this.createGridLayer(mgrsOverlay); - this.eventService.addObservationsChangedListener(this); - this.eventService.addUsersChangedListener(this); + this.eventService.mapObservations$.pipe( + filter(obs => obs !== null), + pairwise(), + takeUntil(this.destroy$) + ).subscribe(([prev, curr]) => { + const prevById = _.keyBy(prev, 'id') + const currById = _.keyBy(curr, 'id') + + const added = curr.filter((o: any) => !prevById[o.id]) + const updated = curr.filter((o: any) => prevById[o.id] && (prevById[o.id] as any).lastModified !== o.lastModified) + const removed = prev.filter((o: any) => !currById[o.id]) + + added.forEach((o: any) => { this.observationsById[o.id] = o }) + if (added.length) this.addFeaturesToLayer(added as unknown as MapFeature[], 'observations') + + updated.forEach((o: any) => { + if (this.observationsById[o.id]) { + this.observationsById[o.id] = o + this.updateFeatureForLayer(o, 'observations') + } + }) + + removed.forEach((o: any) => { + delete this.observationsById[o.id] + this.removeFeatureFromLayer(o, 'observations') + }) + }) + + this.eventService.locations$.pipe( + filter(result => result !== null), + map(result => result.data), + pairwise(), + takeUntil(this.destroy$) + ).subscribe(([prev, curr]) => { + const prevById = _.keyBy(prev, 'id') + const currById = _.keyBy(curr, 'id') + + const added = curr.filter((u: any) => !prevById[u.id]) + const updated = curr.filter((u: any) => prevById[u.id] && prevById[u.id].location.properties.timestamp !== u.location.properties.timestamp) + const removed = prev.filter((u: any) => !currById[u.id]) + + added.forEach((u: any) => { + this.usersById[u.id] = u + this.addFeaturesToLayer([u.location], 'people') + }) + + updated.forEach((u: any) => { + const user = this.usersById[u.id] + if (user) { + this.usersById[u.id] = u + this.updateFeatureForLayer(u.location, 'people') + if (this.followedFeature.layer === 'people' && user.id === this.followedFeature.id) + this.zoomToFeatureInLayer(u, 'people') + } + }) + + removed.forEach((u: any) => { + delete this.usersById[u.id] + this.removeFeatureFromLayer(u.location, 'people') + }) + }) + this.eventService.addLayersChangedListener(this); this.eventService.addFeedItemsChangedListener(this) } destroy() { - Object.values(this.vectorLayers).forEach((layerInfo: any) => { - this.listeners.forEach((listener: any) => { - if (typeof listener.onLayerRemoved === 'function') { - listener.onLayerRemoved(layerInfo); - } + Object.values(this.vectorLayers).forEach(layerInfo => { + this.listeners.forEach(listener => { + listener.onLayerRemoved?.(layerInfo); }); }); this.vectorLayers = {}; + this.featuresByLayerId = {}; - Object.values(this.rasterLayers).forEach((layerInfo: any) => { - this.listeners.forEach((listener: any) => { - if (typeof listener.onLayerRemoved === 'function') { - listener.onLayerRemoved(layerInfo); - } + Object.values(this.rasterLayers).forEach(layerInfo => { + this.listeners.forEach(listener => { + listener.onLayerRemoved?.(layerInfo); }); }); this.rasterLayers = {}; + this.explodedLayerIds = {}; this.listeners = []; + this.delegate = null; + this.destroy$.next(); + this.destroy$.complete(); this.eventService.removeLayersChangedListener(this); - this.eventService.removeObservationsChangedListener(this); - this.eventService.removeUsersChangedListener(this); this.eventService.removeFeedItemsChangedListener(this); } - onLayersChanged(changed, event) { - const { added = [], updated = [], removed = [] } = changed + onLayersChanged(changed: { added?: Layer[], removed?: MapLayerId[] }, event: MageEvent) { + const { added = [], removed = [] } = changed let baseLayerFound = false; - added.forEach((layer: any) => { - // Add token to the url of all private layers - // TODO add watch for token change and reset the url for these layers - if (layer.type === 'Imagery' && layer.url.indexOf('private') === 0) { - layer.url = layer.url + "?access_token=" + this.sessionService.getToken(); - } - + added.forEach((layer: Layer) => { if (layer.type === 'Imagery') { - if (layer.base && !baseLayerFound) { - layer.options = { selected: true }; - baseLayerFound = true; + if (!layer.format) { + console.error(`Imagery layer ${layer.id} has no format, skipping`); + return; + } + + // Add token to the url of all private layers + // TODO add watch for token change and reset the url for these layers + let url = layer.url ?? ''; + if (url.indexOf('private') === 0) { + url = url + "?access_token=" + this.sessionService.getToken(); } - this.createRasterLayer(layer); + const selectedByDefault = (layer.base ?? false) && !baseLayerFound; + if (selectedByDefault) baseLayerFound = true; + + const rasterLayer: RasterLayer = layer.format === 'WMS' + ? { + type: 'Imagery', id: layer.id, name: layer.name, url, base: layer.base, selectedByDefault, + format: 'WMS', + wms: { + layers: layer.wms?.layers ?? '', + version: layer.wms?.version ?? '1.1.1', + format: layer.wms?.format ?? 'image/png', + transparent: layer.wms?.transparent ?? true, + styles: layer.wms?.styles + } + } + : { type: 'Imagery', id: layer.id, name: layer.name, url, base: layer.base, selectedByDefault, format: layer.format }; + + this.createRasterLayer(rasterLayer); } else if (layer.type === 'Feature') { - this.featureService.getFeatureCollection(event, layer).subscribe((featureCollection: any) => { - this.createVectorLayer({ - id: layer.id, + this.featureService.getFeatureCollection(event, layer).subscribe((featureCollection: GeoJSON.FeatureCollection) => { + const vectorLayer: VectorLayer = { + id: String(layer.id), name: layer.name, // TODO need to track by id as well not just names group: 'feature', - type: 'geojson', - geojson: featureCollection, - options: { + type: 'vector', + renderHooks: { popup: { - html: function (feature) { + html: function (feature: MapFeature) { // TODO use leaflet template for this let content = ""; - if (feature.properties.name) { + if (feature.properties?.name) { content += '
' + feature.properties.name + '
'; } - if (feature.properties.description) { + if (feature.properties?.description) { content += '
' + feature.properties.description + '
'; } @@ -187,24 +305,41 @@ export class MapService { } } } - }); + }; + this.createVectorLayer(vectorLayer); + this.addFeaturesToLayer(featureCollection.features as MapFeature[], vectorLayer.id); }); } else if (layer.type === 'GeoPackage') { - layer.eventId = event.id; - this.createRasterLayer(layer); + const childIds = (layer.tables ?? []).map(table => { + const geoPackageLayer: GeoPackageLayer = { + type: 'GeoPackage', + id: `${layer.id}-${table.name}`, + layerId: layer.id, + name: table.name, + renderAs: table.type, + url: `api/events/${event.id}/layers/${layer.id}/${table.name}/{z}/{x}/{y}.png`, + minZoom: table.minZoom, + maxZoom: table.maxZoom, + bbox: table.bbox + }; + this.createRasterLayer(geoPackageLayer); + return geoPackageLayer.id; + }); + this.explodedLayerIds[layer.id] = childIds; } }) - removed.forEach((layer: any) => { - this.removeLayer(layer); + removed.forEach((id: MapLayerId) => { + (this.explodedLayerIds[id] ?? [id]).forEach(childId => this.removeLayer(childId)); + delete this.explodedLayerIds[id]; }) } - onFeedItemsChanged(changed) { + onFeedItemsChanged(changed: FeedItemsChanged) { const { added = [], updated = [], removed = [] } = changed // Filter out non geospatial feeds - const geospatialFilter = ({ feed }) => { return feed.itemsHaveSpatialDimension; } + const geospatialFilter = ({ feed }: { feed: Feed }) => { return feed.itemsHaveSpatialDimension; } added.filter(geospatialFilter).forEach(({ feed, items }) => { // TODO: the access token query string parameter means these icon urls // bust the browser cache on every login. Static icons now use this @@ -214,27 +349,25 @@ export class MapService { // route) would avoid the per-login cache miss entirely. const iconId = (feed.mapStyle && feed.mapStyle.icon) ? feed.mapStyle.icon.id : feed.icon ? feed.icon.id : null; const iconUrl = iconId ? `/api/icons/${iconId}/content?access_token=${this.sessionService.getToken()}` : '/assets/images/default_marker.png' - this.createFeedLayer({ + const feedLayer: VectorLayer = { id: `feed-${feed.id}`, name: feed.title, group: 'feed', - type: 'geojson', - geojson: { - type: 'FeatureCollection', - features: items - }, - options: { - iconWidth: 24, - selected: feed.showOnMapByDefault != undefined ? feed.showOnMapByDefault : false, + type: 'vector', + iconWidth: 24, + iconUrl, + selectedByDefault: feed.showOnMapByDefault != undefined ? feed.showOnMapByDefault : false, + renderHooks: { popup: (layer, feature) => { this.mapPopupService.popupFeedItem(layer, feed, feature); }, onLayer: (layer, feature) => { this.mapPopupService.registerFeedItem(layer, feed, feature); - }, - iconUrl + } } - }); + }; + this.createFeedLayer(feedLayer); + this.addFeaturesToLayer(items, feedLayer.id); }); updated.filter(geospatialFilter).forEach(({ feed, items }) => { @@ -254,79 +387,30 @@ export class MapService { }); } - onObservationsChanged(changed) { - const { added = [], updated = [], removed = [] } = changed - added.forEach((added: any) => { - this.observationsById[added.id] = added; - }) - if (added.length) this.addFeaturesToLayer(added, 'observations'); - - updated.forEach((updated: any) => { - const observation = this.observationsById[updated.id]; - if (observation) { - this.observationsById[updated.id] = updated; - this.updateFeatureForLayer(updated, 'observations'); - } - }) - - removed.forEach((removed: any) => { - delete this.observationsById[removed.id]; - this.removeFeatureFromLayer(removed, 'observations'); - }) - } - - onUsersChanged(changed) { - const { added = [], updated = [], removed = [] } = changed - - added.forEach((added: any) => { - this.usersById[added.id] = added; - this.addFeaturesToLayer([added.location], 'people'); - }) - - updated.forEach((updated: any) => { - const user = this.usersById[updated.id]; - if (user) { - this.usersById[updated.id] = updated; - this.updateFeatureForLayer(updated.location, 'people'); - - // pan/zoom map to user if this is the user we are following - if (this.followedFeature.layer === 'people' && user.id === this.followedFeature.id) - this.zoomToFeatureInLayer(user, 'people'); - } - }) - - removed.forEach((removed: any) => { - delete this.usersById[removed.id]; - this.removeFeatureFromLayer(removed.location, 'people'); - }) - } - - setDelegate(theDelegate) { + setDelegate(theDelegate: MapDelegate) { this.delegate = theDelegate; } - addListener(listener) { + addListener(listener: MapListener) { this.listeners.push(listener); - if (typeof listener.onLayersChanged === 'function') { - const layers = Object.values(this.rasterLayers).concat(Object.values(this.vectorLayers)).concat(Object.values(this.gridLayers)); - listener.onLayersChanged({ added: layers }); - } + const layers: MapLayer[] = [ + ...Object.values(this.rasterLayers), + ...Object.values(this.vectorLayers), + ...Object.values(this.gridLayers) + ]; + listener.onLayersChanged?.({ added: layers }); - if (typeof listener.onFeaturesChanged === 'function') { - Object.values(this.vectorLayers).forEach((vectorLayer: any) => { - listener.onFeaturesChanged({ id: vectorLayer.id, added: Object.values(vectorLayer.featuresById) }); - }) - } + Object.values(this.vectorLayers).forEach((vectorLayer: VectorLayer) => { + listener.onFeaturesChanged?.({ id: vectorLayer.id, added: Object.values(this.featuresByLayerId[vectorLayer.id] ?? {}) }); + }) - if (typeof listener.onBaseLayerSelected === 'function') { - listener.onBaseLayerSelected(this.baseLayer); - } + listener.onBaseLayerSelected?.(this.baseLayer); } - removeListener(listener) { - this.listeners = this.listeners.filter((l: any) => l !== listener) + removeListener(listener: MapListener) { + this.listeners = this.listeners.filter(l => l !== listener) } getRasterLayers() { @@ -337,7 +421,7 @@ export class MapService { return this.vectorLayers; } - createRasterLayer(layer) { + createRasterLayer(layer: RasterLayer | GeoPackageLayer) { this.layersChanged({ added: [layer] }); @@ -345,61 +429,58 @@ export class MapService { this.rasterLayers[layer.id] = layer; } - createVectorLayer(layer) { + createVectorLayer(layer: VectorLayer) { this.layersChanged({ added: [layer] }); - layer.featuresById = {}; + this.featuresByLayerId[layer.id] = {}; this.vectorLayers[layer.id] = layer; } - createGridLayer(layer) { + createGridLayer(layer: GridOverlay) { this.layersChanged({ added: [layer] }); - layer.featuresById = {}; this.gridLayers[layer.id] = layer; } - createFeedLayer(layer) { + createFeedLayer(layer: VectorLayer) { this.layersChanged({ added: [layer] }); - layer.featuresById = {}; + this.featuresByLayerId[layer.id] = {}; this.feedLayers[layer.id] = layer; } - createFeature(feature, style, listeners?: any): FeatureEdit | undefined { - if (this.delegate) return this.delegate.createFeature(feature, style, listeners); + createFeature(feature: MapFeature, delegate: FeatureEditDelegate): FeatureEdit | undefined { + if (this.delegate) return this.delegate.createFeature(feature, delegate); return undefined; } - addFeaturesToLayer(features, layerId) { - const vectorLayer = this.vectorLayers[layerId]; - features.forEach((feature: any) => { - vectorLayer.featuresById[feature.id] = feature; - + addFeaturesToLayer(features: MapFeature[], layerId: string) { + const featuresById = this.featuresByLayerId[layerId] ?? (this.featuresByLayerId[layerId] = {}); + features.forEach((feature: MapFeature) => { + featuresById[feature.id] = feature; }) this.featuresChanged({ id: layerId, - added: [features] + added: features }); } - updateFeatureForLayer(feature, layerId) { + updateFeatureForLayer(feature: MapFeature, layerId: string) { this.featuresChanged({ id: layerId, updated: [feature] }); } - removeFeatureFromLayer(feature, layerId) { - const vectorLayer = this.vectorLayers[layerId]; - delete vectorLayer.featuresById[feature.id]; + removeFeatureFromLayer(feature: Pick, layerId: string) { + delete this.featuresByLayerId[layerId]?.[feature.id]; this.featuresChanged({ id: layerId, @@ -407,29 +488,21 @@ export class MapService { }); } - deselectFeatureInLayer(feature, layerId) { - this.listeners.forEach((listener: any) => { - if (typeof listener.onFeatureDeselect === 'function') { - listener.onFeatureDeselect({ - id: layerId, - feature: feature - }); - } + deselectFeatureInLayer(feature: Partial>, layerId: string) { + if (!hasId(feature)) return; + this.listeners.forEach(listener => { + listener.onFeatureDeselect?.({ id: layerId, feature }); }) } - zoomToFeatureInLayer(feature, layerId) { - this.listeners.forEach((listener: any) => { - if (typeof listener.onFeatureZoom === 'function') { - listener.onFeatureZoom({ - id: layerId, - feature: feature - }); - } + zoomToFeatureInLayer(feature: Partial>, layerId: string) { + if (!hasId(feature)) return; + this.listeners.forEach(listener => { + listener.onFeatureZoom?.({ id: layerId, feature }); }); } - followFeatureInLayer(feature, layerId) { + followFeatureInLayer(feature: Partial>, layerId: string) { if (feature && (this.followedFeature.id !== feature.id || this.followedFeature.layer !== layerId)) { this.followedFeature.id = feature.id; this.followedFeature.layer = layerId; @@ -440,104 +513,65 @@ export class MapService { } } - onLocation(location) { - this.listeners.forEach((listener: any) => { - if (typeof listener.onLocation === 'function') { - listener.onLocation(location); - } + onLocation(location: LeafletLocationEvent) { + this.listeners.forEach(listener => { + listener.onLocation?.(location); }); } onLocationStop() { - this.listeners.forEach((listener: any) => { - if (typeof listener.onLocationStop === 'function') { - listener.onLocationStop(); - } + this.listeners.forEach(listener => { + listener.onLocationStop?.(); }); } - onBroadcastLocation(callback) { - this.listeners.forEach((listener: any) => { - if (typeof listener.onBroadcastLocation === 'function') { - listener.onBroadcastLocation(callback); - } + featuresChanged(changed: FeaturesChanged) { + this.listeners.forEach(listener => { + listener.onFeaturesChanged?.(changed); }); } - featuresChanged(changed) { - this.listeners.forEach((listener: any) => { - changed.added = changed.added || []; - changed.updated = changed.updated || []; - changed.removed = changed.removed || []; - - if (typeof listener.onFeaturesChanged === 'function') { - listener.onFeaturesChanged(changed); - } + layersChanged(changed: { added: MapLayer[] }) { + this.listeners.forEach(listener => { + listener.onLayersChanged?.(changed); }); } - layersChanged(changed) { - this.listeners.forEach((listener: any) => { - changed.added = changed.added || []; - changed.removed = changed.removed || []; - - if (typeof listener.onLayersChanged === 'function') { - listener.onLayersChanged(changed); - } - }); - } - - selectBaseLayer(layer) { + selectBaseLayer(layer: RenderedRasterLayer) { this.baseLayer = layer; - this.listeners.forEach((listener: any) => { - if (typeof listener.onBaseLayerSelected === 'function') { - listener.onBaseLayerSelected(layer); - } + this.listeners.forEach(listener => { + listener.onBaseLayerSelected?.(layer); }); } - removeLayer(layer) { - const vectorLayer = this.vectorLayers[layer.id]; + removeLayer(id: MapLayerId) { + const vectorLayer = this.vectorLayers[id]; if (vectorLayer) { - this.listeners.forEach((listener: any) => { - if (typeof listener.onLayerRemoved === 'function') { - listener.onLayerRemoved(vectorLayer); - } + this.listeners.forEach(listener => { + listener.onLayerRemoved?.(vectorLayer); }); - delete this.vectorLayers[layer.id]; + delete this.vectorLayers[id]; + delete this.featuresByLayerId[id]; } - const rasterLayer = this.rasterLayers[layer.id]; + const rasterLayer = this.rasterLayers[id]; if (rasterLayer) { - this.listeners.forEach((listener: any) => { - if (typeof listener.onLayerRemoved === 'function') { - listener.onLayerRemoved(rasterLayer); - } + this.listeners.forEach(listener => { + listener.onLayerRemoved?.(rasterLayer); }); - delete this.rasterLayers[layer.id]; + delete this.rasterLayers[id]; } } - removeFeed(feed) { - const feedLayer = this.feedLayers[feed.id]; - if (feedLayer) { - this.listeners.forEach((listener: any) => { - if (typeof listener.onFeedRemoved === 'function') { - listener.onFeedRemoved(feedLayer); - } + removeFeed(feedLayer: VectorLayer) { + if (this.feedLayers[feedLayer.id]) { + this.listeners.forEach(listener => { + listener.onFeedRemoved?.(feedLayer); }); - delete this.feedLayers[feed.id]; + delete this.feedLayers[feedLayer.id]; } } - - hideFeed(hide) { - this.listeners.forEach((listener: any) => { - if (typeof listener.onHideFeed === 'function') { - listener.onHideFeed(hide); - } - }); - } } diff --git a/web-app/src/app/navigation/navigation.component.html b/web-app/src/app/navigation/navigation.component.html index 163b71ee1..ce99d4917 100644 --- a/web-app/src/app/navigation/navigation.component.html +++ b/web-app/src/app/navigation/navigation.component.html @@ -9,9 +9,6 @@
diff --git a/web-app/src/app/navigation/navigation.component.spec.ts b/web-app/src/app/navigation/navigation.component.spec.ts index 7f1783dda..a2b6e00c4 100644 --- a/web-app/src/app/navigation/navigation.component.spec.ts +++ b/web-app/src/app/navigation/navigation.component.spec.ts @@ -5,7 +5,6 @@ import { FilterService } from '../filter/filter.service'; import { MapService } from '../map/map.service'; import { UserService } from '../user/user.service'; import { EventService } from '../event/event.service'; -import { PollingService } from '../event/polling.service'; import { SessionService } from 'mage-web-app/http/session.service'; import { Router } from '@angular/router'; import { provideHttpClientTesting } from '@angular/common/http/testing'; @@ -25,18 +24,15 @@ describe('Navigation Component', () => { let userService: jasmine.SpyObj; beforeEach(waitForAsync(() => { - filterService = jasmine.createSpyObj('FilterService', ['setFilter', 'removeFilters', 'getIntervalChoice'], { - event$: of(null), - teams$: of([]), - interval$: of({}) + filterService = jasmine.createSpyObj('FilterService', ['setEvent', 'getSavedEventId', 'destroy'], { + event$: of(null) }); - filterService.getIntervalChoice.and.returnValue({ filter: 'all', label: 'All' }); + filterService.getSavedEventId.and.returnValue(null); eventService = jasmine.createSpyObj('EventService', ['query', 'init', 'destroy']); eventService.query.and.returnValue(of([eventA, eventB])); - userService = jasmine.createSpyObj('UserService', ['getRecentEventId', 'logout']); - userService.getRecentEventId.and.returnValue(null); + userService = jasmine.createSpyObj('UserService', ['logout']); TestBed.configureTestingModule({ declarations: [NavigationComponent], @@ -46,7 +42,6 @@ describe('Navigation Component', () => { { provide: MapService, useValue: jasmine.createSpyObj('MapService', ['init', 'destroy', 'onLocationStop']) }, { provide: UserService, useValue: userService }, { provide: EventService, useValue: eventService }, - { provide: PollingService, useValue: jasmine.createSpyObj('PollingService', ['getPollingInterval', 'setPollingInterval']) }, { provide: SessionService, useValue: { amAdmin: false } }, { provide: Router, useValue: jasmine.createSpyObj('Router', ['navigate']) }, provideHttpClient(withInterceptorsFromDi()), @@ -64,18 +59,18 @@ describe('Navigation Component', () => { expect(component).toBeTruthy(); }); - it('selects the first event when there is no recently used event', () => { + it('selects the first event when there is no saved event', () => { component.ngOnInit(); - expect(filterService.setFilter).toHaveBeenCalledWith({ event: eventA }); + expect(filterService.setEvent).toHaveBeenCalledWith(eventA); }); - it('selects the recently used event when one is saved', () => { - userService.getRecentEventId.and.returnValue(eventB.id); + it('selects the saved event when one is stored', () => { + filterService.getSavedEventId.and.returnValue(eventB.id); component.ngOnInit(); - expect(filterService.setFilter).toHaveBeenCalledWith({ event: eventB }); + expect(filterService.setEvent).toHaveBeenCalledWith(eventB); }); it('does not select an event when none are available', () => { @@ -83,7 +78,18 @@ describe('Navigation Component', () => { component.ngOnInit(); - expect(filterService.setFilter).not.toHaveBeenCalled(); + expect(filterService.setEvent).not.toHaveBeenCalled(); + }); + + it('does not select an event when the events arrive after the component is destroyed', () => { + const events$ = new Subject(); + eventService.query.and.returnValue(events$.asObservable()); + component.ngOnInit(); + fixture.destroy(); + + events$.next([eventA, eventB]); + + expect(filterService.setEvent).not.toHaveBeenCalled(); }); it('sorts the event list by name for the picker', () => { @@ -107,12 +113,12 @@ describe('Navigation Component', () => { component.eventSearchControl.setValue('bravo'); }); - it('calls setFilter when an event is selected from the picker', () => { + it('calls setEvent when an event is selected from the picker', () => { component.ngOnInit(); component.onSelectEvent(eventB); - expect(filterService.setFilter).toHaveBeenCalledWith({ event: eventB }); + expect(filterService.setEvent).toHaveBeenCalledWith(eventB); }); it('does not show the "no event" placeholder until the event query resolves', () => { @@ -128,12 +134,12 @@ describe('Navigation Component', () => { expect(component.eventsLoaded).toBe(true); }); - it('unsubscribes and cleans up on destroy', () => { + it('cleans up on destroy', () => { component.ngOnInit(); component.ngOnDestroy(); - expect(filterService.removeFilters).toHaveBeenCalled(); expect(eventService.destroy).toHaveBeenCalled(); + expect(filterService.destroy).toHaveBeenCalled(); }); }); diff --git a/web-app/src/app/navigation/navigation.component.ts b/web-app/src/app/navigation/navigation.component.ts index 731c5d115..9f04ec29d 100644 --- a/web-app/src/app/navigation/navigation.component.ts +++ b/web-app/src/app/navigation/navigation.component.ts @@ -16,8 +16,7 @@ import { FilterService } from '../filter/filter.service'; import { MapService } from '../map/map.service'; import { UserService } from '../user/user.service'; import { EventService } from '../event/event.service'; -import { PollingService } from '../event/polling.service'; -import * as _ from 'underscore'; +import { MageEvent } from '../entities/event/entities.event'; import { Router } from '@angular/router'; import { SessionService } from 'mage-web-app/http/session.service'; @@ -43,9 +42,6 @@ export class NavigationComponent implements OnInit, OnDestroy { state = 'map'; filteredEvent: any = {}; eventsLoaded = false; - filteredTeams: any; - filteredInterval: any; - feedChangedUsers = {}; isAdmin: boolean = false; constructor( @@ -55,7 +51,6 @@ export class NavigationComponent implements OnInit, OnDestroy { private userService: UserService, private eventService: EventService, private filterService: FilterService, - private pollingService: PollingService, private destroyRef: DestroyRef ) {} @@ -64,49 +59,21 @@ export class NavigationComponent implements OnInit, OnDestroy { .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((event) => this.onEventSelected(event)); - this.filterService.teams$ - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((teams) => { - this.filteredTeams = _.map(teams, (t) => t.name).join(', '); - }); - - this.filterService.interval$ - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(() => { - const intervalChoice = this.filterService.getIntervalChoice(); - if (intervalChoice.filter !== 'all') { - if (intervalChoice.filter === 'custom') { - // TODO format custom time interval - this.filteredInterval = 'Custom time interval'; - } else { - this.filteredInterval = intervalChoice.label; - } - } else { - this.filteredInterval = null; - } - }); - this.filteredEvents = this.eventSearchControl.valueChanges.pipe( startWith(''), map((value) => this.filterEvents(value ?? '')) ); - this.eventService.query().subscribe((events) => { + this.eventService.query().pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe((events: MageEvent[]) => { this.events = [...events].sort((a, b) => a.name.localeCompare(b.name)); this.eventSearchControl.setValue('', { emitEvent: true }); - const recentEventId = this.userService.getRecentEventId(); - const recentEvent = _.find(events, (event) => { - return event.id === recentEventId; - }); - const event = recentEvent || (events.length > 0 ? events[0] : null); + const savedId = this.filterService.getSavedEventId(); + const event = (savedId != null ? events.find((e) => e.id === savedId) : null) ?? events[0]; if (event) { - this.filterService.setFilter({ event }); - this.pollingService.setPollingInterval( - this.pollingService.getPollingInterval() - ); - } else { - // TODO welcome to mage, sorry you have no events + this.filterService.setEvent(event); } this.eventsLoaded = true; }); @@ -117,11 +84,9 @@ export class NavigationComponent implements OnInit, OnDestroy { } ngOnDestroy(): void { - this.filterService.removeFilters(); - this.mapService.destroy(); - this.eventService.destroy(); + this.filterService.destroy(); } toggleFeed(): void { @@ -144,7 +109,7 @@ export class NavigationComponent implements OnInit, OnDestroy { } onSelectEvent(event: any): void { - this.filterService.setFilter({ event }); + this.filterService.setEvent(event); } private filterEvents(name: string): any[] { @@ -154,8 +119,6 @@ export class NavigationComponent implements OnInit, OnDestroy { } private onEventSelected(event: any) { - this.feedChangedUsers = {}; - if (event) { this.filteredEvent = event; diff --git a/web-app/src/app/observation/observation-delete/observation-delete.component.spec.ts b/web-app/src/app/observation/observation-delete/observation-delete.component.spec.ts index d2bc4de00..ff26354db 100644 --- a/web-app/src/app/observation/observation-delete/observation-delete.component.spec.ts +++ b/web-app/src/app/observation/observation-delete/observation-delete.component.spec.ts @@ -1,20 +1,33 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { MatDialogModule as MatDialogModule, MatDialogRef as MatDialogRef } from '@angular/material/dialog'; - +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { of } from 'rxjs'; import { ObservationDeleteComponent } from './observation-delete.component'; +import { EventService } from '../../event/event.service'; -/* TODO tests MUST contain at least 1 test describe('ObservationDeleteComponent', () => { let component: ObservationDeleteComponent; let fixture: ComponentFixture; + let eventService: jasmine.SpyObj; + let dialogRef: jasmine.SpyObj>; + + const mageEvent: any = { id: 1, name: 'Test Event', forms: [] }; + const observation: any = { id: 'obs1', eventId: 1 }; beforeEach(waitForAsync(() => { + eventService = jasmine.createSpyObj('EventService', ['getEventById', 'archiveObservation']); + dialogRef = jasmine.createSpyObj('MatDialogRef', ['close']); + eventService.getEventById.and.returnValue(mageEvent); + TestBed.configureTestingModule({ - imports: [MatDialogModule], declarations: [ObservationDeleteComponent], - providers: [{ provide: MatDialogRef, useValue: {} }] - }) - .compileComponents(); + providers: [ + { provide: MatDialogRef, useValue: dialogRef }, + { provide: EventService, useValue: eventService }, + { provide: MAT_DIALOG_DATA, useValue: observation } + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); })); beforeEach(() => { @@ -23,8 +36,26 @@ describe('ObservationDeleteComponent', () => { fixture.detectChanges(); }); - // it('should create', () => { - // expect(component).toBeTruthy(); - // }); + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('looks up the event the observation belongs to', () => { + expect(eventService.getEventById).toHaveBeenCalledWith(observation.eventId); + expect(component.event).toEqual(mageEvent); + }); + + it('closes the dialog with "cancel" when closed without deleting', () => { + component.close(); + expect(dialogRef.close).toHaveBeenCalledWith('cancel'); + }); + + it('archives the observation and closes with "delete" on success', () => { + eventService.archiveObservation.and.returnValue(of(observation)); + + component.delete(); + + expect(eventService.archiveObservation).toHaveBeenCalledWith(observation); + expect(dialogRef.close).toHaveBeenCalledWith('delete'); + }); }); -*/ diff --git a/web-app/src/app/observation/observation-delete/observation-delete.component.ts b/web-app/src/app/observation/observation-delete/observation-delete.component.ts index cca666810..b884493df 100644 --- a/web-app/src/app/observation/observation-delete/observation-delete.component.ts +++ b/web-app/src/app/observation/observation-delete/observation-delete.component.ts @@ -1,11 +1,7 @@ import { Component, Inject } from '@angular/core' import { MatDialogRef as MatDialogRef, MAT_DIALOG_DATA as MAT_DIALOG_DATA } from '@angular/material/dialog'; import { EventService } from '../../event/event.service'; - -export interface Observation { - id: string; - eventId: number; -} +import { Observation } from '../../entities/observation/entities.observation'; @Component({ selector: 'observation-delete', diff --git a/web-app/src/app/observation/observation-edit/observation-edit.component.spec.ts b/web-app/src/app/observation/observation-edit/observation-edit.component.spec.ts index e3bbcbbc9..0d7d7c19e 100644 --- a/web-app/src/app/observation/observation-edit/observation-edit.component.spec.ts +++ b/web-app/src/app/observation/observation-edit/observation-edit.component.spec.ts @@ -45,7 +45,7 @@ describe('ObservationEditComponent', () => { beforeEach(waitForAsync(() => { userService = jasmine.createSpyObj('UserService', ['getEventPreferences']); eventService = jasmine.createSpyObj('EventService', ['getEventById', 'getFormsForEvent']); - eventService.getEventById.and.callFake((eventId: number) => ({ id: eventId })); + eventService.getEventById.and.callFake((eventId: number): any => ({ id: eventId })); eventService.getFormsForEvent.and.returnValue([]); userService.getEventPreferences.and.returnValue(of(null)); diff --git a/web-app/src/app/observation/observation-filter/observation-field-filter.component.html b/web-app/src/app/observation/observation-filter/observation-field-filter.component.html index 6be081e46..51435bb68 100644 --- a/web-app/src/app/observation/observation-filter/observation-field-filter.component.html +++ b/web-app/src/app/observation/observation-filter/observation-field-filter.component.html @@ -1,63 +1,69 @@ -
- - Keyword Filter - - info_outline - -
-
Condition Filter - - and - - or + @for (group of conditionGroups; track $index; let groupIndex = $index) { + @if (groupIndex > 0) { + and + } + @for (condition of group.conditions; track $index; let conditionIndex = $index) { + @if (conditionIndex > 0) { + or + } {{ condition.displayValue }} cancel - - + } + } - - {{ selectedField.title }} - cancel - - - {{ selectedOperatorDisplay }} - cancel - - - {{ pendingValueDisplay }} - cancel - + @if (selectedField) { + + {{ selectedField.title }} + cancel + + } + @if (selectedOperator) { + + {{ selectedOperatorDisplay }} + cancel + + } + @if (pendingValueDisplay) { + + {{ pendingValueDisplay }} + cancel + + } - + @if (!isDateValueState && !isNullOperatorState && !pendingValueDisplay) { + + } - + @if (isDateValueState) { + + } - + @if (isDateValueState) { + + } info_outline @@ -65,31 +71,39 @@ - - - - + @if (conditionState === 'field') { + @for (group of filteredFormGroups | async; track group.formName) { + + @for (option of group.fields; track option) { + + + {{ option.display }} + + } + + } + } @else { + @for (option of filteredOptions | async; track option) { + {{ option.display }} - - - - - {{ option.display }} - - + } + } - Finish or remove this condition. + @if (showInvalidNumberError) { + Enter a valid number. + } @else if (showIncompleteError && hasIncompleteCondition) { + Finish or remove this condition. + }
- - - - - - - + @if (!hasConditions) { + + } @else { + + + }
diff --git a/web-app/src/app/observation/observation-filter/observation-field-filter.component.spec.ts b/web-app/src/app/observation/observation-filter/observation-field-filter.component.spec.ts new file mode 100644 index 000000000..deabfe1b2 --- /dev/null +++ b/web-app/src/app/observation/observation-filter/observation-field-filter.component.spec.ts @@ -0,0 +1,519 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { SimpleChange } from '@angular/core'; +import { MatMomentDateModule } from '@angular/material-moment-adapter'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { ObservationFieldFilterComponent } from './observation-field-filter.component'; +import { Condition, SimpleCondition } from '../../entities/observation/filter/entities.observation.filter'; + +const forms: any[] = [ + { + id: 1, + name: 'Form A', + color: '#111111', + archived: false, + fields: [ + { id: 1, name: 'severity', title: 'Severity', type: 'textfield' }, + { id: 2, name: 'count', title: 'Count', type: 'numberfield' }, + { id: 3, name: 'reported', title: 'Reported', type: 'date' }, + { id: 4, name: 'verified', title: 'Verified', type: 'checkbox' }, + { + id: 5, name: 'status', title: 'Status', type: 'dropdown', + choices: [{ title: 'Open' }, { title: 'Closed' }, { title: '', blank: true }] + }, + { id: 6, name: 'secret', title: 'Secret', type: 'password' }, + { id: 7, name: 'oldField', title: 'Old Field', type: 'textfield', archived: true }, + ] + }, + { + id: 2, + name: 'Archived Form', + color: '#222222', + archived: true, + fields: [ + { id: 8, name: 'ignored', title: 'Ignored', type: 'textfield' } + ] + } +]; + +function fieldOption(component: ObservationFieldFilterComponent, formId: number, fieldName: string) { + return (component as any).allFieldGroups + .flatMap((g: any) => g.fields) + .find((f: any) => f.formId === formId && f.field.name === fieldName); +} + +function selectField(component: ObservationFieldFilterComponent, formId: number, fieldName: string): void { + component.onOptionSelected({ option: { value: fieldOption(component, formId, fieldName) } } as any); +} + +function selectComparator(component: ObservationFieldFilterComponent, operator: string, display: string): void { + component.onOptionSelected({ option: { value: { type: 'comparator', operator, display } } } as any); +} + +function selectValueOption(component: ObservationFieldFilterComponent, value: any, display: string): void { + component.onOptionSelected({ option: { value: { type: 'value', value, display } } } as any); +} + +describe('ObservationFieldFilterComponent', () => { + let component: ObservationFieldFilterComponent; + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ObservationFieldFilterComponent, MatMomentDateModule, BrowserAnimationsModule] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ObservationFieldFilterComponent); + component = fixture.componentInstance; + }); + + function setForms(): void { + component.forms = forms; + component.ngOnChanges({ forms: new SimpleChange(null, forms, true) }); + } + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('ngOnChanges - forms', () => { + it('builds field groups excluding archived forms, archived fields, and excluded field types', () => { + setForms(); + + const groups = (component as any).allFieldGroups; + expect(groups.length).toBe(1); + expect(groups[0].formName).toBe('Form A'); + + const fieldNames = groups[0].fields.map((f: any) => f.field.name); + expect(fieldNames).toEqual(['severity', 'count', 'reported', 'verified', 'status']); + }); + + it('resets condition groups and builder state', () => { + setForms(); + selectField(component, 1, 'severity'); + expect(component.conditionState).toBe('comparator'); + + setForms(); + + expect(component.conditionState).toBe('field'); + expect(component.conditionGroups).toEqual([]); + }); + }); + + describe('ngOnChanges - condition', () => { + it('populates a single simple condition', () => { + setForms(); + const condition: SimpleCondition = { formId: 1, field: 'severity', operator: '=', value: 'high' }; + + component.condition = condition; + component.ngOnChanges({ condition: new SimpleChange(null, condition, true) }); + + expect(component.conditionGroups.length).toBe(1); + expect(component.conditionGroups[0].conditions.length).toBe(1); + expect(component.conditionGroups[0].conditions[0].displayValue).toBe('Severity is high'); + }); + + it('populates an "or" node as a single group with multiple conditions', () => { + setForms(); + const condition: Condition = { + or: [ + { formId: 1, field: 'severity', operator: '=', value: 'high' }, + { formId: 1, field: 'severity', operator: '=', value: 'medium' } + ] + }; + + component.condition = condition; + component.ngOnChanges({ condition: new SimpleChange(null, condition, true) }); + + expect(component.conditionGroups.length).toBe(1); + expect(component.conditionGroups[0].conditions.length).toBe(2); + }); + + it('populates an "and" node as multiple groups', () => { + setForms(); + const condition: Condition = { + and: [ + { formId: 1, field: 'severity', operator: '=', value: 'high' }, + { formId: 1, field: 'count', operator: '>', value: 5 } + ] + }; + + component.condition = condition; + component.ngOnChanges({ condition: new SimpleChange(null, condition, true) }); + + expect(component.conditionGroups.length).toBe(2); + expect(component.conditionGroups[0].conditions.length).toBe(1); + expect(component.conditionGroups[1].conditions.length).toBe(1); + }); + + it('drops a condition referencing a field that no longer exists in the current forms', () => { + setForms(); + const condition: SimpleCondition = { formId: 1, field: 'doesNotExist', operator: '=', value: 'x' }; + + component.condition = condition; + component.ngOnChanges({ condition: new SimpleChange(null, condition, true) }); + + expect(component.conditionGroups).toEqual([{ conditions: [] }]); + }); + + it('does nothing when the condition is null', () => { + setForms(); + component.condition = null; + component.ngOnChanges({ condition: new SimpleChange(null, null, true) }); + + expect(component.conditionGroups).toEqual([]); + }); + }); + + describe('field/comparator/value selection', () => { + beforeEach(() => setForms()); + + it('selecting a field moves to the comparator state', () => { + selectField(component, 1, 'severity'); + + expect(component.conditionState).toBe('comparator'); + expect(component.selectedField.name).toBe('severity'); + expect(component.selectedFormId).toBe(1); + expect(component.selectedFormName).toBe('Form A'); + }); + + it('selecting a non-null comparator moves to the value state', () => { + selectField(component, 1, 'severity'); + selectComparator(component, '=', 'is'); + + expect(component.conditionState).toBe('value'); + expect(component.selectedOperator).toBe('='); + expect(component.isNullOperatorState).toBeFalse(); + }); + + it('selecting a null-check comparator moves to the value state with hasValue already true', () => { + selectField(component, 1, 'severity'); + selectComparator(component, 'IS NULL', 'is empty'); + + expect(component.conditionState).toBe('value'); + expect(component.isNullOperatorState).toBeTrue(); + expect(component.hasValue).toBeTrue(); + }); + + it('selecting a value option sets the pending value without leaving the value state', () => { + selectField(component, 1, 'status'); + selectComparator(component, '=', 'is'); + selectValueOption(component, 'Open', 'Open'); + + expect(component.conditionState).toBe('value'); + expect(component.pendingValue).toBe('Open'); + expect(component.pendingValueDisplay).toBe('Open'); + }); + }); + + describe('onInputKeydown', () => { + beforeEach(() => setForms()); + + it('Enter finalizes the current condition and returns to the field state', () => { + selectField(component, 1, 'severity'); + selectComparator(component, '=', 'is'); + component.inputControl.setValue('high'); + + component.onInputKeydown({ key: 'Enter' } as KeyboardEvent); + + expect(component.conditionState).toBe('field'); + expect(component.conditionGroups.length).toBe(1); + expect(component.conditionGroups[0].conditions[0].condition).toEqual({ formId: 1, field: 'severity', operator: '=', value: 'high' }); + }); + + it('Enter with no value does nothing', () => { + selectField(component, 1, 'severity'); + selectComparator(component, '=', 'is'); + + component.onInputKeydown({ key: 'Enter' } as KeyboardEvent); + + expect(component.conditionState).toBe('value'); + expect(component.conditionGroups).toEqual([]); + }); + + it('Backspace on empty input in the value state removes the operator chip', () => { + selectField(component, 1, 'severity'); + selectComparator(component, '=', 'is'); + + component.onInputKeydown({ key: 'Backspace' } as KeyboardEvent); + + expect(component.conditionState).toBe('comparator'); + expect(component.selectedOperator).toBeNull(); + expect(component.selectedField.name).toBe('severity'); + }); + + it('Backspace on empty input in the comparator state removes the field chip', () => { + selectField(component, 1, 'severity'); + + component.onInputKeydown({ key: 'Backspace' } as KeyboardEvent); + + expect(component.conditionState).toBe('field'); + expect(component.selectedField).toBeNull(); + }); + + it('Backspace with non-empty input does nothing', () => { + selectField(component, 1, 'severity'); + selectComparator(component, '=', 'is'); + component.inputControl.setValue('high'); + + component.onInputKeydown({ key: 'Backspace' } as KeyboardEvent); + + expect(component.conditionState).toBe('value'); + expect(component.selectedOperator).toBe('='); + }); + }); + + describe('addOr / addAnd', () => { + beforeEach(() => setForms()); + + it('addOr adds a second condition to the same group', () => { + selectField(component, 1, 'severity'); + selectComparator(component, '=', 'is'); + component.inputControl.setValue('high'); + component.addOr(); + + selectField(component, 1, 'severity'); + selectComparator(component, '=', 'is'); + component.inputControl.setValue('medium'); + component.addOr(); + + expect(component.conditionGroups.length).toBe(1); + expect(component.conditionGroups[0].conditions.length).toBe(2); + }); + + it('addAnd starts a new group', () => { + selectField(component, 1, 'severity'); + selectComparator(component, '=', 'is'); + component.inputControl.setValue('high'); + component.addAnd(); + + selectField(component, 1, 'count'); + selectComparator(component, '>', '>'); + component.inputControl.setValue('5'); + component.addAnd(); + + expect(component.conditionGroups.length).toBe(2); + expect(component.conditionGroups[0].conditions.length).toBe(1); + expect(component.conditionGroups[1].conditions.length).toBe(1); + }); + + it('addOr/addAnd on a null-check comparator finalizes without a value', () => { + selectField(component, 1, 'severity'); + selectComparator(component, 'IS NULL', 'is empty'); + + component.addOr(); + + expect(component.conditionGroups[0].conditions[0].condition).toEqual({ formId: 1, field: 'severity', operator: 'IS NULL' }); + }); + + it('addOr does nothing outside the value state', () => { + selectField(component, 1, 'severity'); + component.addOr(); + expect(component.conditionGroups).toEqual([]); + }); + }); + + describe('emitted condition shape', () => { + let emitted: (Condition | undefined)[]; + + beforeEach(() => { + setForms(); + emitted = []; + component.conditionChanged.subscribe(c => emitted.push(c)); + }); + + it('emits the raw simple condition for a single condition', () => { + selectField(component, 1, 'severity'); + selectComparator(component, '=', 'is'); + component.inputControl.setValue('high'); + component.addOr(); + + expect(emitted[emitted.length - 1]).toEqual({ formId: 1, field: 'severity', operator: '=', value: 'high' }); + }); + + it('emits an "or" node for multiple conditions in the same group', () => { + selectField(component, 1, 'severity'); + selectComparator(component, '=', 'is'); + component.inputControl.setValue('high'); + component.addOr(); + + selectField(component, 1, 'severity'); + selectComparator(component, '=', 'is'); + component.inputControl.setValue('medium'); + component.addOr(); + + expect(emitted[emitted.length - 1]).toEqual({ + or: [ + { formId: 1, field: 'severity', operator: '=', value: 'high' }, + { formId: 1, field: 'severity', operator: '=', value: 'medium' } + ] + }); + }); + + it('emits an "and" node for multiple groups', () => { + selectField(component, 1, 'severity'); + selectComparator(component, '=', 'is'); + component.inputControl.setValue('high'); + component.addAnd(); + + selectField(component, 1, 'count'); + selectComparator(component, '>', '>'); + component.inputControl.setValue('5'); + component.addAnd(); + + expect(emitted[emitted.length - 1]).toEqual({ + and: [ + { formId: 1, field: 'severity', operator: '=', value: 'high' }, + { formId: 1, field: 'count', operator: '>', value: 5 } + ] + }); + }); + + it('emits undefined once the last condition is removed', () => { + selectField(component, 1, 'severity'); + selectComparator(component, '=', 'is'); + component.inputControl.setValue('high'); + component.addOr(); + + component.removeCondition(0, 0); + + expect(emitted[emitted.length - 1]).toBeUndefined(); + expect(component.conditionGroups).toEqual([]); + }); + }); + + describe('removeCondition', () => { + beforeEach(() => setForms()); + + it('removes a single condition and its now-empty group', () => { + selectField(component, 1, 'severity'); + selectComparator(component, '=', 'is'); + component.inputControl.setValue('high'); + component.addAnd(); + + selectField(component, 1, 'count'); + selectComparator(component, '>', '>'); + component.inputControl.setValue('5'); + component.addOr(); + + component.removeCondition(0, 0); + + expect(component.conditionGroups.length).toBe(1); + expect(component.conditionGroups[0].conditions[0].field.name).toBe('count'); + }); + + it('removes one condition from a group without removing the group', () => { + selectField(component, 1, 'severity'); + selectComparator(component, '=', 'is'); + component.inputControl.setValue('high'); + component.addOr(); + + selectField(component, 1, 'severity'); + selectComparator(component, '=', 'is'); + component.inputControl.setValue('medium'); + component.addOr(); + + component.removeCondition(0, 0); + + expect(component.conditionGroups.length).toBe(1); + expect(component.conditionGroups[0].conditions.length).toBe(1); + expect((component.conditionGroups[0].conditions[0].condition as any).value).toBe('medium'); + }); + }); + + describe('clear', () => { + beforeEach(() => setForms()); + + it('removes every condition without emitting a change', () => { + selectField(component, 1, 'severity'); + selectComparator(component, '=', 'is'); + component.inputControl.setValue('high'); + component.addOr(); + const emitted: any[] = []; + component.conditionChanged.subscribe(condition => emitted.push(condition)); + + component.clear(); + + expect(component.conditionGroups).toEqual([]); + expect(emitted).toEqual([]); + }); + + it('abandons a condition that was still being built', () => { + selectField(component, 1, 'severity'); + selectComparator(component, '=', 'is'); + + component.clear(); + + expect(component.conditionState).toBe('field'); + expect(component.selectedField).toBeNull(); + }); + }); + + describe('numberfield value parsing', () => { + beforeEach(() => setForms()); + + it('parses valid numeric text to a number', () => { + selectField(component, 1, 'count'); + selectComparator(component, '=', 'is'); + component.inputControl.setValue('5'); + component.addOr(); + + expect((component.conditionGroups[0].conditions[0].condition as any).value).toBe(5); + }); + + it('leaves Add enabled for unparseable text, but shows an error and does not finalize once clicked', () => { + selectField(component, 1, 'count'); + selectComparator(component, '=', 'is'); + component.inputControl.setValue('not-a-number'); + + expect(component.hasValue).toBeTrue(); + expect(component.showInvalidNumberError).toBeFalse(); + + component.addOr(); + + expect(component.showInvalidNumberError).toBeTrue(); + expect(component.conditionState).toBe('value'); + expect(component.conditionGroups).toEqual([]); + }); + + it('clears the error once the field/operator/value chips are reset', () => { + selectField(component, 1, 'count'); + selectComparator(component, '=', 'is'); + component.inputControl.setValue('not-a-number'); + component.addOr(); + expect(component.showInvalidNumberError).toBeTrue(); + + component.removeOperatorChip(); + + expect(component.showInvalidNumberError).toBeFalse(); + }); + }); + + describe('getters', () => { + beforeEach(() => setForms()); + + it('hasIncompleteCondition is true once a field is selected and false once reset', () => { + expect(component.hasIncompleteCondition).toBeFalse(); + selectField(component, 1, 'severity'); + expect(component.hasIncompleteCondition).toBeTrue(); + }); + + it('inputPlaceholder reflects the current state', () => { + expect(component.inputPlaceholder).toBe('Add Condition...'); + selectField(component, 1, 'severity'); + expect(component.inputPlaceholder).toBe('Select operator...'); + selectComparator(component, '=', 'is'); + expect(component.inputPlaceholder).toBe('Enter value...'); + }); + + it('hasConditions reflects whether any group has conditions', () => { + expect(component.hasConditions).toBeFalse(); + selectField(component, 1, 'severity'); + selectComparator(component, '=', 'is'); + component.inputControl.setValue('high'); + component.addOr(); + expect(component.hasConditions).toBeTrue(); + }); + }); +}); diff --git a/web-app/src/app/observation/observation-filter/observation-field-filter.component.ts b/web-app/src/app/observation/observation-filter/observation-field-filter.component.ts index f6698c219..64326321d 100644 --- a/web-app/src/app/observation/observation-filter/observation-field-filter.component.ts +++ b/web-app/src/app/observation/observation-filter/observation-field-filter.component.ts @@ -1,11 +1,17 @@ -import { Component, ElementRef, EventEmitter, Input, OnChanges, OnDestroy, Output, SimpleChanges, ViewChild } from '@angular/core' -import { FormControl } from '@angular/forms' +import { Component, ElementRef, EventEmitter, Input, OnChanges, Output, SimpleChanges, ViewChild } from '@angular/core' +import { AsyncPipe } from '@angular/common' +import { FormControl, ReactiveFormsModule } from '@angular/forms' import { ErrorStateMatcher } from '@angular/material/core' -import { MatAutocompleteSelectedEvent, MatAutocompleteTrigger } from '@angular/material/autocomplete' -import { MatChipGrid } from '@angular/material/chips' -import { MatDatepicker, MatDatepickerInputEvent } from '@angular/material/datepicker' -import { Observable, Subject, map, startWith, debounceTime, distinctUntilChanged, takeUntil } from 'rxjs' -import { ObservationFieldFilter, SimpleCondition } from '../../entities/observation/filter/entities.observation.filter' +import { MatAutocompleteModule, MatAutocompleteSelectedEvent, MatAutocompleteTrigger } from '@angular/material/autocomplete' +import { MatButtonModule } from '@angular/material/button' +import { MatChipGrid, MatChipsModule } from '@angular/material/chips' +import { MatDatepicker, MatDatepickerInputEvent, MatDatepickerModule } from '@angular/material/datepicker' +import { MatFormFieldModule } from '@angular/material/form-field' +import { MatIconModule } from '@angular/material/icon' +import { MatInputModule } from '@angular/material/input' +import { MatTooltipModule } from '@angular/material/tooltip' +import { Observable, map, startWith } from 'rxjs' +import { Condition, SimpleCondition } from '../../entities/observation/filter/entities.observation.filter' import { AutocompleteOption, buildFieldGroups, @@ -25,13 +31,24 @@ import { selector: 'observation-field-filter', templateUrl: './observation-field-filter.component.html', styleUrls: ['./observation-field-filter.component.scss'], - standalone: false + imports: [ + AsyncPipe, + ReactiveFormsModule, + MatAutocompleteModule, + MatButtonModule, + MatChipsModule, + MatDatepickerModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatTooltipModule + ] }) -export class ObservationFieldFilterComponent implements OnChanges, OnDestroy { +export class ObservationFieldFilterComponent implements OnChanges { @Input() forms: any[] = [] - @Input() filter: ObservationFieldFilter | null = null + @Input() condition: Condition | null | undefined = null @Input() showIncompleteError = false - @Output() filterChanged = new EventEmitter() + @Output() conditionChanged = new EventEmitter() @ViewChild('filterInput') filterInput: ElementRef @ViewChild(MatAutocompleteTrigger) autoTrigger: MatAutocompleteTrigger @@ -39,10 +56,11 @@ export class ObservationFieldFilterComponent implements OnChanges, OnDestroy { @ViewChild('chipGridCondition') chipGridCondition?: MatChipGrid incompleteErrorStateMatcher: ErrorStateMatcher = { - isErrorState: () => this.showIncompleteError && this.hasIncompleteCondition + isErrorState: () => this.showInvalidNumberError || (this.showIncompleteError && this.hasIncompleteCondition) } - keywordControl = new FormControl('') + showInvalidNumberError = false + inputControl = new FormControl('') conditionGroups: FilterConditionGroup[] = [] filteredOptions: Observable @@ -60,7 +78,6 @@ export class ObservationFieldFilterComponent implements OnChanges, OnDestroy { pendingValueDisplay: string = null private allFieldGroups: FormGroup[] = [] - private destroy$ = new Subject() get hasConditions(): boolean { return this.conditionGroups.some(g => g.conditions.length > 0) @@ -81,6 +98,15 @@ export class ObservationFieldFilterComponent implements OnChanges, OnDestroy { return (this.inputControl.value || '').toString().trim().length > 0 } + get hasInvalidNumberValue(): boolean { + if (this.conditionState !== 'value') return false + if (this.selectedField?.type !== 'numberfield') return false + if (this.pendingValue != null) return false + const input = (this.inputControl.value || '').toString().trim() + if (!input) return false + return isNaN(Number(input)) + } + // true whenever the user has started picking a field/operator/value // but hasn't clicked Add or New Group to actually commit it yet. get hasIncompleteCondition(): boolean { @@ -99,27 +125,18 @@ export class ObservationFieldFilterComponent implements OnChanges, OnDestroy { if (changes.forms) { this.allFieldGroups = buildFieldGroups(this.forms) this.conditionGroups = [] - this.keywordControl.setValue('') this.resetState() - this.setupKeywordSubscription() } - if (changes.filter) { - this.populateFilter(this.filter) + if (changes.condition) { + this.populateCondition(this.condition) } - // showIncompleteError is the other half of incompleteErrorStateMatcher; - // mat-chip-grid won't re-evaluate it on its own (see updateChipGridErrorState). if (changes.showIncompleteError) { this.updateChipGridErrorState() } } - ngOnDestroy(): void { - this.destroy$.next() - this.destroy$.complete() - } - onOptionSelected(event: MatAutocompleteSelectedEvent): void { const option: AutocompleteOption = event.option.value @@ -230,7 +247,7 @@ export class ObservationFieldFilterComponent implements OnChanges, OnDestroy { if (this.conditionState !== 'value') return if (this.isNullOperatorState) { this.finalizeCondition(); return } const value = this.resolveValue() - if (value == null) return + if (value == null) { this.blockIfInvalidNumber(); return } this.finalizeCondition(value) } @@ -241,7 +258,7 @@ export class ObservationFieldFilterComponent implements OnChanges, OnDestroy { return } const value = this.resolveValue() - if (value == null) return + if (value == null) { this.blockIfInvalidNumber(); return } this.finalizeCondition(value) } @@ -253,18 +270,29 @@ export class ObservationFieldFilterComponent implements OnChanges, OnDestroy { return } const value = this.resolveValue() - if (value == null) return + if (value == null) { this.blockIfInvalidNumber(); return } this.conditionGroups.push({ conditions: [] }) this.finalizeCondition(value) } + private blockIfInvalidNumber(): void { + if (!this.hasInvalidNumberValue) return + this.showInvalidNumberError = true + this.updateChipGridErrorState() + } + + clear(): void { + this.conditionGroups = [] + this.resetState() + } + removeCondition(groupIndex: number, conditionIndex: number): void { const group = this.conditionGroups[groupIndex] group.conditions.splice(conditionIndex, 1) if (group.conditions.length === 0) { this.conditionGroups.splice(groupIndex, 1) } - this.emitFilter() + this.emitCondition() } private clearInput(): void { @@ -272,6 +300,10 @@ export class ObservationFieldFilterComponent implements OnChanges, OnDestroy { if (this.filterInput) { this.filterInput.nativeElement.value = '' } + if (this.showInvalidNumberError) { + this.showInvalidNumberError = false + this.updateChipGridErrorState() + } } private resolveValue(): any { @@ -396,31 +428,13 @@ export class ObservationFieldFilterComponent implements OnChanges, OnDestroy { condition, }) - this.emitFilter() + this.emitCondition() this.resetState() } - private setupKeywordSubscription(): void { - this.destroy$.next() - this.keywordControl.valueChanges.pipe( - debounceTime(300), - distinctUntilChanged(), - takeUntil(this.destroy$) - ).subscribe(() => { - this.emitFilter() - }) - } - - private populateFilter(filter: ObservationFieldFilter | null): void { - if (!filter) return - - if (filter.keyword != null) { - this.keywordControl.setValue(filter.keyword, { emitEvent: false }) - } - - if (filter.condition) { - this.conditionGroups = this.conditionGroupsFromFilter(filter.condition) - } + private populateCondition(condition: Condition | null | undefined): void { + if (!condition) return + this.conditionGroups = this.conditionGroupsFromFilter(condition) } private conditionGroupsFromFilter(condition: any): FilterConditionGroup[] { @@ -467,7 +481,7 @@ export class ObservationFieldFilterComponent implements OnChanges, OnDestroy { } } - private emitFilter(): void { + private emitCondition(): void { const groups = this.conditionGroups .filter(g => g.conditions.length > 0) .map(group => { @@ -477,17 +491,13 @@ export class ObservationFieldFilterComponent implements OnChanges, OnDestroy { return { or: group.conditions.map(c => c.condition) } }) - const filter: ObservationFieldFilter = {} - - const keyword = (this.keywordControl.value || '').toString().trim() - if (keyword) filter.keyword = keyword - + let condition: Condition | undefined if (groups.length === 1) { - filter.condition = groups[0] + condition = groups[0] } else if (groups.length > 1) { - filter.condition = { and: groups } + condition = { and: groups } } - this.filterChanged.emit(filter) + this.conditionChanged.emit(condition) } } diff --git a/web-app/src/app/observation/observation-filter/observation-filter.component.html b/web-app/src/app/observation/observation-filter/observation-filter.component.html new file mode 100644 index 000000000..3fe158693 --- /dev/null +++ b/web-app/src/app/observation/observation-filter/observation-filter.component.html @@ -0,0 +1,56 @@ +

Observation Filter

+ +
+
+
+
Time Range
+ + + @for (choice of intervalChoices; track choice.filter) { + {{choice.label}} + } + + +
+ + @if (intervalChoice().filter === 'custom') { +
+
+ + +
+ +
+ } + + + +
+
Filter by
+
+ + + +
+
+ + +
+
+ +
+ + +
diff --git a/web-app/src/app/observation/observation-filter/observation-filter.component.scss b/web-app/src/app/observation/observation-filter/observation-filter.component.scss new file mode 100644 index 000000000..8634efbd4 --- /dev/null +++ b/web-app/src/app/observation/observation-filter/observation-filter.component.scss @@ -0,0 +1,48 @@ +mat-form-field { + width: 100%; +} + +.dialog-content { + display: flex; + flex-direction: column; + gap: 20px; + padding-top: 8px; +} + +.time-field { + width: 100%; + margin-bottom: -1.25em; +} + +.datetime { + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.datetime__pickers { + flex: 1 1 260px; + min-width: 260px; +} + +.timezone__button { + flex: 0 0 auto; + white-space: nowrap; +} + +.filter-label { + margin-bottom: 4px; +} + +.filter-toggles { + display: flex; + gap: 4px; +} + +.filter-toggle--active { + background-color: var(--mat-sys-primary); + color: var(--mat-sys-on-primary); + border-radius: 50%; +} diff --git a/web-app/src/app/observation/observation-filter/observation-filter.component.spec.ts b/web-app/src/app/observation/observation-filter/observation-filter.component.spec.ts new file mode 100644 index 000000000..935fb9a20 --- /dev/null +++ b/web-app/src/app/observation/observation-filter/observation-filter.component.spec.ts @@ -0,0 +1,221 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { MatDialogRef } from '@angular/material/dialog'; +import { ObservationFilterDialogComponent } from './observation-filter.component'; +import { FilterService } from '../../filter/filter.service'; +import { DEFAULT_OBSERVATION_FILTER, INTERVAL_CHOICES } from '../../filter/filter.types'; +import { BinaryCondition } from '../../entities/observation/filter/entities.observation.filter'; + +describe('ObservationFilterDialogComponent', () => { + let component: ObservationFilterDialogComponent; + let fixture: ComponentFixture; + let filterService: jasmine.SpyObj; + let dialogRef: jasmine.SpyObj>; + + const mageEvent = { id: 1, name: 'Test Event', teams: [{ id: 't1', name: 'Team 1' }] } as any; + + beforeEach(waitForAsync(() => { + filterService = jasmine.createSpyObj('FilterService', ['getEvent', 'getObservationFilter', 'setObservationFilter']); + dialogRef = jasmine.createSpyObj('MatDialogRef', ['close']); + + filterService.getEvent.and.returnValue(mageEvent); + filterService.getObservationFilter.and.returnValue(null); + + TestBed.configureTestingModule({ + imports: [ObservationFilterDialogComponent], + providers: [ + { provide: MatDialogRef, useValue: dialogRef }, + { provide: FilterService, useValue: filterService } + ] + }).overrideComponent(ObservationFilterDialogComponent, { + set: { imports: [], schemas: [NO_ERRORS_SCHEMA] } + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ObservationFilterDialogComponent); + component = fixture.componentInstance; + }); + + it('should create', () => { + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); + + describe('ngOnInit', () => { + it('populates event and teams from the current event', () => { + fixture.detectChanges(); + expect(component.mageEvent).toEqual(mageEvent); + expect(component.teams).toEqual(mageEvent.teams); + }); + + it('defaults to no filters and the default interval choice when nothing is saved', () => { + fixture.detectChanges(); + expect(component.initialMemberFilter).toBeNull(); + expect(component.memberFilter).toBeNull(); + expect(component.hasAttachments()).toBe(false); + expect(component.isFlaggedImportant()).toBe(false); + expect(component.isUserFavorite()).toBe(false); + expect(component.condition()).toBeUndefined(); + expect(component.intervalChoice()).toEqual(INTERVAL_CHOICES[1]); + expect(component.timeZone()).toBe('local'); + }); + + it('restores the saved filter state', () => { + const savedChoice = { filter: 'custom', label: 'Custom' }; + const startDate = new Date('2024-01-01T00:00:00Z'); + const endDate = new Date('2024-01-02T00:00:00Z'); + const condition: BinaryCondition = { formId: 1, field: 'status', operator: '=', value: 'open' }; + filterService.getObservationFilter.and.returnValue({ + ...DEFAULT_OBSERVATION_FILTER, + memberFilter: { teamIds: ['t1'], userIds: [] }, + hasAttachments: true, + isFlaggedImportant: true, + isUserFavorite: true, + fieldFilter: { keyword: 'flood', condition }, + timeInterval: { choice: savedChoice, options: { startDate, endDate, localTime: false } } + }); + + fixture.detectChanges(); + + expect(component.initialMemberFilter).toEqual({ teamIds: ['t1'], userIds: [] }); + expect(component.memberFilter).toEqual({ teamIds: ['t1'], userIds: [] }); + expect(component.hasAttachments()).toBeTrue(); + expect(component.isFlaggedImportant()).toBeTrue(); + expect(component.isUserFavorite()).toBeTrue(); + expect(component.condition()).toEqual(condition); + expect(component.intervalChoice()).toEqual(savedChoice); + expect(component.timeZone()).toBe('gmt'); + expect(component.defaultStartDate).toEqual(startDate); + expect(component.defaultEndDate).toEqual(endDate); + }); + }); + + describe('onMemberFilterChanged', () => { + beforeEach(() => fixture.detectChanges()); + + it('sets the member filter when a team or user is selected', () => { + component.onMemberFilterChanged({ teamIds: ['t1'], userIds: [] }); + expect(component.memberFilter).toEqual({ teamIds: ['t1'], userIds: [] }); + }); + + it('clears the member filter when the selection is empty', () => { + component.onMemberFilterChanged({ teamIds: [], userIds: [] }); + expect(component.memberFilter).toBeNull(); + }); + }); + + describe('onConditionChanged', () => { + beforeEach(() => fixture.detectChanges()); + + it('sets the condition', () => { + const condition: BinaryCondition = { formId: 1, field: 'status', operator: '=', value: 'open' }; + component.onConditionChanged(condition); + expect(component.condition()).toEqual(condition); + }); + + it('clears the condition', () => { + component.condition.set({ formId: 1, field: 'status', operator: '=', value: 'open' }); + component.onConditionChanged(undefined); + expect(component.condition()).toBeUndefined(); + }); + }); + + describe('onTimezone', () => { + beforeEach(() => fixture.detectChanges()); + + it('toggles between local and gmt', () => { + component.timeZone.set('local'); + component.onTimezone(); + expect(component.timeZone()).toBe('gmt'); + component.onTimezone(); + expect(component.timeZone()).toBe('local'); + }); + }); + + describe('compareIntervalChoices', () => { + beforeEach(() => fixture.detectChanges()); + + it('compares by the filter value', () => { + expect(component.compareIntervalChoices({ filter: 'all', label: 'All' }, { filter: 'all', label: 'Different label' })).toBeTrue(); + expect(component.compareIntervalChoices({ filter: 'all', label: 'All' }, { filter: 'today', label: 'All' })).toBeFalse(); + }); + }); + + describe('onFilter', () => { + beforeEach(() => fixture.detectChanges()); + + it('blocks saving and shows an error when the condition builder has a pending, uncommitted condition', () => { + spyOn(component, 'fieldFilter').and.returnValue({ hasIncompleteCondition: true } as any); + + component.onFilter(); + + expect(component.showIncompleteConditionError()).toBeTrue(); + expect(filterService.setObservationFilter).not.toHaveBeenCalled(); + expect(dialogRef.close).not.toHaveBeenCalled(); + }); + + it('saves the filter state and closes the dialog', () => { + const condition: BinaryCondition = { formId: 1, field: 'status', operator: '=', value: 'open' }; + component.memberFilter = { teamIds: ['t1'], userIds: [] }; + component.hasAttachments.set(true); + component.isFlaggedImportant.set(true); + component.isUserFavorite.set(true); + component.condition.set(condition); + component.intervalChoice.set({ filter: 86400, label: 'Last 24 Hours' }); + + component.onFilter(); + + expect(filterService.setObservationFilter).toHaveBeenCalledWith({ + memberFilter: { teamIds: ['t1'], userIds: [] }, + hasAttachments: true, + isUserFavorite: true, + isFlaggedImportant: true, + timeInterval: { choice: { filter: 86400, label: 'Last 24 Hours' } }, + }, condition); + expect(dialogRef.close).toHaveBeenCalled(); + }); + + it('includes start/end dates and the local timezone flag for a custom interval', () => { + const startDate = new Date('2024-02-01T00:00:00Z'); + const endDate = new Date('2024-02-02T00:00:00Z'); + component.intervalChoice.set({ filter: 'custom', label: 'Custom' }); + component.timeZone.set('gmt'); + component.onStartDate(startDate); + component.onEndDate(endDate); + + component.onFilter(); + + const [base] = filterService.setObservationFilter.calls.mostRecent().args; + expect(base.timeInterval).toEqual({ choice: { filter: 'custom', label: 'Custom' }, options: { startDate, endDate, localTime: false } }); + }); + + it('keeps the previously saved custom dates when the user does not touch the date pickers', () => { + const startDate = new Date('2024-02-01T00:00:00Z'); + const endDate = new Date('2024-02-02T00:00:00Z'); + filterService.getObservationFilter.and.returnValue({ + ...DEFAULT_OBSERVATION_FILTER, + timeInterval: { choice: { filter: 'custom', label: 'Custom' }, options: { startDate, endDate, localTime: true } } + }); + fixture = TestBed.createComponent(ObservationFilterDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + component.onFilter(); + + const [base] = filterService.setObservationFilter.calls.mostRecent().args; + expect(base.timeInterval).toEqual({ choice: { filter: 'custom', label: 'Custom' }, options: { startDate, endDate, localTime: true } }); + }); + }); + + describe('onCancel', () => { + it('closes the dialog without saving', () => { + fixture.detectChanges(); + + component.onCancel(); + + expect(dialogRef.close).toHaveBeenCalled(); + expect(filterService.setObservationFilter).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/web-app/src/app/observation/observation-filter/observation-filter.component.ts b/web-app/src/app/observation/observation-filter/observation-filter.component.ts new file mode 100644 index 000000000..ae79e2051 --- /dev/null +++ b/web-app/src/app/observation/observation-filter/observation-filter.component.ts @@ -0,0 +1,134 @@ +import { ChangeDetectionStrategy, Component, OnInit, signal, viewChild } from '@angular/core' +import { MatButtonModule } from '@angular/material/button' +import { MatDialogModule, MatDialogRef } from '@angular/material/dialog' +import { MatFormFieldModule } from '@angular/material/form-field' +import { MatIconModule } from '@angular/material/icon' +import { MatSelectModule } from '@angular/material/select' +import { MatTooltipModule } from '@angular/material/tooltip' +import { DatetimePickerComponent } from '../../datetime-picker/datetime-picker.component' +import { FilterService } from '../../filter/filter.service' +import { EventMemberFilterComponent, MemberFilterSelection } from '../../event/event-member-filter.component' +import { Condition } from '../../entities/observation/filter/entities.observation.filter' +import { FilterChoice, INTERVAL_CHOICES, Interval } from '../../filter/filter.types' +import { ObservationFieldFilterComponent } from './observation-field-filter.component' +import moment from 'moment' + +@Component({ + selector: 'observation-filter', + templateUrl: './observation-filter.component.html', + styleUrls: ['./observation-filter.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + DatetimePickerComponent, + EventMemberFilterComponent, + ObservationFieldFilterComponent, + MatButtonModule, + MatDialogModule, + MatFormFieldModule, + MatIconModule, + MatSelectModule, + MatTooltipModule + ] +}) +export class ObservationFilterDialogComponent implements OnInit { + fieldFilter = viewChild(ObservationFieldFilterComponent) + + mageEvent: any + teams: any[] + localOffset = moment().format('Z') + + intervalChoices = INTERVAL_CHOICES + + initialMemberFilter: MemberFilterSelection | null = null + memberFilter: MemberFilterSelection | null = null + hasAttachments = signal(false) + isUserFavorite = signal(false) + isFlaggedImportant = signal(false) + condition = signal(undefined) + showIncompleteConditionError = signal(false) + timeZone = signal<'local' | 'gmt'>('local') + intervalChoice = signal(this.intervalChoices[1]) + defaultStartDate: Date + startDate: Date + defaultEndDate: Date + endDate: Date + + constructor( + public dialogRef: MatDialogRef, + private filterService: FilterService + ) { } + + ngOnInit() { + this.mageEvent = this.filterService.getEvent() + this.teams = this.mageEvent?.teams + + const observationFilter = this.filterService.getObservationFilter() + this.initialMemberFilter = observationFilter?.memberFilter ?? null + this.memberFilter = this.initialMemberFilter + this.hasAttachments.set(observationFilter?.hasAttachments ?? false) + this.isFlaggedImportant.set(observationFilter?.isFlaggedImportant ?? false) + this.isUserFavorite.set(observationFilter?.isUserFavorite ?? false) + this.condition.set(observationFilter?.fieldFilter?.condition) + + const savedInterval = observationFilter?.timeInterval + this.intervalChoice.set(savedInterval?.choice ?? this.intervalChoices[1]) + this.timeZone.set(savedInterval?.options?.localTime === false ? 'gmt' : 'local') + this.defaultStartDate = savedInterval?.options?.startDate ?? moment().startOf('day').toDate() + this.defaultEndDate = savedInterval?.options?.endDate ?? moment().endOf('day').toDate() + this.startDate = this.defaultStartDate + this.endDate = this.defaultEndDate + } + + onMemberFilterChanged(selection: MemberFilterSelection): void { + this.memberFilter = (selection.teamIds.length || selection.userIds.length) ? selection : null + } + + onConditionChanged(condition: Condition | undefined): void { + this.condition.set(condition) + } + + onStartDate(date: Date): void { + this.startDate = date + } + + onEndDate(date: Date): void { + this.endDate = date + } + + onTimezone(): void { + this.timeZone.update(timeZone => timeZone === 'gmt' ? 'local' : 'gmt') + } + + public compareIntervalChoices = function (option: FilterChoice, value: FilterChoice): boolean { + return option.filter === value.filter + } + + onFilter(): void { + if (this.fieldFilter()?.hasIncompleteCondition) { + this.showIncompleteConditionError.set(true) + return + } + + this.filterService.setObservationFilter({ + memberFilter: this.memberFilter, + hasAttachments: this.hasAttachments(), + isUserFavorite: this.isUserFavorite(), + isFlaggedImportant: this.isFlaggedImportant(), + timeInterval: this.timeInterval(), + }, this.condition()) + this.dialogRef.close() + } + + onCancel(): void { + this.dialogRef.close() + } + + private timeInterval(): Interval { + const choice = this.intervalChoice() + const localTime = this.timeZone() === 'local' + if (choice.filter === 'custom') { + return { choice, options: { startDate: this.startDate, endDate: this.endDate, localTime } } + } + return { choice } + } +} diff --git a/web-app/src/app/observation/observation-list/observation-list.component.html b/web-app/src/app/observation/observation-list/observation-list.component.html index 4d6dcac2b..fa85d210b 100644 --- a/web-app/src/app/observation/observation-list/observation-list.component.html +++ b/web-app/src/app/observation/observation-list/observation-list.component.html @@ -1,105 +1,70 @@
- - Observations + +
+
+ Observations + sync +
+
+ {{observationCount}} ·{{filterTimerange}} + + {{filter.icon}} + +
+
+
-
- - - All - - - Favorites - - - Important - - - Attachments - - -
- -
- - Observation Page - - - {{ observationPage[0]?.properties?.timestamp | moment : 'fromNow' }} - - - +
+
-
- -
- -
+ +
+ error_outline +
Unable to Load Observations
+
Something went wrong while searching. Please try again.
+ +
+
+ + + + +
+ +
+
+
-
+
-
- - -
- -
-
-
-
- Display Name - · - JUL 11 2012 10:00 AM -
-
- -
Primary Value
- -
Secondary Value
-
- -
- place -
-
- - -
-
- -
-
-
- -
+ + +
-
- -
+ +
+ distance +
No Observations
+
Try adjusting your filters or search term.
+
+
- -
-
- - + +
+ +
One moment…
+
Gathering your observations
-
\ No newline at end of file +
diff --git a/web-app/src/app/observation/observation-list/observation-list.component.scss b/web-app/src/app/observation/observation-list/observation-list.component.scss index 25e1b3f91..d74f38aa9 100644 --- a/web-app/src/app/observation/observation-list/observation-list.component.scss +++ b/web-app/src/app/observation/observation-list/observation-list.component.scss @@ -7,189 +7,123 @@ left: 0; display: block; overflow-y: auto; - background: #e1e2e1; + background: var(--mat-sys-surface-container-low); } .observation-feed { display: flex; flex-direction: column; height: 100%; - background: white; + background: var(--mat-sys-surface); } -.observation-feed-toolbar { +.toolbar { + display: flex; + flex-direction: row; + align-items: center; color: var(--mat-sys-primary); - background-color: white; - margin-top: 8px; - padding: 0 16px; - flex: 0 0 auto; + --mat-icon-button-icon-color: var(--mat-sys-primary); + background-color: var(--mat-sys-surface-container-lowest); + height: auto; + min-height: 64px; + padding: 8px 16px; +} - span { - font: var(--mat-sys-title-large); - } +.toolbar-text { + display: flex; + flex-direction: column; + flex: 1; } -.actions { +.toolbar-text__title { display: flex; - flex-direction: row; align-items: center; - box-sizing: border-box; - min-height: 52px; - padding: 8px; - flex: 0 0 auto; + gap: 4px; + font: var(--mat-sys-title-large); } -.feed-pages { - margin: 0 8px 8px; - flex: 0 0 auto; -} +.toolbar-text__subtitle { + display: flex; + align-items: center; + gap: 4px; + font: 500 12px / 16px Roboto, "Helvetica Neue", sans-serif; + color: var(--mat-sys-on-surface-variant); + + .filter-icons { + display: flex; + align-items: center; + gap: 4px; + } -.feed-pages__select { - width: 100%; + .filter-icon { + font-size: 14px; + width: 14px; + height: 14px; + line-height: 14px; + } } -:host ::ng-deep .feed-pages__select .mat-form-field-wrapper, -:host ::ng-deep .feed-pages__select .mat-mdc-text-field-wrapper { - padding-bottom: 0; +.stale-icon { + color: var(--mat-sys-error); + font-size: 18px; + width: 18px; + height: 18px; } -:host ::ng-deep .mat-card-image, -:host ::ng-deep .mat-mdc-card-image { - margin-bottom: 0; +.search-area { + padding: 8px 12px; + background: var(--mat-sys-surface-container-low); } .page-container { - background: #e1e2e1; + background: var(--mat-sys-surface-container-low); padding: 8px 0; flex: 1 1 auto; overflow-y: auto; } -.feed-observation { - margin: 0 8px 8px; +mat-paginator { + --mat-paginator-container-background-color: var(--mat-sys-surface); + border-top: 1px solid color-mix(in srgb, var(--mat-sys-on-surface) 12%, transparent); } -.item-card { +.feed-observation { margin: 0 8px 8px; } -.item-header { - display: flex; - flex-direction: row; - padding-bottom: 16px; -} - -.item-header-text { - font-size: 24px; - font-weight: 500; - display: flex; - flex-direction: row; - justify-content: space-between; - margin-top: 16px; - margin-bottom: 2px; -} - -.item-header-content { - display: flex; - flex-direction: column; +.list__empty { flex: 1; - min-width: 0; - margin-right: 16px; -} - -.item-overline { - font-family: Roboto, sans-serif; - -moz-osx-font-smoothing: grayscale; - -webkit-font-smoothing: antialiased; - font-size: 11px; - font-weight: 500; - letter-spacing: 0.16667em; - text-decoration: none; - text-transform: uppercase; -} - -.item-thumbnail { - width: 72px; - height: 72px; - font-size: 72px; - line-height: 72px; - text-align: center; - color: rgba(0, 0, 0, 0.2); - flex: 0 0 auto; -} - -.item-header-text, -.item-overline, -.item-subhead, -.item-time { - color: #e1e2e1; - background-color: #e1e2e1; - margin-bottom: 8px; -} - -.actions-buttons { display: flex; - flex-direction: row; - align-items: center; - box-sizing: border-box; -} - -.actions-icons { - display: flex; - flex-direction: row; + flex-direction: column; + justify-content: center; align-items: center; - box-sizing: border-box; - color: rgba(0, 0, 0, 0.2); - flex-grow: 1; - justify-content: flex-end; -} - -.actions-icons > * + * { - margin-left: 8px; + height: 100%; } -.actions__location-button { - padding: 4px; +.list__empty mat-progress-spinner { + margin-bottom: 16px; } -.actions__location-icon { - font-size: 18px; - height: 18px; - width: 18px; - margin-right: 4px; - margin-top: -2px; +.list__empty button { + margin-top: 16px; } -.actions__location-text { - color: #e1e2e1; - background-color: #e1e2e1; +.list__empty-headline { + color: var(--mat-sys-on-surface-variant); + font: 400 24px / 32px Roboto, "Helvetica Neue", sans-serif; + line-height: 32px; + margin-bottom: 8px; } -.loading { - position: relative; - overflow: hidden; +.list__empty-subheader { + color: var(--mat-sys-on-surface-variant); + font: 400 14px / 20px Roboto, "Helvetica Neue", sans-serif; + text-align: center; } -.loading::after { - display: block; - content: ''; - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - transform: translateX(-100%); - background: linear-gradient( - 90deg, - transparent, - rgba(255, 255, 255, 0.3), - transparent - ); - animation: loading 1.5s infinite; -} - -@keyframes loading { - 100% { - transform: translateX(100%); - } +.icon { + font-size: 96px; + font-variation-settings: 'fill' 0, 'wght' 300, 'grad' 0, 'opsz' 96; + color: var(--mat-sys-outline); + margin-bottom: 16px; } diff --git a/web-app/src/app/observation/observation-list/observation-list.component.spec.ts b/web-app/src/app/observation/observation-list/observation-list.component.spec.ts index a5f10e5cc..a5285325c 100644 --- a/web-app/src/app/observation/observation-list/observation-list.component.spec.ts +++ b/web-app/src/app/observation/observation-list/observation-list.component.spec.ts @@ -1,17 +1,50 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; - +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { BehaviorSubject } from 'rxjs'; import { ObservationListComponent } from './observation-list.component'; +import { EventService } from '../../event/event.service'; +import { FilterService } from '../../filter/filter.service'; +import { ObservationService } from '../observation.service'; +import { DEFAULT_OBSERVATION_FILTER, EventObservationFilter } from '../../filter/filter.types'; +import { ObservationFilterDialogComponent } from '../observation-filter/observation-filter.component'; -/* TODO test MUST contain at least 1 test describe('ObservationListComponent', () => { let component: ObservationListComponent; let fixture: ComponentFixture; + let dialog: jasmine.SpyObj; + let eventService: jasmine.SpyObj; + let filterService: jasmine.SpyObj; + let observationService: jasmine.SpyObj; + let eventSubject: BehaviorSubject; + let observationFilterSubject: BehaviorSubject; + let observationPageSubject: BehaviorSubject; beforeEach(waitForAsync(() => { + eventSubject = new BehaviorSubject(null); + observationFilterSubject = new BehaviorSubject(null); + observationPageSubject = new BehaviorSubject(null); + + dialog = jasmine.createSpyObj('MatDialog', ['open']); + eventService = jasmine.createSpyObj('EventService', ['retrySearch'], { + observationPage$: observationPageSubject.asObservable() + }); + filterService = jasmine.createSpyObj('FilterService', ['setObservationKeyword'], { + event$: eventSubject.asObservable(), + observationFilter$: observationFilterSubject.asObservable() + }); + observationService = jasmine.createSpyObj('ObservationService', ['setPagingOptions', 'clearPagingOptions']); + TestBed.configureTestingModule({ - declarations: [ObservationListComponent ] - }) - .compileComponents(); + declarations: [ObservationListComponent], + providers: [ + { provide: MatDialog, useValue: dialog }, + { provide: EventService, useValue: eventService }, + { provide: FilterService, useValue: filterService }, + { provide: ObservationService, useValue: observationService } + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); })); beforeEach(() => { @@ -20,8 +53,197 @@ describe('ObservationListComponent', () => { fixture.detectChanges(); }); - // it('should create', () => { - // expect(component).toBeTruthy(); - // }); + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('event$', () => { + it('sets the current event and resets the page index', () => { + component.currentPageIndex = 3; + const event = { id: 1, name: 'Test Event' }; + + eventSubject.next(event); + + expect(component.event).toEqual(event); + expect(component.currentPageIndex).toBe(0); + }); + }); + + describe('observationFilter$', () => { + it('resets paging to the first page at the current page size', () => { + component.pageSize = 25; + + observationFilterSubject.next(DEFAULT_OBSERVATION_FILTER); + + expect(observationService.setPagingOptions).toHaveBeenCalledWith({ page: 0, page_size: 25 }); + }); + + it('resets loaded/searchError/stale while the new page loads', () => { + component.loaded = true; + component.searchError = true; + component.stale = true; + + observationFilterSubject.next(DEFAULT_OBSERVATION_FILTER); + + expect(component.loaded).toBeFalse(); + expect(component.searchError).toBeFalse(); + expect(component.stale).toBeFalse(); + }); + + it('syncs the search bar to the filter keyword', () => { + const searchBar = jasmine.createSpyObj('SearchBarComponent', ['setValue']); + (component as any).searchBar = searchBar; + + observationFilterSubject.next({ ...DEFAULT_OBSERVATION_FILTER, fieldFilter: { keyword: 'flood' } }); + + expect(searchBar.setValue).toHaveBeenCalledWith('flood'); + }); + + it('shows "All time" for the "All" interval choice', () => { + observationFilterSubject.next({ + ...DEFAULT_OBSERVATION_FILTER, + timeInterval: { choice: { filter: 'all', label: 'All' } } + }); + expect(component.filterTimerange).toBe('All time'); + }); + + it('shows the choice label for a named interval', () => { + observationFilterSubject.next({ + ...DEFAULT_OBSERVATION_FILTER, + timeInterval: { choice: { filter: 86400, label: 'Last 24 Hours' } } + }); + expect(component.filterTimerange).toBe('Last 24 Hours'); + }); + + it('formats a custom interval as a date range', () => { + const startDate = new Date('2024-01-01T00:00:00Z'); + const endDate = new Date('2024-01-05T00:00:00Z'); + observationFilterSubject.next({ + ...DEFAULT_OBSERVATION_FILTER, + timeInterval: { choice: { filter: 'custom', label: 'Custom' }, options: { startDate, endDate } } + }); + + expect(component.filterTimerange).toContain('2024'); + expect(component.filterTimerange).toContain('–'); + }); + + it('builds the active filter icon list and count', () => { + observationFilterSubject.next({ + ...DEFAULT_OBSERVATION_FILTER, + memberFilter: { teamIds: ['t1'], userIds: [] }, + hasAttachments: true, + isUserFavorite: true, + isFlaggedImportant: true, + fieldFilter: { condition: { formId: 1, field: 'f', operator: '=', value: 'x' } } + }); + + expect(component.activeFilters.length).toBe(5); + expect(component.filterCount).toBe(5); + }); + + it('reports no active filters for the default filter', () => { + observationFilterSubject.next(DEFAULT_OBSERVATION_FILTER); + expect(component.activeFilters).toEqual([]); + expect(component.filterCount).toBe(0); + }); + + it('clears the timerange and filters when the filter is null', () => { + observationFilterSubject.next(DEFAULT_OBSERVATION_FILTER); + observationFilterSubject.next(null); + + expect(component.filterTimerange).toBe(''); + expect(component.activeFilters).toEqual([]); + }); + }); + + describe('observationPage$', () => { + it('ignores a null page', () => { + component.loaded = true; + observationPageSubject.next(null); + expect(component.loaded).toBeTrue(); + }); + + it('populates observations and pagination state from a successful page', () => { + observationPageSubject.next({ data: [{ id: 'o1' }], totalCount: 1, pageIndex: 0, error: null }); + + expect(component.observations).toEqual([{ id: 'o1' }]); + expect(component.observationCount).toBe(1); + expect(component.totalObservations).toBe(1); + expect(component.loaded).toBeTrue(); + expect(component.searchError).toBeFalse(); + expect(component.stale).toBeFalse(); + }); + + it('shows a full-page error when the fetch fails with no prior data', () => { + observationPageSubject.next({ data: [], totalCount: 0, pageIndex: 0, error: new Error('boom') }); + + expect(component.searchError).toBeTrue(); + expect(component.stale).toBeFalse(); + }); + + it('shows a stale-data indicator when the fetch fails but prior data remains', () => { + observationPageSubject.next({ data: [{ id: 'o1' }], totalCount: 1, pageIndex: 0, error: new Error('boom') }); + + expect(component.searchError).toBeFalse(); + expect(component.stale).toBeTrue(); + }); + + it('defaults to an empty observation list when data is missing', () => { + observationPageSubject.next({ totalCount: 0, pageIndex: 0, error: null }); + expect(component.observations).toEqual([]); + }); + }); + + describe('onSearch', () => { + it('sets the observation keyword', () => { + component.onSearch('flood'); + expect(filterService.setObservationKeyword).toHaveBeenCalledWith('flood'); + }); + + it('clears the keyword when the search text is empty', () => { + component.onSearch(''); + expect(filterService.setObservationKeyword).toHaveBeenCalledWith(undefined); + }); + + it('trims the search text before setting the keyword', () => { + component.onSearch(' flood '); + expect(filterService.setObservationKeyword).toHaveBeenCalledWith('flood'); + }); + + it('clears the keyword when the search text is only whitespace', () => { + component.onSearch(' '); + expect(filterService.setObservationKeyword).toHaveBeenCalledWith(undefined); + }); + }); + + describe('openFilterDialog', () => { + it('opens the observation filter dialog', () => { + component.openFilterDialog(); + expect(dialog.open).toHaveBeenCalledWith(ObservationFilterDialogComponent, jasmine.objectContaining({ width: '675px' })); + }); + }); + + describe('retrySearch', () => { + it('delegates to the event service', () => { + component.retrySearch(); + expect(eventService.retrySearch).toHaveBeenCalled(); + }); + }); + + describe('onPageChange', () => { + it('updates the page size and requests the new page', () => { + component.onPageChange({ pageIndex: 2, pageSize: 25, length: 100 }); + + expect(component.pageSize).toBe(25); + expect(observationService.setPagingOptions).toHaveBeenCalledWith({ page: 2, page_size: 25 }); + }); + }); + + describe('ngOnDestroy', () => { + it('clears the paging options so the page stops being fetched', () => { + fixture.destroy(); + + expect(observationService.clearPagingOptions).toHaveBeenCalled(); + }); + }); }); -*/ diff --git a/web-app/src/app/observation/observation-list/observation-list.component.ts b/web-app/src/app/observation/observation-list/observation-list.component.ts index 360311480..c120f8c38 100644 --- a/web-app/src/app/observation/observation-list/observation-list.component.ts +++ b/web-app/src/app/observation/observation-list/observation-list.component.ts @@ -1,9 +1,22 @@ -import { Component, DestroyRef, OnDestroy, OnInit } from '@angular/core'; +import { Component, DestroyRef, OnDestroy, OnInit, ViewChild } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { merge } from 'rxjs'; +import { MatDialog } from '@angular/material/dialog'; +import { PageEvent } from '@angular/material/paginator'; import moment from 'moment'; import { EventService } from '../../event/event.service'; import { FilterService } from '../../filter/filter.service'; +import { ObservationService } from '../observation.service'; +import { SearchBarComponent } from '../../search-bar/search-bar.component'; +import { ObservationFilterDialogComponent } from '../observation-filter/observation-filter.component'; +import { EventObservationFilter } from '../../filter/filter.types'; + +const filterDefinitions = { + members: { icon: 'group', tooltip: 'Members' }, + attachments: { icon: 'attach_file', tooltip: 'Has attachments' }, + favorites: { icon: 'favorite', tooltip: 'Favorites' }, + important: { icon: 'flag', tooltip: 'Important' }, + fieldFilters: { icon: 'tune', tooltip: 'Field filters' } +} @Component({ selector: 'observation-list', @@ -13,104 +26,131 @@ import { FilterService } from '../../filter/filter.service'; }) export class ObservationListComponent implements OnInit, OnDestroy { loaded = false + searchError = false + stale = false - event: any - - observationsById = {} + observations: any[] = [] + currentPageIndex = 0 + pageSize = 50 + totalObservations = 0 - currentObservationPage = 0 - observationPages = [] - observationsPerPage = 50 + @ViewChild(SearchBarComponent) private searchBar: SearchBarComponent - filter = 'all' + event: any - filterChangedListener: any + filterCount = 0 + filterPluralMapping = { + '=0': 'No active filters', + '=1': '1 active filter', + 'other': '# active filters' + } + activeFilters: { icon: string; tooltip: string }[] = [] + observationCount: number + filterTimerange: string constructor( + private dialog: MatDialog, private eventService: EventService, private filterService: FilterService, - private destroyRef: DestroyRef) { - } + private observationService: ObservationService, + private destroyRef: DestroyRef + ) {} ngOnInit(): void { - this.event = this.filterService.getEvent() - this.eventService.addObservationsChangedListener(this) - merge( - this.filterService.event$, - this.filterService.teams$, - this.filterService.users$, - this.filterService.forms$, - this.filterService.interval$, - this.filterService.actionFilter$ - ) + this.filterService.event$ .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(() => this.onFilterChanged()) + .subscribe(event => this.onEvent(event)) + + this.filterService.observationFilter$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((filter) => this.onObservationFilter(filter)) + + this.eventService.observationPage$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((page) => this.onObservationPage(page)) } ngOnDestroy(): void { - this.eventService.removeObservationsChangedListener(this) + this.observationService.clearPagingOptions() } - trackByPageId(index: number, page: any): any { - return index + trackByObservationId(_index: number, observation: any): any { + return observation.id } - trackByObservationId(index: number, observation: any): any { - return observation.id + onSearch(text: string): void { + const keyword = text?.trim() + this.filterService.setObservationKeyword(keyword || undefined) } - onFilterChanged(): void { - this.currentObservationPage = 0 + openFilterDialog(): void { + this.dialog.open(ObservationFilterDialogComponent, { + autoFocus: false, + restoreFocus: false, + width: '675px' + }) } - filterChanged(filter): void { - this.filter = filter; - this.filterService.setFilter({ actionFilter: filter }); + onEvent(event: any) { + this.event = event + this.currentPageIndex = 0 } - onObservationsChanged(changed): void { + onObservationPage(page: any) { + if (page === null) return + this.observations = page.data ?? [] + this.observationCount = page.totalCount + this.totalObservations = page.totalCount ?? 0 + this.currentPageIndex = page.pageIndex ?? 0 this.loaded = true - this.event = this.filterService.getEvent() - - const { added = [], updated = [], removed = [] } = changed - added.forEach(observation => { - this.observationsById[observation.id] = observation - }) - - updated.forEach(observation => { - this.observationsById[observation.id] = observation - }) - - removed.forEach(observation => { - delete this.observationsById[observation.id] - }) + const hasError = page.error != null + this.searchError = hasError && this.observations.length === 0 + this.stale = hasError && this.observations.length > 0 + } - this.calculateObservationPages(Object.values(this.observationsById)) + retrySearch(): void { + this.eventService.retrySearch() } - calculateObservationPages(observations: any[]): void { - if (!observations) return; + private onObservationFilter(filter: EventObservationFilter) { + this.loaded = false + this.searchError = false + this.stale = false + this.observations = [] + this.currentPageIndex = 0 + this.observationService.setPagingOptions({ page: 0, page_size: this.pageSize }) + this.searchBar?.setValue(filter?.fieldFilter?.keyword ?? '') + + this.filterTimerange = filter ? this.formatTimeInterval(filter.timeInterval) : '' + this.activeFilters = filter ? this.buildActiveFilters(filter) : [] + this.filterCount = this.activeFilters.length + } - // Sort the observations - observations.sort((a, b) => { - return moment(b.properties.timestamp).valueOf() - moment(a.properties.timestamp).valueOf() - }) + private formatTimeInterval(timeInterval?: EventObservationFilter['timeInterval']): string { + const choice = timeInterval?.choice + if (!choice || choice.label === 'All') return 'All time' - // Slice into pages - const pages = [] - for (let i = 0, j = observations.length; i < j; i += this.observationsPerPage) { - pages.push(observations.slice(i, i + this.observationsPerPage)) + if (choice.filter === 'custom') { + const format = (date?: Date) => date ? moment(date).format('MMM D, YYYY') : '?' + return `${format(timeInterval.options?.startDate)} – ${format(timeInterval.options?.endDate)}` } + return choice.label + } - this.observationPages = pages - - // If a new page showed up that wasn't there before, switch to it - if (this.currentObservationPage === -1 && pages.length) { - this.currentObservationPage = 0 - } + private buildActiveFilters(filter: EventObservationFilter): { icon: string; tooltip: string }[] { + const { memberFilter, hasAttachments, isUserFavorite, isFlaggedImportant, fieldFilter } = filter + const filters: { icon: string; tooltip: string }[] = [] + if (memberFilter && (memberFilter.teamIds.length > 0 || memberFilter.userIds.length > 0)) filters.push(filterDefinitions.members) + if (hasAttachments) filters.push(filterDefinitions.attachments) + if (isUserFavorite) filters.push(filterDefinitions.favorites) + if (isFlaggedImportant) filters.push(filterDefinitions.important) + if (fieldFilter && (fieldFilter.condition != null || fieldFilter.keyword)) filters.push(filterDefinitions.fieldFilters) + return filters + } - // ensure the page that they were on did not go away - this.currentObservationPage = Math.min(this.currentObservationPage, pages.length - 1) + onPageChange(event: PageEvent): void { + this.pageSize = event.pageSize + this.observationService.setPagingOptions({ page: event.pageIndex, page_size: event.pageSize }) } } diff --git a/web-app/src/app/observation/observation-view/observation-view.component.ts b/web-app/src/app/observation/observation-view/observation-view.component.ts index 5475a0de0..58396bcf8 100644 --- a/web-app/src/app/observation/observation-view/observation-view.component.ts +++ b/web-app/src/app/observation/observation-view/observation-view.component.ts @@ -9,6 +9,8 @@ import { MatDialog as MatDialog } from '@angular/material/dialog'; import { MatBottomSheet } from '@angular/material/bottom-sheet'; import { MapService } from '../../map/map.service'; import { EventService } from '../../event/event.service'; +import { Form } from '../../entities/event/entities.event'; +import { FormProperties, Observation } from '../../entities/observation/entities.observation'; import { SessionService } from 'mage-web-app/http/session.service'; @Component({ @@ -47,7 +49,11 @@ export class ObservationViewComponent implements OnChanges { description?: string } = { open: false } - observationForm: any + observationForm?: { + geometryField: { title: string, type: string, value: Observation['geometry'] }, + timestampField: { title: string, type: string, value: Date }, + forms: Form[] + } primaryFeedField: any = {} secondaryFeedField: any = {} @@ -68,7 +74,7 @@ export class ObservationViewComponent implements OnChanges { if (changes.observation) { this.updateFavorites() - this.importantEditor.description = this.observation.important ? this.observation.important.description : null + this.importantEditor.description = this.observation.important?.description ?? undefined } } @@ -107,13 +113,15 @@ export class ObservationViewComponent implements OnChanges { } markAsImportant(): void { - this.eventService.markObservationAsImportant(this.observation, { description: this.importantEditor.description }).subscribe(() => { + this.eventService.markObservationAsImportant(this.observation, { description: this.importantEditor.description }).subscribe(observation => { + this.observation.important = observation.important this.importantEditor.open = false }) } clearImportant(): void { - this.eventService.clearObservationAsImportant(this.observation).subscribe(() => { + this.eventService.clearObservationAsImportant(this.observation).subscribe(observation => { + this.observation.important = observation.important this.importantEditor.open = false delete this.importantEditor.description }) @@ -172,11 +180,15 @@ export class ObservationViewComponent implements OnChanges { const aclPermissions = myAccess.permissions || [] this.canEditImportant = this.sessionService.user.role.permissions.includes('UPDATE_EVENT') || aclPermissions.includes('update') - const formMap = this.eventService.getFormsForEvent(this.event, {}).reduce((map, form) => { + const formMap = this.eventService.getFormsForEvent(this.event, {}).reduce>((map, form) => { map[form.id] = form return map }, {}) + const forms: Form[] = this.observation.properties.forms.map((propertyForm: FormProperties) => { + return this.eventService.createForm(propertyForm, formMap[propertyForm.formId]) + }) + this.observationForm = { geometryField: { title: 'Location', @@ -188,24 +200,19 @@ export class ObservationViewComponent implements OnChanges { type: 'date', value: moment(this.observation.properties.timestamp).toDate() }, - forms: [] + forms } - this.observation.properties.forms.forEach(propertyForm => { - const observationForm = this.eventService.createForm(propertyForm, formMap[propertyForm.formId]) - this.observationForm.forms.push(observationForm) - }) - this.primaryFeedField = {} this.secondaryFeedField = {} if (this.observation.properties.forms.length > 0) { const firstForm = this.observation.properties.forms[0] - const observationForm = this.observationForm.forms.find(observationForm => { + const observationForm = forms.find(observationForm => { return observationForm.id === firstForm.formId }) - if (observationForm.primaryFeedField && firstForm[observationForm.primaryFeedField]) { + if (observationForm?.primaryFeedField && firstForm[observationForm.primaryFeedField]) { const field = observationForm.fields.find(field => field.name === observationForm.primaryFeedField) this.primaryFeedField = { field: field, @@ -213,7 +220,7 @@ export class ObservationViewComponent implements OnChanges { } } - if (observationForm.secondaryFeedField && firstForm[observationForm.secondaryFeedField]) { + if (observationForm?.secondaryFeedField && firstForm[observationForm.secondaryFeedField]) { const field = observationForm.fields.find(field => field.name === observationForm.secondaryFeedField) this.secondaryFeedField = { field: field, diff --git a/web-app/src/app/observation/observation.service.spec.ts b/web-app/src/app/observation/observation.service.spec.ts index d1a350cc9..2f32bdbcc 100644 --- a/web-app/src/app/observation/observation.service.spec.ts +++ b/web-app/src/app/observation/observation.service.spec.ts @@ -1,65 +1,163 @@ -import { TestBed } from '@angular/core/testing'; -import { ObservationService } from './observation.service'; -import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; -import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; +import { TestBed } from '@angular/core/testing' +import { ObservationService } from './observation.service' +import { HttpTestingController, HttpClientTestingModule } from '@angular/common/http/testing' +import { SessionService } from '../http/session.service' +import { BinaryCondition } from '../entities/observation/filter/entities.observation.filter' -describe('Observation Service', () => { - let service: ObservationService; - let httpMock: HttpTestingController; - const event: any = { id: 1, forms: [] }; +const mockEvent: any = { id: 1, name: 'Test Event', forms: [], style: {} } + +const mockSessionService = { + getToken: jasmine.createSpy('getToken').and.returnValue('test-token') +} + +describe('ObservationService', () => { + let service: ObservationService + let httpTestingController: HttpTestingController beforeEach(() => { TestBed.configureTestingModule({ - imports: [], - providers: [ObservationService, provideHttpClient(withInterceptorsFromDi()), provideHttpClientTesting()] -}); - service = TestBed.inject(ObservationService); - httpMock = TestBed.inject(HttpTestingController); - }); + providers: [ + ObservationService, + { provide: SessionService, useValue: mockSessionService } + ], + imports: [HttpClientTestingModule] + }) + service = TestBed.inject(ObservationService) + httpTestingController = TestBed.inject(HttpTestingController) + }) afterEach(() => { - httpMock.verify(); - }); - - it('should be created', () => { - expect(service).toBeTruthy(); - }); - - it('should not include date params when no interval is provided', () => { - service.getObservationsForEvent(event, {}).subscribe(); - - const req = httpMock.expectOne(r => r.url === '/api/events/1/observations'); - expect(req.request.params.has('observationStartDate')).toBeFalse(); - expect(req.request.params.has('observationEndDate')).toBeFalse(); - req.flush([]); - }); - - it('should not send literal "undefined" or "null" date params when the interval has no start or end', () => { - service.getObservationsForEvent(event, { interval: { start: null, end: undefined } }).subscribe(); - - const req = httpMock.expectOne(r => r.url === '/api/events/1/observations'); - expect(req.request.params.has('observationStartDate')).toBeFalse(); - expect(req.request.params.has('observationEndDate')).toBeFalse(); - req.flush([]); - }); - - it('should include only the date params that are present on the interval', () => { - service.getObservationsForEvent(event, { interval: { start: '2026-01-01T00:00:00.000Z' } }).subscribe(); - - const req = httpMock.expectOne(r => r.url === '/api/events/1/observations'); - expect(req.request.params.get('observationStartDate')).toBe('2026-01-01T00:00:00.000Z'); - expect(req.request.params.has('observationEndDate')).toBeFalse(); - req.flush([]); - }); - - it('should include both date params when the interval has a start and end', () => { - service.getObservationsForEvent(event, { - interval: { start: '2026-01-01T00:00:00.000Z', end: '2026-01-02T00:00:00.000Z' } - }).subscribe(); - - const req = httpMock.expectOne(r => r.url === '/api/events/1/observations'); - expect(req.request.params.get('observationStartDate')).toBe('2026-01-01T00:00:00.000Z'); - expect(req.request.params.get('observationEndDate')).toBe('2026-01-02T00:00:00.000Z'); - req.flush([]); - }); -}); + httpTestingController.verify() + }) + + it('should be created', () => { + expect(service).toBeTruthy() + }) + + describe('getObservationsForMap', () => { + + it('uses GET when no filter provided', () => { + service.getObservationsForMap(mockEvent, {}).subscribe() + + const req = httpTestingController.expectOne(r => r.method === 'GET') + expect(req.request.url).toBe(`/api/events/${mockEvent.id}/observations`) + req.flush([]) + }) + + it('does not send a view param, the server has no map view', () => { + service.getObservationsForMap(mockEvent, {}).subscribe() + + const req = httpTestingController.expectOne(r => r.method === 'GET') + expect(req.request.params.has('view')).toBeFalse() + req.flush([]) + }) + + it('transforms the observations so the map gets their style and icon', () => { + let result: any[] = [] + service.getObservationsForMap(mockEvent, {}).subscribe(observations => result = observations) + + const req = httpTestingController.expectOne(r => r.method === 'GET') + req.flush([{ id: 'o1', type: 'Feature', geometry: { type: 'Point', coordinates: [0, 0] }, properties: { forms: [] } }]) + + expect(result[0].style.iconUrl).toBe('/api/events/1/icons?access_token=test-token') + }) + + it('uses GET when filter is empty object', () => { + service.getObservationsForMap(mockEvent, { filter: {} }).subscribe() + + const req = httpTestingController.expectOne(r => r.method === 'GET') + expect(req.request.url).toBe(`/api/events/${mockEvent.id}/observations`) + req.flush([]) + }) + + it('uses POST to search endpoint when keyword filter provided', () => { + service.getObservationsForMap(mockEvent, { filter: { keyword: 'flood' } }).subscribe() + + const req = httpTestingController.expectOne(r => r.method === 'POST') + expect(req.request.url).toBe(`/api/events/${mockEvent.id}/observations/search`) + expect(req.request.body.keyword).toBe('flood') + expect(req.request.body.condition).toBeUndefined() + req.flush([]) + }) + + it('uses POST to search endpoint when condition filter provided', () => { + const condition: BinaryCondition = { formId: 1, field: 'status', operator: '=', value: 'open' } + service.getObservationsForMap(mockEvent, { filter: { condition } }).subscribe() + + const req = httpTestingController.expectOne(r => r.method === 'POST') + expect(req.request.url).toBe(`/api/events/${mockEvent.id}/observations/search`) + expect(req.request.body.condition).toEqual(condition) + expect(req.request.body.keyword).toBeUndefined() + req.flush([]) + }) + + it('uses POST with both keyword and condition when both provided', () => { + const condition: BinaryCondition = { formId: 1, field: 'status', operator: '=', value: 'open' } + service.getObservationsForMap(mockEvent, { filter: { keyword: 'flood', condition } }).subscribe() + + const req = httpTestingController.expectOne(r => r.method === 'POST') + expect(req.request.url).toBe(`/api/events/${mockEvent.id}/observations/search`) + expect(req.request.body.keyword).toBe('flood') + expect(req.request.body.condition).toEqual(condition) + req.flush([]) + }) + + it('includes base options as query params on search POST', () => { + service.getObservationsForMap(mockEvent, { filter: { keyword: 'flood' }, states: 'active' }).subscribe() + + const req = httpTestingController.expectOne(r => r.method === 'POST') + expect(req.request.params.get('states')).toBe('active') + req.flush([]) + }) + }) + + describe('paging options', () => { + it('has none until they are set', () => { + expect(service.getPagingOptions()).toBeNull() + }) + + it('clears the options and tells subscribers', () => { + const emitted: any[] = [] + service.paging$.subscribe(paging => emitted.push(paging)) + service.setPagingOptions({ page: 2, page_size: 25 }) + + service.clearPagingOptions() + + expect(service.getPagingOptions()).toBeNull() + expect(emitted[emitted.length - 1]).toBeNull() + }) + }) + + describe('archiveObservationForEvent', () => { + it('posts the server\'s archive state name', () => { + const observation: any = { id: 'o1' } + + service.archiveObservationForEvent(mockEvent, observation).subscribe() + + const req = httpTestingController.expectOne(r => r.method === 'POST') + expect(req.request.url).toBe(`/api/events/${mockEvent.id}/observations/o1/states`) + expect(req.request.body).toEqual({ name: 'archive' }) + req.flush({}) + }) + }) + + describe('getObservationIconUrlForEvent', () => { + const iconUrl = (formId: number | null, primary: string | null, variant: string | null) => + service.getObservationIconUrlForEvent(1, formId, primary, variant) + + it('uses only the event when there is no form', () => { + expect(iconUrl(null, null, null)).toBe('/api/events/1/icons?access_token=test-token') + }) + + it('adds each segment in form, primary, variant order', () => { + expect(iconUrl(5, null, null)).toBe('/api/events/1/icons/5?access_token=test-token') + expect(iconUrl(5, 'a', null)).toBe('/api/events/1/icons/5/a?access_token=test-token') + expect(iconUrl(5, 'a', 'b')).toBe('/api/events/1/icons/5/a/b?access_token=test-token') + }) + + it('drops a segment that has no preceding segment, since the route is positional', () => { + expect(iconUrl(5, null, 'b')).toBe('/api/events/1/icons/5?access_token=test-token') + expect(iconUrl(null, 'a', 'b')).toBe('/api/events/1/icons?access_token=test-token') + }) + }) +}) diff --git a/web-app/src/app/observation/observation.service.ts b/web-app/src/app/observation/observation.service.ts index 60262e540..6fd85fbe4 100644 --- a/web-app/src/app/observation/observation.service.ts +++ b/web-app/src/app/observation/observation.service.ts @@ -1,17 +1,18 @@ import { Injectable } from "@angular/core"; import { SessionService } from "../http/session.service"; import { HttpClient, HttpParams } from "@angular/common/http"; -import { Observable, map, mergeMap } from "rxjs"; -import * as _ from "underscore"; -import { MageEvent } from "core-lib-src/event"; -import { Observation } from "../entities/observation/entities.observation"; -import { Form } from "../entities/event/entities.event"; +import { BehaviorSubject, Observable, map, mergeMap } from "rxjs"; +import * as _ from "lodash"; +import { Attachment, Observation, ObservationId, ObservationStateName, ObservationStyle } from "../entities/observation/entities.observation"; +import { EventId, Form, FormStyle, MageEvent } from "../entities/event/entities.event"; +import { Style } from "../entities/map/entities.map"; import { ObservationFieldFilter } from "../entities/observation/filter/entities.observation.filter"; export type ObservationsRequestOptions = { - states?: 'active' | 'archive' + states?: ObservationStateName populate?: boolean sort?: string + startDate?: string observationStartDate?: string observationEndDate?: string favoritedBy?: string @@ -20,6 +21,8 @@ export type ObservationsRequestOptions = { teams?: string[] users?: string[] filter?: ObservationFieldFilter + page?: number + page_size?: number } export type ObservationsPageRequestOptions = ObservationsRequestOptions & { @@ -28,51 +31,64 @@ export type ObservationsPageRequestOptions = ObservationsRequestOptions & { include_total_count: boolean } +export type ObservationsPage = { + items: Observation[] + totalCount?: number + links: { next: number | null, prev: number | null } +} + + +export type SaveableObservation = Partial & { noGeometry?: boolean } + +export type PagingOptions = { + page: number + page_size: number +} + @Injectable({ providedIn: "root", }) export class ObservationService { + + private pagingSubject = new BehaviorSubject(null); + readonly paging$: Observable = this.pagingSubject.asObservable(); + constructor( private client: HttpClient, private sessionService: SessionService ) { } - getId(eventId: number): Observable { - return this.client.post(`/api/events/${eventId}/observations/id/`, { + setPagingOptions(options: PagingOptions) { + this.pagingSubject.next(options); + } + + clearPagingOptions() { + this.pagingSubject.next(null); + } + + getPagingOptions(): PagingOptions | null { + return this.pagingSubject.getValue(); + } + + getId(eventId: EventId): Observable<{ id: ObservationId }> { + return this.client.post<{ id: ObservationId }>(`/api/events/${eventId}/observations/id/`, { eventId: eventId, }); } - getObservation(eventId: string, observationId: string): Observable { - return this.client.get( - `/api/events/${eventId}/observations/${observationId}` + getObservation(eventId: string, observationId: ObservationId): Observable { + return this.client.get( + `/api/events/${eventId}/observations/${observationId}`, + { params: { populate: true } } ); } - getObservationsForEvent(event: MageEvent, options: any): Observable { - let params = new HttpParams() - .set("eventId", event.id.toString()) - .set("states", "active") - .set("populate", "true"); - - if (options.interval?.start) { - params = params.set("observationStartDate", options.interval.start); - } - if (options.interval?.end) { - params = params.set("observationEndDate", options.interval.end); - } - - return this.client - .get(`/api/events/${event.id}/observations`, { params }) - .pipe( - map((observations: any) => { - return this.transformObservations(observations, event); - }) - ); + getObservationsForMap(event: MageEvent, options: ObservationsRequestOptions): Observable { + return this.fetchObservations(event, options, (observations: Observation[]) => this.transformObservations(observations, event)); } - getObservationsPage(event: MageEvent, options: ObservationsPageRequestOptions): Observable { - return this.fetchObservations(event, options, response => ({ + getObservationsPage(event: MageEvent, options: ObservationsPageRequestOptions): Observable { + return this.fetchObservations(event, options, (response: ObservationsPage) => ({ ...response, items: this.transformObservations(response.items, event) })) @@ -90,7 +106,7 @@ export class ObservationService { return this.client.get(`/api/events/${event.id}/observations`, { params }).pipe(map(transform)) } - saveObservationForEvent(event: MageEvent, observation: any): Observable { + saveObservationForEvent(event: MageEvent, observation: SaveableObservation): Observable { return this.saveObservation(event, observation).pipe( map((observation) => { return this.transformObservations(observation, event)[0] @@ -98,7 +114,7 @@ export class ObservationService { ) } - private saveObservation(event: MageEvent, observation: any): Observable { + private saveObservation(event: MageEvent, observation: SaveableObservation): Observable { // If the noGemetry flag is set, override the geometry to a default point. if (!!observation.noGeometry) { observation.geometry = { @@ -107,14 +123,14 @@ export class ObservationService { } } if (observation.id) { - return this.client.put( + return this.client.put( `/api/events/${event.id}/observations/${observation.id}`, observation ); } else { return this.getId(event.id).pipe( mergeMap((result) => { - return this.client.put( + return this.client.put( `/api/events/${event.id}/observations/${result.id}`, observation ); @@ -123,48 +139,56 @@ export class ObservationService { } } - addObservationFavorite(event, observation): Observable { - return this.client.put( + addObservationFavorite(event: MageEvent, observation: Observation): Observable { + return this.client.put( `/api/events/${event.id}/observations/${observation.id}/favorite`, observation + ).pipe( + map((observation) => this.transformObservations(observation, event)[0]) ); } - removeObservationFavorite(event, observation): Observable { - return this.client.delete( + removeObservationFavorite(event: MageEvent, observation: Observation): Observable { + return this.client.delete( `/api/events/${event.id}/observations/${observation.id}/favorite`, { body: observation } + ).pipe( + map((observation) => this.transformObservations(observation, event)[0]) ); } markObservationAsImportantForEvent( - event, - observation, - important - ): Observable { - return this.client.put( + event: MageEvent, + observation: Observation, + important: Pick, 'description'> + ): Observable { + return this.client.put( `/api/events/${event.id}/observations/${observation.id}/important`, important + ).pipe( + map((observation) => this.transformObservations(observation, event)[0]) ); } - clearObservationAsImportantForEvent(event, observation): Observable { - return this.client.delete( + clearObservationAsImportantForEvent(event: MageEvent, observation: Observation): Observable { + return this.client.delete( `/api/events/${event.id}/observations/${observation.id}/important`, { body: observation } + ).pipe( + map((observation) => this.transformObservations(observation, event)[0]) ); } - archiveObservationForEvent(event, observation): Observable { + archiveObservationForEvent(event: MageEvent, observation: Observation): Observable { return this.client - .post( + .post( `/api/events/${event.id}/observations/${observation.id}/states`, - { name: "archive" } + { name: ObservationStateName.Archived } ) .pipe(map(() => observation)); } - addAttachmentToObservationForEvent(event, observation, attachment) { + addAttachmentToObservationForEvent(event: MageEvent, observation: Observation, attachment: Attachment): void { const attachments = observation.attachments.slice(); const update = attachments.find((a) => a.id === attachment.id); if (update) { @@ -175,33 +199,31 @@ export class ObservationService { } deleteAttachmentInObservationForEvent( - event, - observation, - attachment - ): Observable { + event: MageEvent, + observation: Observation, + attachment: Attachment + ): Observable { return this.client - .delete( + .delete( `/api/events/${event.id}/observations/${observation.id}/attachments/${attachment.id}` ) .pipe( - map((response: any) => { + map((response: Observation) => { response.attachments = _.reject( observation.attachments, - function (a) { - return attachment.id === a.id; - } + (a) => attachment.id === a.id ); return response; }) ); } - transformObservations(observations, event) { - if (!_.isArray(observations)) observations = [observations]; + transformObservations(observations: Observation | Observation[], event: MageEvent): Observation[] { + const list: Observation[] = Array.isArray(observations) ? observations : [observations]; - let formMap = _.indexBy(event.forms, "id"); - observations.forEach((observation: Observation) => { - let form: Form; + const formMap = _.keyBy(event.forms, "id"); + list.forEach((observation: Observation) => { + let form: Form | undefined; if (observation.properties.forms.length) { form = formMap[observation.properties.forms[0].formId]; } @@ -211,23 +233,24 @@ export class ObservationService { event, form ); - if (observation.geometry.type === "Polygon") { - this.minimizePolygon(observation.geometry.coordinates); - } else if (observation.geometry.type === "LineString") { - this.minimizeLineString(observation.geometry.coordinates); + const { geometry } = observation; + if (geometry.type === "Polygon") { + this.minimizePolygon(geometry.coordinates); + } else if (geometry.type === "LineString") { + this.minimizeLineString(geometry.coordinates); } }); - return observations; + return list; } - minimizePolygon(polygon) { + minimizePolygon(polygon: number[][][]) { for (let i = 0; i < polygon.length; i++) { this.minimizeLineString(polygon[i]); } } - minimizeLineString(lineString) { + minimizeLineString(lineString: number[][]) { let world = 360; let coord = lineString[0]; for (let i = 1; i < lineString.length; i++) { @@ -244,50 +267,50 @@ export class ObservationService { } } - getObservationStyleForForm(observation, event, form) { - let formId = null; - let formStyle = null; - let primaryField = null; - let letiantField = null; + getObservationStyleForForm(observation: Pick, event: Pick, form?: Form): ObservationStyle { + let formId: number | null = null; + let formStyle: FormStyle | null = null; + let primaryField: string | null = null; + let variantField: string | null = null; - if (observation.properties.forms.length) { + if (form && observation.properties.forms.length) { let firstForm = observation.properties.forms[0]; formId = form.id; - formStyle = form.style; - primaryField = firstForm[form.primaryField]; - letiantField = firstForm[form.letiantField]; + formStyle = form.style ?? null; + primaryField = form.primaryField ? firstForm[form.primaryField] : null; + variantField = form.variantField ? firstForm[form.variantField] : null; } - let style: any = this.getObservationStyle( - event.style, - formStyle, - primaryField, - letiantField - ); - style.iconUrl = this.getObservationIconUrlForEvent( - event.id, - formId, - primaryField, - letiantField - ); - - return style; + return { + ...this.getObservationStyle( + event.style, + formStyle, + primaryField, + variantField + ), + iconUrl: this.getObservationIconUrlForEvent( + event.id, + formId, + primaryField, + variantField + ) + }; } - getObservationStyle(eventStyle, formStyle, primary, letiant) { - let style = eventStyle || {}; + getObservationStyle(eventStyle: Style, formStyle: FormStyle | null, primary: string | null, variant: string | null): Omit { + let style: Partial