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
-
+
-
+
+ Keyword Filter
+
+ info_outline
+
+
+
Form Fields To Include
@@ -177,7 +183,7 @@
Filter by Team or Member
-
+
exportSizeWarningLimit">
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 @@
arrow_back
-
Export
+
Export
+
+ download
+
+
+ delete
+
@@ -128,14 +134,6 @@
-
-
- Delete
-
-
- 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
-
-
-
- 0 || selectedTeams.length > 0">
-
- Teams
-
-
-
- {{ team.name }}
- cancel
-
-
-
-
-
-
-
- {{ team.name }}
-
-
-
-
-
-
-
- 0 || selectedUsers.length > 0">
-
- Users
-
-
-
- {{ user.displayName || user.username }}
- cancel
-
-
-
-
-
-
-
- {{ user.displayName || user.username }}
-
-
-
-
-
-
-
- 0 || selectedForms.length > 0">
-
- Forms
-
-
-
- {{ form.name }}
- cancel
-
-
-
-
-
-
-
- {{ form.name }}
-
-
-
-
-
-
-
-
- Time
-
-
- {{ choice.label }}
-
-
-
-
-
-
-
-
-
- Local ({{ localOffset }})
- GMT (+00:00)
-
-
-
-
-
-
-
-
-
-
-
-
-
- Cancel
-
-
- Filter
-
-
-
\ 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