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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 7 additions & 8 deletions service/src/adapters/exports/adapters.exports.controllers.web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
}
Expand Down
15 changes: 15 additions & 0 deletions service/src/app.impl/teams/app.impl.teams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
25 changes: 13 additions & 12 deletions service/src/models/observation.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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) {
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 4 additions & 0 deletions service/test/observation/observationFavoriteTest.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions service/test/observation/observationImportantTest.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 3 additions & 1 deletion web-app/angular.json
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,9 @@
"node_modules"
]
},
"scripts": [],
"scripts": [
"node_modules/js_cols/dist/js_cols.min.js"
],
"codeCoverage": true
}
},
Expand Down
22 changes: 10 additions & 12 deletions web-app/src/app/datetime-picker/datetime-picker.component.html
Original file line number Diff line number Diff line change
@@ -1,29 +1,27 @@
<div class="datetime">
<div class="date">
<mat-form-field appearance="fill" floatLabel="always">
<mat-label>{{ title }} Date</mat-label>
<input matInput [matDatepicker]="datePicker" [(ngModel)]="date" [required]="required" (ngModelChange)="onDate()"
<mat-label>{{ title() }} Date</mat-label>
<input matInput [matDatepicker]="datePicker" [(ngModel)]="date" [required]="required()" (ngModelChange)="onDate()"
#dateModel="ngModel" autocomplete="off" />
<mat-datepicker-toggle matSuffix [for]="datePicker"></mat-datepicker-toggle>
<mat-datepicker #datePicker></mat-datepicker>
<mat-error *ngIf="
dateModel.errors?.required && !dateModel.errors?.matDatepickerParse
">
Date is required
</mat-error>
<mat-error *ngIf="dateModel.errors?.matDatepickerParse">
Invalid date
</mat-error>
@if (dateModel.errors?.required && !dateModel.errors?.matDatepickerParse) {
<mat-error>Date is required</mat-error>
}
@if (dateModel.errors?.matDatepickerParse) {
<mat-error>Invalid date</mat-error>
}
</mat-form-field>
</div>

<div class="time">
<mat-form-field appearance="fill" floatLabel="always">
<mat-label>
{{ title }} Time {{ timezone === 'gmt' ? '(GMT)' : '(Local)' }}
{{ title() }} Time {{ timezone() === 'gmt' ? '(GMT)' : '(Local)' }}
</mat-label>
<mat-timepicker #timePicker interval="30m"></mat-timepicker>
<input matInput [matTimepicker]="timePicker" [(ngModel)]="timeValue" [required]="required"
<input matInput [matTimepicker]="timePicker" [(ngModel)]="timeValue" [required]="required()"
(ngModelChange)="onTime()" autocomplete="off" />
<mat-timepicker-toggle matSuffix [for]="timePicker"></mat-timepicker-toggle>
<mat-error>Invalid time</mat-error>
Expand Down
110 changes: 96 additions & 14 deletions web-app/src/app/datetime-picker/datetime-picker.component.spec.ts
Original file line number Diff line number Diff line change
@@ -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<DatetimePickerComponent>;
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();
});
});
*/
Loading
Loading