+
+
+
+
+
+
+
+
+
+
+ No associated tags
+
+
+
+ No additional tags
+
+
+
+ No description found
+
+
+
+ No files found
+
diff --git a/src/app/components/import-annotations/details/details.component.scss b/src/app/components/import-annotations/details/details.component.scss
new file mode 100644
index 000000000..66fc8bb3b
--- /dev/null
+++ b/src/app/components/import-annotations/details/details.component.scss
@@ -0,0 +1,3 @@
+section {
+ margin-top: 2rem;
+}
diff --git a/src/app/components/import-annotations/details/details.component.spec.ts b/src/app/components/import-annotations/details/details.component.spec.ts
new file mode 100644
index 000000000..cd864e8b3
--- /dev/null
+++ b/src/app/components/import-annotations/details/details.component.spec.ts
@@ -0,0 +1,607 @@
+import {
+ SpectatorRouting,
+ SpyObject,
+ createRoutingFactory,
+} from "@ngneat/spectator";
+import { SharedModule } from "@shared/shared.module";
+import { MockBawApiModule } from "@baw-api/baw-apiMock.module";
+import { assertPageInfo } from "@test/helpers/pageRoute";
+import { ToastrService } from "ngx-toastr";
+import { AudioEventImport, IAudioEventImport } from "@models/AudioEventImport";
+import { generateAudioEventImport } from "@test/fakes/AudioEventImport";
+import { Injector } from "@angular/core";
+import {
+ AudioEventImportFileRead,
+ IAudioEventImportFileRead,
+} from "@models/AudioEventImport/AudioEventImportFileRead";
+import { TagsService } from "@baw-api/tag/tags.service";
+import { Tag } from "@models/Tag";
+import { of } from "rxjs";
+import { ShallowAudioEventsService } from "@baw-api/audio-event/audio-events.service";
+import { AudioEvent } from "@models/AudioEvent";
+import {
+ AUDIO_EVENT_IMPORT,
+ SHALLOW_AUDIO_EVENT,
+ TAG,
+} from "@baw-api/ServiceTokens";
+import { modelData } from "@test/helpers/faker";
+import { AudioEventImportService } from "@baw-api/audio-event-import/audio-event-import.service";
+import {
+ AudioEventImportFileWrite,
+ IAudioEventImportFileWrite,
+} from "@models/AudioEventImport/AudioEventImportFileWrite";
+import { DateTime, Settings } from "luxon";
+import { Id } from "@interfaces/apiInterfaces";
+import { TypeaheadInputComponent } from "@shared/typeahead-input/typeahead-input.component";
+import { InlineListComponent } from "@shared/inline-list/inline-list.component";
+import { Filters } from "@baw-api/baw-api.service";
+import { LoadingComponent } from "@shared/loading/loading.component";
+import { fakeAsync, flush, tick } from "@angular/core/testing";
+import { AnnotationsDetailsComponent } from "./details.component";
+
+describe("AnnotationsDetailsComponent", () => {
+ let spectator: SpectatorRouting;
+
+ let injector: SpyObject;
+ let mockTagsService: SpyObject;
+ let mockEventsService: SpyObject;
+ let mockAudioEventImportService: SpyObject;
+
+ let mockAudioEventImport: AudioEventImport;
+ let mockTagModels: Tag[];
+ let mockAudioEvents: AudioEvent[];
+
+ const createComponent = createRoutingFactory({
+ component: AnnotationsDetailsComponent,
+ declarations: [
+ InlineListComponent,
+ TypeaheadInputComponent,
+ LoadingComponent,
+ ],
+ imports: [SharedModule, MockBawApiModule],
+ mocks: [ToastrService],
+ });
+
+ function setup(): void {
+ spectator = createComponent({
+ detectChanges: false,
+ data: {
+ audioEventImport: {
+ model: mockAudioEventImport,
+ },
+ },
+ });
+
+ injector = spectator.inject(Injector);
+ mockAudioEventImport["injector"] = injector;
+
+ mockAudioEventImport.files.map(
+ (fileModel: AudioEventImportFileRead) =>
+ (fileModel["injector"] = injector)
+ );
+
+ mockTagModels = [];
+ mockAudioEvents = [];
+
+ mockTagsService = spectator.inject(TAG.token);
+ mockTagsService.filter.and.callFake(() => of(mockTagModels));
+
+ mockEventsService = spectator.inject(SHALLOW_AUDIO_EVENT.token);
+ mockEventsService.filter.and.callFake(() => of(mockAudioEvents));
+
+ mockAudioEventImportService = spectator.inject(AUDIO_EVENT_IMPORT.token);
+ mockAudioEventImportService.importFile = jasmine.createSpy(
+ "importFile"
+ ) as any;
+ mockAudioEventImportService.importFile.and.callFake(() =>
+ of(mockAudioEventImport)
+ );
+ mockAudioEventImportService.show = jasmine.createSpy("show") as any;
+ mockAudioEventImportService.show.and.callFake(() =>
+ of(mockAudioEventImport)
+ );
+
+ // without mocking the timezone, tests that assert time will fail in CI
+ // and other timezones that are not the same as the developers local timezone (UTC+8)
+ const mockUserTimeZone = "Australia/Perth"; // +08:00 UTC
+ Settings.defaultZone = mockUserTimeZone;
+
+ spectator.detectChanges();
+ }
+
+ function getElementByInnerText(text: string): T {
+ return spectator.debugElement.query(
+ (element) => element.nativeElement.innerText === text
+ )?.nativeElement as T;
+ }
+
+ function addFileToImportGroup(index: number, file: File): void {
+ const requestedInputElement = getFileInputElement(index);
+
+ const dataTransfer = new DataTransfer();
+ dataTransfer.items.add(file);
+ requestedInputElement.files = dataTransfer.files;
+
+ requestedInputElement.dispatchEvent(new Event("change"));
+ spectator.detectChanges();
+ }
+
+ function addFileCollectionToImportGroup(index: number, files: File[]): void {
+ const requestedInputElement = getFileInputElement(index);
+
+ const dataTransfer = new DataTransfer();
+ files.forEach((file) => dataTransfer.items.add(file));
+ requestedInputElement.files = dataTransfer.files;
+
+ requestedInputElement.dispatchEvent(new Event("change"));
+ spectator.detectChanges();
+ }
+
+ // typeahead input functionality is tested in the typeahead component
+ // therefore, I don't want to reimplement the mocks and tests here
+ // I am therefore going to add the tag id directly to the import group
+ function addAnnotationsToImportGroup(index: number, tagIds: Id[]): void {
+ spectator.component.importGroups[index].additionalTagIds = tagIds;
+ spectator.detectChanges();
+ }
+
+ function removeImportGroup(index: number): void {
+ const removeButton = getRemoveInputGroupButton(index);
+ removeButton.click();
+ spectator.detectChanges();
+ }
+
+ function fileToImportGroup(
+ file: File,
+ data?: Partial
+ ): AudioEventImportFileWrite {
+ return new AudioEventImportFileWrite({
+ id: mockAudioEventImport.id,
+ file,
+ additionalTagIds: [],
+ commit: true,
+ ...data,
+ });
+ }
+
+ function generateMockFile(): File {
+ return new File(
+ [modelData.descriptionLong()],
+ modelData.system.commonFileName("csv")
+ );
+ }
+
+ function generateMockAudioEventImport(
+ data?: Partial
+ ): AudioEventImport {
+ const mockNewAudioEventImport: AudioEventImport = new AudioEventImport(
+ generateAudioEventImport(data)
+ );
+ mockNewAudioEventImport["injector"] = injector;
+ mockNewAudioEventImport.files.map(
+ (fileModel: AudioEventImportFileRead) =>
+ (fileModel["injector"] = injector)
+ );
+
+ return mockNewAudioEventImport;
+ }
+
+ const getImportGroupElements = (): HTMLElement[] =>
+ spectator.queryAll(".import-group");
+ const getFileInputElement = (index: number): HTMLInputElement =>
+ spectator.queryAll("input[type='file']")[index];
+ const getRemoveInputGroupButton = (index: number): HTMLButtonElement =>
+ spectator.queryAll(".remove-import-group-button")[index];
+ const importAllButton = (): HTMLButtonElement =>
+ spectator.query("button[type='submit']");
+
+ beforeEach(() => {
+ mockAudioEventImport = new AudioEventImport(generateAudioEventImport());
+ setup();
+ });
+
+ assertPageInfo(AnnotationsDetailsComponent, "test name", {
+ audioEventImport: {
+ model: new AudioEventImport(
+ generateAudioEventImport({ name: "test name" })
+ ),
+ },
+ });
+
+ it("should create", () => {
+ expect(spectator.component).toBeInstanceOf(AnnotationsDetailsComponent);
+ });
+
+ it("should make one correct audio event request", () => {
+ const expectedFilters = {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ filter: { audio_event_import_id: { eq: mockAudioEventImport.id } },
+ sorting: { direction: "desc", orderBy: "createdAt" },
+ paging: { page: 1 },
+ };
+
+ expect(mockEventsService.filter).toHaveBeenCalledOnceWith(expectedFilters);
+ });
+
+ it("should have an empty import group if no files are imported", () => {
+ const importGroupElements: HTMLElement[] = getImportGroupElements();
+ expect(importGroupElements).toHaveLength(1);
+ });
+
+ // in this test, we are creating three populated import groups
+ // therefore there should be four editable import groups
+ it("should create a new import group if all import groups have files", () => {
+ addFileToImportGroup(0, generateMockFile());
+ addFileToImportGroup(1, generateMockFile());
+ addFileToImportGroup(2, generateMockFile());
+
+ expect(getImportGroupElements()).toHaveLength(4);
+ });
+
+ it("should use the correct import group number for a new import group", () => {
+ const expectedImportGroupName = "Import Group 2";
+
+ expect(getElementByInnerText(expectedImportGroupName)).not.toExist();
+ addFileToImportGroup(0, generateMockFile());
+ expect(getElementByInnerText(expectedImportGroupName)).toExist();
+ });
+
+ it("should make the correct api calls when one import group is imported to the api", fakeAsync(() => {
+ const mockFile: File = generateMockFile();
+ const mockFileWriteModel: AudioEventImportFileWrite =
+ fileToImportGroup(mockFile);
+
+ addFileToImportGroup(0, mockFile);
+
+ // because an api call is made during the dry run, we want to make sure that we don't pass the test using the dry run call
+ // therefore we reset the api call spy so that this test will fail if dry runs work, but wet imports do not
+ mockAudioEventImportService.importFile.calls.reset();
+ const importAllButtonElement: HTMLButtonElement = importAllButton();
+ importAllButtonElement.click();
+
+ tick();
+ spectator.detectChanges();
+
+ expect(mockAudioEventImportService.importFile).toHaveBeenCalledOnceWith(
+ mockFileWriteModel
+ );
+ }));
+
+ it("should make the correct api calls when multiple import groups are imported to the api", fakeAsync(() => {
+ const mockFiles: File[] = modelData.randomArray(4, 10, () =>
+ generateMockFile()
+ );
+
+ mockFiles.forEach((file: File, i: number) => {
+ addFileToImportGroup(i, file);
+ });
+
+ // because an api call is made during the dry run, we want to make sure that we don't pass the test using the dry run call
+ // therefore we reset the api call spy so that this test will fail if dry runs work, but wet imports do not
+ mockAudioEventImportService.importFile.calls.reset();
+ const importAllButtonElement: HTMLButtonElement = importAllButton();
+ importAllButtonElement.click();
+
+ // because uploads wait for each other with promises, flushing the zone will cause all the promises to resolve
+ flush();
+ spectator.detectChanges();
+
+ // this test is supposed to fail if the empty import group is imported to the api
+ expect(mockAudioEventImportService.importFile).toHaveBeenCalledTimes(
+ mockFiles.length
+ );
+
+ mockFiles.forEach((file: File) => {
+ const mockFileWriteModel: AudioEventImportFileWrite =
+ fileToImportGroup(file);
+
+ expect(mockAudioEventImportService.importFile).toHaveBeenCalledWith(
+ mockFileWriteModel
+ );
+
+ // because the different property of each object is the file, we can check that the name of the file is not in the api call
+ // without doing this, it will compare the top level properties of the object, causing the test to not fail/fail incorrectly
+ expect(mockAudioEventImportService.importFile).toHaveBeenCalledWith(
+ jasmine.objectContaining({ file })
+ );
+ });
+ }));
+
+ it("should update the event table when an import group is imported", fakeAsync(() => {
+ const importAllButtonElement: HTMLButtonElement = importAllButton();
+ const expectedFilters = {
+ filter: { audio_event_import_id: { eq: mockAudioEventImport.id } },
+ sorting: { direction: "desc", orderBy: "createdAt" },
+ paging: { page: 1 },
+ } as Filters;
+
+ // since the events service is called on initial load
+ // therefore, we need to reset the call count so that we can isolate that the import caused the new call
+ mockEventsService.filter.calls.reset();
+
+ // import the import import group
+ addFileToImportGroup(0, generateMockFile());
+ importAllButtonElement.click();
+
+ tick();
+ spectator.detectChanges();
+
+ expect(mockEventsService.filter).toHaveBeenCalledWith(expectedFilters);
+ }));
+
+ it("should update the files table when an import group is imported", fakeAsync(() => {
+ const importAllButtonElement: HTMLButtonElement = importAllButton();
+
+ const newAudioEventImport = generateMockAudioEventImport();
+
+ mockAudioEventImportService.show.calls.reset();
+ mockAudioEventImportService.show.and.returnValue(of(newAudioEventImport));
+
+ addFileToImportGroup(0, generateMockFile());
+ importAllButtonElement.click();
+
+ tick();
+ spectator.detectChanges();
+
+ expect(spectator.component.audioEventImport).toEqual(newAudioEventImport);
+ }));
+
+ it("should not include a removed import group when importing to the api", fakeAsync(() => {
+ const mockFiles: File[] = modelData.randomArray(5, 5, () =>
+ generateMockFile()
+ );
+
+ mockFiles.forEach((file: File, i: number) => {
+ addFileToImportGroup(i, file);
+ });
+
+ // remove the second last import group
+ removeImportGroup(mockFiles.length - 2);
+ const removedFile: File = mockFiles.splice(mockFiles.length - 2, 1)[0];
+
+ mockAudioEventImportService.importFile.calls.reset();
+ const importAllButtonElement: HTMLButtonElement = importAllButton();
+ importAllButtonElement.click();
+
+ tick();
+ spectator.detectChanges();
+
+ // assert that the removed import group was not imported to the api
+ expect(mockAudioEventImportService.importFile).not.toHaveBeenCalledWith(
+ jasmine.objectContaining({
+ file: jasmine.objectContaining({
+ name: removedFile.name,
+ }),
+ })
+ );
+
+ // assert that all the other import groups that were not removed were imported to the api
+ mockFiles.forEach((file: File) => {
+ expect(mockAudioEventImportService.importFile).toHaveBeenCalledWith(
+ fileToImportGroup(file)
+ );
+
+ expect(mockAudioEventImportService.importFile).toHaveBeenCalledWith(
+ jasmine.objectContaining({ file })
+ );
+ });
+ }));
+
+ it("should use a local dateTime format for events table rows", () => {
+ const importedAt = DateTime.fromISO("2022-11-04T20:12:31.000Z");
+ // relative to UTC+8
+ const expectedDateTime = "2022-11-05 04:12:31";
+
+ // we use the interface here because we are using it as a sub-model on the AudioEventImport constructor
+ // this means that the mockImportedFile will be called with the AudioEventImportFileRead constructor
+ const mockImportedFile: IAudioEventImportFileRead = {
+ name: modelData.system.commonFileName("csv"),
+ importedAt,
+ additionalTags: [],
+ };
+
+ mockAudioEventImport = new AudioEventImport(
+ generateAudioEventImport({
+ files: [mockImportedFile],
+ })
+ );
+
+ mockAudioEventImport["injector"] = injector;
+
+ mockAudioEventImport.files.map(
+ (fileModel: AudioEventImportFileRead) =>
+ (fileModel["injector"] = injector)
+ );
+
+ spectator.component.audioEventImport = mockAudioEventImport;
+
+ spectator.detectChanges();
+
+ const importedAtColumn =
+ getElementByInnerText(expectedDateTime);
+
+ expect(importedAtColumn).toExist();
+ });
+
+ it("should use a local dateTime format for the file table rows", () => {
+ const importedAt = DateTime.fromISO("2022-11-04T20:12:31.000Z");
+ // relative to UTC+8
+ const expectedDateTime = "2022-11-05 04:12:31";
+
+ // we use the interface here because we are using it as a sub-model on the AudioEventImport constructor
+ // this means that the mockImportedFile will be called with the AudioEventImportFileRead constructor
+ const mockImportedFile: IAudioEventImportFileRead = {
+ name: modelData.system.commonFileName("csv"),
+ importedAt,
+ additionalTags: [],
+ };
+
+ mockAudioEventImport = new AudioEventImport(
+ generateAudioEventImport({
+ files: [mockImportedFile],
+ })
+ );
+
+ mockAudioEventImport["injector"] = injector;
+
+ mockAudioEventImport.files.map(
+ (fileModel: AudioEventImportFileRead) =>
+ (fileModel["injector"] = injector)
+ );
+
+ spectator.component.audioEventImport = mockAudioEventImport;
+
+ spectator.detectChanges();
+
+ const importedAtColumn =
+ getElementByInnerText(expectedDateTime);
+
+ expect(importedAtColumn).toExist();
+ });
+
+ it("should perform a dry run of an import when a file is added", () => {
+ const mockFile: File = generateMockFile();
+ const mockFileWriteModel: AudioEventImportFileWrite = fileToImportGroup(
+ mockFile,
+ { commit: false }
+ );
+
+ addFileToImportGroup(0, mockFile);
+ expect(mockAudioEventImportService.importFile).toHaveBeenCalledOnceWith(
+ mockFileWriteModel
+ );
+ });
+
+ it("should make the correct dry run calls for multiple event groups", fakeAsync(() => {
+ const mockFiles: File[] = modelData.randomArray(4, 10, () =>
+ generateMockFile()
+ );
+
+ mockFiles.forEach((file: File, i: number) => {
+ addFileToImportGroup(i, file);
+
+ expect(mockAudioEventImportService.importFile).toHaveBeenCalledWith(
+ jasmine.objectContaining({ file })
+ );
+ });
+
+ expect(mockAudioEventImportService.importFile).toHaveBeenCalledTimes(
+ mockFiles.length
+ );
+ }));
+
+ it("should perform a dry run correctly for a single event group with multiple files", () => {
+ const mockFiles: File[] = modelData.randomArray(4, 10, () =>
+ generateMockFile()
+ );
+
+ addFileCollectionToImportGroup(0, mockFiles);
+
+ mockFiles.forEach((file: File) => {
+ expect(mockAudioEventImportService.importFile).toHaveBeenCalledWith(
+ jasmine.objectContaining({ file })
+ );
+ });
+
+ expect(mockAudioEventImportService.importFile).toHaveBeenCalledTimes(
+ mockFiles.length
+ );
+ });
+
+ it("should not display a remove button for an import group with no files", () => {
+ // we want to add a new import group and assert that the empty import group does not include a remove button
+ addFileToImportGroup(0, generateMockFile());
+ const removeButtons: HTMLButtonElement[] =
+ spectator.queryAll(".remove-import-group-button");
+ expect(removeButtons).toHaveLength(1);
+ });
+
+ it("should remove an import group if the 'Remove' button is clicked", () => {
+ addFileToImportGroup(0, generateMockFile());
+ expect(getImportGroupElements()).toHaveLength(2);
+
+ removeImportGroup(0);
+
+ expect(getImportGroupElements()).toHaveLength(1);
+ });
+
+ it("should disable the 'import All' button when there is an error in an import group", () => {
+ const fakeErrors: string[] = ["audio_recording_id must be greater than 0"];
+
+ addFileToImportGroup(0, generateMockFile());
+
+ spectator.component.importGroups[0].errors = fakeErrors;
+ spectator.detectChanges();
+
+ expect(importAllButton()).toBeDisabled();
+ });
+
+ it("should not have the 'import All' button disabled if there are no errors in any import group", () => {
+ addFileToImportGroup(0, generateMockFile());
+ expect(importAllButton()).not.toBeDisabled();
+ });
+
+ it("should have the 'import All' button disabled if there are no import groups with files", () => {
+ expect(importAllButton()).toBeDisabled();
+ });
+
+ it("should import import groups correctly to the api when a file and associated tags are added", fakeAsync(() => {
+ const mockFile: File = generateMockFile();
+ const mockAssociatedTags: Id[] = modelData.randomArray(2, 5, () =>
+ modelData.id()
+ );
+
+ const mockFileWriteModel: AudioEventImportFileWrite = fileToImportGroup(
+ mockFile,
+ { additionalTagIds: mockAssociatedTags }
+ );
+
+ addFileToImportGroup(0, mockFile);
+ addAnnotationsToImportGroup(0, mockAssociatedTags);
+
+ // because an api call is made during the dry run, we want to make sure that we don't pass the test using the dry run call
+ // therefore we reset the api call spy so that this test will fail if dry runs work, but wet imports do not
+ mockAudioEventImportService.importFile.calls.reset();
+ const importAllButtonElement: HTMLButtonElement = importAllButton();
+ importAllButtonElement.click();
+
+ tick();
+ spectator.detectChanges();
+
+ expect(mockAudioEventImportService.importFile).toHaveBeenCalledOnceWith(
+ mockFileWriteModel
+ );
+
+ expect(mockAudioEventImportService.importFile).toHaveBeenCalledWith(
+ jasmine.objectContaining({
+ file: mockFile,
+ additionalTagIds: mockAssociatedTags,
+ })
+ );
+ }));
+
+ it("should disable the form inputs when an import is in progress", () => {
+ const mockFiles: File[] = modelData.randomArray(4, 10, () =>
+ generateMockFile()
+ );
+
+ mockFiles.forEach((file: File, i: number) => {
+ addFileToImportGroup(i, file);
+ });
+
+ // assert that the form is not disabled before the import is started
+ expect(spectator.component.uploading).toBeFalse();
+
+ // we don't perform an async tick here because that would cause the fake api responses to be returned, enabling the import form
+ importAllButton().click();
+ spectator.detectChanges();
+
+ expect(spectator.component.uploading).toBeTrue();
+ expect(importAllButton()).toBeDisabled();
+
+ mockFiles.forEach((_, i: number) => {
+ expect(getRemoveInputGroupButton(i)).toBeDisabled();
+ expect(getFileInputElement(i)).toBeDisabled();
+ });
+ });
+});
diff --git a/src/app/components/import-annotations/details/details.component.ts b/src/app/components/import-annotations/details/details.component.ts
new file mode 100644
index 000000000..cd7cab7e1
--- /dev/null
+++ b/src/app/components/import-annotations/details/details.component.ts
@@ -0,0 +1,352 @@
+import { Component, OnInit } from "@angular/core";
+import { List } from "immutable";
+import { PageComponent } from "@helpers/page/pageComponent";
+import { AudioEventImport } from "@models/AudioEventImport";
+import { ActivatedRoute, Router } from "@angular/router";
+import {
+ AudioEventImportService,
+ audioEventImportResolvers,
+} from "@baw-api/audio-event-import/audio-event-import.service";
+import { contains, filterAnd, notIn } from "@helpers/filters/filters";
+import { Tag } from "@models/Tag";
+import { takeUntil, Observable, BehaviorSubject } from "rxjs";
+import { Id } from "@interfaces/apiInterfaces";
+import { AudioEvent } from "@models/AudioEvent";
+import { TagsService } from "@baw-api/tag/tags.service";
+import { AudioEventImportFileWrite } from "@models/AudioEventImport/AudioEventImportFileWrite";
+import { Filters, InnerFilter } from "@baw-api/baw-api.service";
+import { AbstractModel } from "@models/AbstractModel";
+import { defaultSuccessMsg } from "@helpers/formTemplate/formTemplate";
+import { ToastrService } from "ngx-toastr";
+import { ShallowAudioEventsService } from "@baw-api/audio-event/audio-events.service";
+import { BawApiError } from "@helpers/custom-errors/baw-api-error";
+import { FORBIDDEN } from "http-status";
+import {
+ AudioEventError,
+ ImportedAudioEvent,
+} from "@models/AudioEventImport/ImportedAudioEvent";
+import { deleteAnnotationImportModal } from "../import-annotations.modals";
+import {
+ annotationsImportMenuItem,
+ editAnnotationImportMenuItem,
+ annotationsImportCategory,
+ annotationImportMenuItem,
+} from "../import-annotations.menu";
+
+export const annotationMenuActions = [
+ annotationsImportMenuItem,
+ editAnnotationImportMenuItem,
+ deleteAnnotationImportModal,
+];
+
+const audioEventImportKey = "audioEventImport";
+
+interface ImportGroup {
+ /** The iterator object of files to be imported */
+ files: FileList;
+ /** An array of errors encountered during a dry run */
+ errors: string[];
+ /**
+ * List of additional tag IDs that are not found within the imported file and will be associated with every event within the import group
+ * This is separate from the identified events because additional tags are typically used for grouping events eg. "testing" and "training"
+ */
+ additionalTagIds: Id[];
+ /** An array of events that were found within the imported file during the dry run */
+ identifiedEvents: ImportedAudioEvent[];
+ /** Defines whether an import group has uploaded to the baw-api without any errors */
+ uploaded: boolean;
+}
+
+/**
+ * ! This component is subject to change due to forecasted breaking api changes
+ * Most of the breaking changes address performance concerns with large annotation imports
+ *
+ * @see https://github.com/QutEcoacoustics/baw-server/issues/664
+ */
+@Component({
+ selector: "baw-annotation-import",
+ templateUrl: "details.component.html",
+ styleUrls: ["details.component.scss"],
+})
+class AnnotationsDetailsComponent extends PageComponent implements OnInit {
+ public constructor(
+ private route: ActivatedRoute,
+ private tagsApi: TagsService,
+ private eventsApi: ShallowAudioEventsService,
+ private eventImportsApi: AudioEventImportService,
+ private notifications: ToastrService,
+ private router: Router
+ ) {
+ super();
+ }
+
+ public importGroups: ImportGroup[] = [this.emptyImportGroup];
+ public audioEventImport: AudioEventImport;
+ // we use this boolean to disable the import form when an upload is in progress
+ public uploading: boolean = false;
+ protected filters$: BehaviorSubject>;
+ private defaultFilters: Filters = {
+ sorting: {
+ direction: "desc",
+ orderBy: "createdAt",
+ },
+ };
+
+ // we want to create each new import group from a template by value
+ // if it is done by reference, we would be modifying the same import group
+ // therefore, we use a getter because they internally work like methods, and return a new object each time
+ private get emptyImportGroup(): ImportGroup {
+ return {
+ files: null,
+ additionalTagIds: [],
+ errors: [],
+ identifiedEvents: [],
+ uploaded: false,
+ };
+ }
+
+ public ngOnInit(): void {
+ const routeData = this.route.snapshot.data;
+ this.audioEventImport = routeData[audioEventImportKey].model;
+
+ this.filters$ = new BehaviorSubject(this.defaultFilters);
+ }
+
+ // used to fetch all previously imported events for the events ngx-datatable
+ protected getModels = (
+ filters: Filters
+ ): Observable => {
+ const eventImportFilters: Filters = {
+ filter: {
+ audio_event_import_id: {
+ eq: this.audioEventImport.id,
+ },
+ } as InnerFilter,
+ ...filters,
+ };
+
+ return this.eventsApi.filter(eventImportFilters);
+ };
+
+ protected deleteModel(): void {
+ this.eventImportsApi
+ .destroy(this.audioEventImport)
+ .pipe(takeUntil(this.unsubscribe))
+ .subscribe({
+ complete: () => {
+ this.notifications.success(
+ defaultSuccessMsg("destroyed", this.audioEventImport.name)
+ );
+ this.router.navigateByUrl(
+ annotationsImportMenuItem.route.toRouterLink()
+ );
+ },
+ });
+ }
+
+ // since the typeahead input returns an array of models, but the api wants the associated tags as an array of id's
+ // we use this helper function to convert the array of models to an array of id's that can be sent in the api request
+ protected getIdsFromAbstractModelArray(items: object[]): Id[] {
+ return items.map((item: AbstractModel): Id => item.id);
+ }
+
+ protected pushToImportGroups(model: ImportGroup, event): void {
+ const files: FileList = event.target.files;
+ model.files = files;
+
+ this.performDryRun(model);
+
+ // if the user updates an existing import group, we don't want to create a new one
+ // however, if the user uses the last empty import group, we want to create a new empty one
+ // that they can use to create a new import group
+ if (this.areImportGroupsFull()) {
+ this.importGroups.push(this.emptyImportGroup);
+ }
+ }
+
+ // uses a reference to the ImportGroup object and update the additional tag ids property
+ protected updateAdditionalTagIds(
+ model: ImportGroup,
+ additionalTagIds: Id[]
+ ): void {
+ model.additionalTagIds = additionalTagIds;
+ this.performDryRun(model);
+ }
+
+ // a predicate to check if every import group is valid
+ // this is used for form validation
+ protected areImportGroupsValid(): boolean {
+ const importErrors = this.importGroups.flatMap((model) => model?.errors);
+ return this.importGroups.length > 1 && importErrors.length === 0;
+ }
+
+ protected removeFromImport(model: ImportGroup): void {
+ const index = this.importGroups.indexOf(model);
+ if (index !== -1) {
+ this.importGroups.splice(index, 1);
+ }
+ }
+
+ // sends all import groups to the api if there are no errors
+ protected async uploadImportGroups(): Promise {
+ // importing invalid annotation imports results in an internal server error
+ // we should therefore not submit any upload groups if there are any errors
+ if (!this.areImportGroupsValid()) {
+ return;
+ }
+
+ // creates a lock so that no more files can be added to the upload queue while the upload is in progress
+ this.uploading = true;
+
+ // we use a "for-of" loop here because if we use a forEach loop (with async callbacks)
+ // it doesn't properly await for each import group to finish uploading
+ for (const model of this.importGroups) {
+ await this.importEventGroup(model);
+ }
+
+ this.importGroups = this.importGroups.filter((model) => !model.uploaded);
+
+ this.refreshTables();
+ this.uploading = false;
+ }
+
+ protected async importEventGroup(model: ImportGroup): Promise {
+ if (!model.files) {
+ return;
+ }
+
+ for (const file of model.files) {
+ await this.uploadFile(model, file);
+ }
+ }
+
+ private uploadFile(model: ImportGroup, file: File): Promise {
+ const audioEventImportModel: AudioEventImportFileWrite =
+ new AudioEventImportFileWrite({
+ id: this.audioEventImport.id,
+ file,
+ additionalTagIds: model.additionalTagIds,
+ commit: true,
+ });
+
+ return this.eventImportsApi
+ .importFile(audioEventImportModel)
+ .pipe(takeUntil(this.unsubscribe))
+ .toPromise()
+ .finally(() => {
+ model.uploaded = true;
+ })
+ .catch((error: BawApiError) => {
+ // some of the default error messages are ambiguous on this page
+ // e.g. "you do not have access to this page" means that the user doesn't have access to the audio recording
+ if (error.status === FORBIDDEN) {
+ model.errors.push(
+ "You do not have access to all the audio recordings or tags in your files."
+ );
+ }
+ });
+ }
+
+ private performDryRun(model: ImportGroup) {
+ // we perform a dry run of the import to check for errors
+ for (const file of model.files) {
+ const audioEventImportModel: AudioEventImportFileWrite =
+ new AudioEventImportFileWrite({
+ id: this.audioEventImport.id,
+ file,
+ additionalTagIds: model.additionalTagIds,
+ commit: false,
+ });
+
+ this.eventImportsApi
+ .importFile(audioEventImportModel)
+ .pipe(takeUntil(this.unsubscribe))
+ .subscribe((result: AudioEventImport) => {
+ model.errors = [];
+ model.identifiedEvents = [];
+
+ // since the model is on the heap and passed as reference, we can update the model here and it will globally update the model
+ result.importedEvents.forEach((event: ImportedAudioEvent) => {
+ model.identifiedEvents.push(event);
+
+ const errors: AudioEventError[] = event.errors;
+
+ for (const error of errors) {
+ model.errors.push(...this.errorToHumanReadable(error));
+ }
+ });
+ });
+ }
+ }
+
+ // deserialization converts all object keys to camelCase
+ // therefore, to make them human readable we add spaces
+ private errorToHumanReadable(error: AudioEventError): string[] {
+ const errors: string[] = [];
+
+ const errorKeys: string[] = Object.keys(error);
+
+ for (const errorKey of errorKeys) {
+ let errorValue: string = error[errorKey].join(", ");
+
+ // sometimes the error value includes the key e.g. "startTimeSeconds is not a number"
+ // in this case, we should not prepend the key to the error value
+ if (!errorValue.includes(errorKey)) {
+ errorValue = `${errorKey} ${errorValue}`;
+ }
+
+ errors.push(errorValue);
+ }
+
+ return errors;
+ }
+
+ // because we create a new empty import group if all import groups are full
+ // we use this predicate to check if every import group has files
+ private areImportGroupsFull(): boolean {
+ return this.importGroups.every((model) => model.files !== null);
+ }
+
+ // because the event and file tables are updated through api requests
+ // we have to make new api requests to the page
+ private refreshTables(): void {
+ // we use the default filter here to prevent two api requests being sent
+ // e.g. if we set the filters to {} here, it would make an api call with the {} filters
+ // then another one with the default filters
+ this.filters$.next(this.defaultFilters);
+
+ // because the "files" property is a sub model on the audio event import model
+ // we have to refetch the audio event import model to update the files table
+ this.eventImportsApi
+ .show(this.audioEventImport)
+ .pipe(takeUntil(this.unsubscribe))
+ .subscribe((result: AudioEventImport) => {
+ this.audioEventImport = result;
+ });
+ }
+
+ // callback used by the typeahead input to search for associated tags
+ protected searchTagsTypeaheadCallback = (
+ text: string,
+ activeItems: Tag[]
+ ): Observable =>
+ this.tagsApi.filter({
+ filter: filterAnd(
+ contains("text", text),
+ notIn("text", activeItems)
+ ),
+ });
+}
+
+AnnotationsDetailsComponent.linkToRoute({
+ category: annotationsImportCategory,
+ pageRoute: annotationImportMenuItem,
+ menus: {
+ actions: List(annotationMenuActions),
+ },
+ resolvers: {
+ [audioEventImportKey]: audioEventImportResolvers.show,
+ },
+});
+
+export { AnnotationsDetailsComponent };
diff --git a/src/app/components/import-annotations/edit/edit.component.spec.ts b/src/app/components/import-annotations/edit/edit.component.spec.ts
new file mode 100644
index 000000000..725859e67
--- /dev/null
+++ b/src/app/components/import-annotations/edit/edit.component.spec.ts
@@ -0,0 +1,97 @@
+import {
+ SpectatorRouting,
+ SpyObject,
+ createRoutingFactory,
+} from "@ngneat/spectator";
+import { SharedModule } from "@shared/shared.module";
+import { MockBawApiModule } from "@baw-api/baw-apiMock.module";
+import { FormsModule } from "@angular/forms";
+import { ToastrService } from "ngx-toastr";
+import { assertPageInfo } from "@test/helpers/pageRoute";
+import { AudioEventImport } from "@models/AudioEventImport";
+import { AudioEventImportService } from "@baw-api/audio-event-import/audio-event-import.service";
+import { Subject } from "rxjs";
+import { testFormlyFields } from "@test/helpers/formly";
+import { modelData } from "@test/helpers/faker";
+import schema from "../audio-event-import.schema.json";
+import { EditAnnotationsComponent } from "./edit.component";
+
+describe("EditAnnotationsComponent", () => {
+ const { fields } = schema;
+
+ let spectator: SpectatorRouting;
+ let apiSpy: SpyObject;
+ let defaultModel: AudioEventImport;
+
+ const createComponent = createRoutingFactory({
+ component: EditAnnotationsComponent,
+ declarations: [],
+ imports: [FormsModule, SharedModule, MockBawApiModule],
+ mocks: [ToastrService],
+ });
+
+ function setup(): void {
+ defaultModel = new AudioEventImport({
+ name: modelData.name.jobTitle(),
+ description: modelData.description(),
+ });
+
+ spectator = createComponent({
+ detectChanges: false,
+ });
+
+ apiSpy = spectator.inject(AudioEventImportService);
+ apiSpy.update = jasmine.createSpy("update") as any;
+ apiSpy.update.and.callFake(() => new Subject());
+
+ spectator.detectChanges();
+ spectator.component.model = defaultModel;
+ spectator.detectChanges();
+ }
+
+ beforeEach(() => setup());
+
+ assertPageInfo(EditAnnotationsComponent, "Edit");
+
+ describe("form", () => {
+ testFormlyFields([
+ {
+ testGroup: "Import Name Input",
+ field: fields[0],
+ key: "name",
+ label: "Import Name",
+ type: "input",
+ inputType: "text",
+ required: true,
+ },
+ {
+ testGroup: "Description Input",
+ field: fields[1],
+ key: "description",
+ label: "Description",
+ type: "textarea",
+ required: false,
+ },
+ ]);
+ });
+
+ describe("component", () => {
+ it("should create", () => {
+ expect(spectator.component).toBeInstanceOf(EditAnnotationsComponent);
+ });
+
+ it("should call the api with the correct model when the form is submitted", () => {
+ const model = new AudioEventImport({
+ name: modelData.name.jobTitle(),
+ description: modelData.description(),
+ });
+
+ spectator.component.submit(model);
+ expect(apiSpy.update).toHaveBeenCalledOnceWith(model);
+ });
+
+ it("should not call the api before the form is submitted", () => {
+ expect(apiSpy.update).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/src/app/components/import-annotations/edit/edit.component.ts b/src/app/components/import-annotations/edit/edit.component.ts
new file mode 100644
index 000000000..bcbc1b21c
--- /dev/null
+++ b/src/app/components/import-annotations/edit/edit.component.ts
@@ -0,0 +1,69 @@
+import { Component, OnInit } from "@angular/core";
+import { List } from "immutable";
+import {
+ AudioEventImportService,
+ audioEventImportResolvers,
+} from "@baw-api/audio-event-import/audio-event-import.service";
+import { ActivatedRoute, Router } from "@angular/router";
+import { ToastrService } from "ngx-toastr";
+import { AudioEventImport } from "@models/AudioEventImport";
+import {
+ FormTemplate,
+ defaultSuccessMsg,
+} from "@helpers/formTemplate/formTemplate";
+import { annotationMenuActions } from "../details/details.component";
+import schema from "../audio-event-import.schema.json";
+import { annotationsImportCategory, editAnnotationImportMenuItem } from "../import-annotations.menu";
+
+const audioEventImportKey = "audioEventImport";
+
+@Component({
+ selector: "baw-edit-annotation-import",
+ template: `
+
+ `,
+})
+class EditAnnotationsComponent
+ extends FormTemplate
+ implements OnInit
+{
+ public constructor(
+ private api: AudioEventImportService,
+ route: ActivatedRoute,
+ notifications: ToastrService,
+ router: Router
+ ) {
+ super(notifications, route, router, {
+ getModel: (models) => models[audioEventImportKey] as AudioEventImport,
+ successMsg: (model) => defaultSuccessMsg("updated", model.name),
+ redirectUser: (model) => this.router.navigateByUrl(model.viewUrl),
+ });
+ }
+
+ public fields = schema.fields;
+
+ protected apiAction(model: Partial) {
+ return this.api.update(new AudioEventImport(model));
+ }
+}
+
+EditAnnotationsComponent.linkToRoute({
+ category: annotationsImportCategory,
+ pageRoute: editAnnotationImportMenuItem,
+ menus: {
+ actions: List(annotationMenuActions),
+ },
+ resolvers: {
+ [audioEventImportKey]: audioEventImportResolvers.show,
+ },
+});
+
+export { EditAnnotationsComponent };
diff --git a/src/app/components/import-annotations/import-annotations.menu.ts b/src/app/components/import-annotations/import-annotations.menu.ts
new file mode 100644
index 000000000..6caedfe3d
--- /dev/null
+++ b/src/app/components/import-annotations/import-annotations.menu.ts
@@ -0,0 +1,63 @@
+import { Category, menuRoute } from "@interfaces/menusInterfaces";
+import { defaultEditIcon, isLoggedInPredicate } from "src/app/app.menus";
+import { CommonRouteTitles } from "src/app/stringConstants";
+import { AudioEventImport } from "@models/AudioEventImport";
+import { retrieveResolvedModel } from "@baw-api/resolver-common";
+import { RouterStateSnapshot } from "@angular/router";
+import {
+ annotationImportRoute,
+ annotationsImportRoute,
+ newAnnotationImportRoute,
+} from "./import-annotations.routes";
+
+export const annotationsImportCategory: Category = {
+ icon: ["fas", "file-import"],
+ label: "Batch Import Annotations",
+ route: annotationsImportRoute,
+};
+
+// we cannot include a route guard predicate for "project editor"
+// as the annotation imports are monted under the root path "http://ecosounds.org/batch_annotations"
+// therefore, we validate capabilities in the import component during the dry run
+export const annotationsImportMenuItem = menuRoute({
+ icon: ["fas", "file-import"],
+ label: "Batch Import Annotations",
+ predicate: isLoggedInPredicate,
+ route: annotationsImportRoute,
+ tooltip: () => "(BETA) View bulk imports for this project",
+});
+
+export const annotationImportMenuItem = menuRoute({
+ icon: ["fas", "file-import"],
+ label: "Batch Import Annotation",
+ parent: annotationsImportMenuItem,
+ predicate: isLoggedInPredicate,
+ route: annotationImportRoute,
+ tooltip: () => "(BETA) View bulk imports for this project",
+ breadcrumbResolve: (pageInfo) =>
+ retrieveResolvedModel(pageInfo, AudioEventImport)?.name,
+ title: (routeData: RouterStateSnapshot): string => {
+ const componentModel = routeData.root.firstChild.data;
+ return componentModel?.audioEventImport.model.name;
+ },
+});
+
+export const newAnnotationImportMenuItem = menuRoute({
+ icon: ["fas", "upload"],
+ label: "Import New Annotations",
+ parent: annotationsImportMenuItem,
+ predicate: isLoggedInPredicate,
+ route: newAnnotationImportRoute,
+ primaryBackground: true,
+ tooltip: () => "(BETA) Import new annotations to this project",
+});
+
+export const editAnnotationImportMenuItem = menuRoute({
+ icon: defaultEditIcon,
+ label: "Edit this annotation import",
+ parent: annotationImportMenuItem,
+ predicate: isLoggedInPredicate,
+ route: annotationImportMenuItem.route.add("edit"),
+ tooltip: () => "(BETA) Edit this annotation import",
+ title: () => CommonRouteTitles.routeEditTitle,
+});
diff --git a/src/app/components/import-annotations/import-annotations.modals.ts b/src/app/components/import-annotations/import-annotations.modals.ts
new file mode 100644
index 000000000..586844bd8
--- /dev/null
+++ b/src/app/components/import-annotations/import-annotations.modals.ts
@@ -0,0 +1,15 @@
+import { defaultDeleteIcon, isLoggedInPredicate } from "src/app/app.menus";
+import { menuModal } from "@menu/widgetItem";
+import { DeleteModalComponent } from "@shared/delete-modal/delete-modal.component";
+import { DetailsComponent } from "@components/projects/pages/details/details.component";
+import { annotationsImportMenuItem } from "./import-annotations.menu";
+
+export const deleteAnnotationImportModal = menuModal({
+ icon: defaultDeleteIcon,
+ label: "Delete annotation import",
+ parent: annotationsImportMenuItem,
+ tooltip: () => "Delete this annotation import",
+ predicate: isLoggedInPredicate,
+ component: DeleteModalComponent,
+ successCallback: (pageComponentInstance?: DetailsComponent) => pageComponentInstance.deleteModel(),
+});
diff --git a/src/app/components/import-annotations/import-annotations.module.ts b/src/app/components/import-annotations/import-annotations.module.ts
new file mode 100644
index 000000000..4dc6d53f7
--- /dev/null
+++ b/src/app/components/import-annotations/import-annotations.module.ts
@@ -0,0 +1,26 @@
+import { getRouteConfigForPage } from "@helpers/page/pageRouting";
+import { NgModule } from "@angular/core";
+import { SharedModule } from "@shared/shared.module";
+import { RouterModule } from "@angular/router";
+import { annotationsImportRoute } from "./import-annotations.routes";
+import { AnnotationsListComponent } from "./list/list.component";
+import { AnnotationsDetailsComponent } from "./details/details.component";
+import { NewAnnotationsComponent } from "./new/new.component";
+import { EditAnnotationsComponent } from "./edit/edit.component";
+
+const components = [
+ // Pages
+ AnnotationsListComponent,
+ AnnotationsDetailsComponent,
+ NewAnnotationsComponent,
+ EditAnnotationsComponent,
+];
+
+const routes = annotationsImportRoute.compileRoutes(getRouteConfigForPage);
+
+@NgModule({
+ declarations: [...components],
+ imports: [SharedModule, RouterModule.forChild(routes)],
+ exports: [RouterModule, ...components],
+})
+export class AnnotationsImportModule {}
diff --git a/src/app/components/import-annotations/import-annotations.routes.ts b/src/app/components/import-annotations/import-annotations.routes.ts
new file mode 100644
index 000000000..c4ba1ae94
--- /dev/null
+++ b/src/app/components/import-annotations/import-annotations.routes.ts
@@ -0,0 +1,5 @@
+import { StrongRoute } from "@interfaces/strongRoute";
+
+export const annotationsImportRoute = StrongRoute.newRoot().addFeatureModule("batch_annotations");
+export const annotationImportRoute = annotationsImportRoute.add(":annotationId");
+export const newAnnotationImportRoute = annotationsImportRoute.add("new");
diff --git a/src/app/components/import-annotations/list/list.component.html b/src/app/components/import-annotations/list/list.component.html
new file mode 100644
index 000000000..eb730a9cc
--- /dev/null
+++ b/src/app/components/import-annotations/list/list.component.html
@@ -0,0 +1,83 @@
+
Are you certain you wish to delete this annotation import?
+
+
All annotations imported will be lost
+
+
+
+
+
diff --git a/src/app/components/import-annotations/list/list.component.spec.ts b/src/app/components/import-annotations/list/list.component.spec.ts
new file mode 100644
index 000000000..fc38ab331
--- /dev/null
+++ b/src/app/components/import-annotations/list/list.component.spec.ts
@@ -0,0 +1,180 @@
+import {
+ SpectatorRouting,
+ SpyObject,
+ createRoutingFactory,
+} from "@ngneat/spectator";
+import { SharedModule } from "@shared/shared.module";
+import { MockBawApiModule } from "@baw-api/baw-apiMock.module";
+import { assertPageInfo } from "@test/helpers/pageRoute";
+import { ToastrService } from "ngx-toastr";
+import { AudioEventImport } from "@models/AudioEventImport";
+import { generateAudioEventImport } from "@test/fakes/AudioEventImport";
+import { AUDIO_EVENT_IMPORT } from "@baw-api/ServiceTokens";
+import { AudioEventImportService } from "@baw-api/audio-event-import/audio-event-import.service";
+import { of } from "rxjs";
+import { Injector } from "@angular/core";
+import { UserLinkComponent } from "@shared/user-link/user-link/user-link.component";
+import { User } from "@models/User";
+import { generateUser } from "@test/fakes/User";
+import { Filters } from "@baw-api/baw-api.service";
+import { DateTime, Settings } from "luxon";
+import { NgbModal, NgbModalConfig } from "@ng-bootstrap/ng-bootstrap";
+import { fakeAsync, tick } from "@angular/core/testing";
+import { AnnotationsListComponent } from "./list.component";
+
+describe("AnnotationsListComponent", () => {
+ let spectator: SpectatorRouting;
+ let fakeAnnotationImport: AudioEventImport;
+ let defaultUser: User;
+ let mockApi: SpyObject;
+ let modalService: SpyObject;
+ let modalConfigService: SpyObject;
+
+ const createComponent = createRoutingFactory({
+ component: AnnotationsListComponent,
+ declarations: [UserLinkComponent],
+ imports: [SharedModule, MockBawApiModule],
+ mocks: [ToastrService],
+ });
+
+ function setup(): void {
+ defaultUser = new User(generateUser());
+
+ // we set the dateTime manually so that we can make assertions in tests about localised time
+ const createdAt = DateTime.fromISO("2022-11-04T20:12:31.000Z");
+ fakeAnnotationImport = new AudioEventImport(
+ generateAudioEventImport({
+ createdAt,
+ })
+ );
+
+ fakeAnnotationImport.addMetadata({
+ paging: { items: 1, page: 0, total: 1, maxPage: 5 },
+ });
+ spyOnProperty(fakeAnnotationImport, "creator").and.callFake(
+ () => defaultUser
+ );
+
+ spectator = createComponent({
+ detectChanges: false,
+ });
+
+ const injector = spectator.inject(Injector);
+ mockApi = spectator.inject(AUDIO_EVENT_IMPORT.token);
+
+ fakeAnnotationImport["injector"] = injector;
+
+ mockApi.filter = jasmine.createSpy("filter") as any;
+ mockApi.filter.and.callFake(() => of([fakeAnnotationImport]));
+
+ mockApi.destroy = jasmine.createSpy("destroy") as any;
+ mockApi.destroy.and.callFake(() => of(null));
+
+ // inject the NgbModal service so that we can
+ // dismiss all modals at the end of every test
+ modalService = spectator.inject(NgbModal);
+
+ // inject the bootstrap modal config service so that we can disable animations
+ // this is needed so that modals can be opened without waiting for the async animation
+ modalConfigService = spectator.inject(NgbModalConfig);
+ modalConfigService.animation = false;
+
+ // without mocking the timezone, tests that assert time will fail in CI
+ // and other timezones that are not the same as the developers local timezone (UTC+8)
+ // additionally, I chose Australia/Perth as day light savings is not observed in this timezone
+ const mockUserTimeZone = "Australia/Perth"; // +08:00 UTC
+ Settings.defaultZone = mockUserTimeZone;
+
+ spectator.detectChanges();
+ }
+
+ function getElementByInnerText(text: string): T {
+ return spectator.debugElement.query(
+ (element) => element.nativeElement.innerText === text
+ )?.nativeElement as T;
+ }
+
+ const viewImportButton = (): HTMLButtonElement =>
+ spectator.query("[name='view-button']");
+ const deleteImportButton = (): HTMLButtonElement =>
+ spectator.query("[name='delete-button']");
+ // I must use { root: true }
+ const modalConfirmButton = (): HTMLButtonElement =>
+ spectator.query(".btn-danger", { root: true });
+
+ beforeEach(() => setup());
+
+ afterEach(() => {
+ // if we keep modals open, it will impact the next test
+ // therefore, we should dismiss all modals at the end of every test
+ modalService.dismissAll();
+ });
+
+ assertPageInfo(AnnotationsListComponent, "Batch Import Annotations");
+
+ it("should create", () => {
+ expect(spectator.component).toBeInstanceOf(AnnotationsListComponent);
+ });
+
+ it("should make one api call on load", () => {
+ expect(mockApi.filter).toHaveBeenCalledTimes(1);
+ });
+
+ it("should make the correct api call on load", () => {
+ const expectedFilterBody: Filters = {
+ sorting: { direction: "desc", orderBy: "createdAt" },
+ paging: { page: 1 },
+ };
+
+ expect(mockApi.filter).toHaveBeenCalledWith(expectedFilterBody);
+ });
+
+ it("should show created dates in the users local timezone", () => {
+ const expectedLocalTime = "2022-11-05 04:12:31";
+
+ const importCreatedColumn =
+ getElementByInnerText(expectedLocalTime);
+
+ expect(importCreatedColumn).toExist();
+ });
+
+ it("should have clickable view buttons next to each import", () => {
+ const viewButton = viewImportButton();
+ expect(viewButton).toExist();
+ });
+
+ it("should have a clickable delete button next to each import", () => {
+ const deleteButton = deleteImportButton();
+ expect(deleteButton).toExist();
+ });
+
+ it("should open a modal when the delete button is clicked", fakeAsync(() => {
+ const deleteButton = deleteImportButton();
+ deleteButton.click();
+
+ tick();
+
+ // we have to use root: true here otherwise the modal window cannot be queried
+ const modalElement = spectator.query("ngb-modal-window", {
+ root: true,
+ });
+ expect(modalElement).toExist();
+ }));
+
+ it("should make the correct api call when the delete button in the delete modal is clicked", fakeAsync(() => {
+ // open the modal
+ const deleteButton = deleteImportButton();
+ deleteButton.click();
+
+ tick();
+
+ // click the confirmation button inside the modal
+ const modalDeleteButton: HTMLButtonElement = modalConfirmButton();
+ modalDeleteButton.click();
+
+ tick();
+ spectator.detectChanges();
+
+ expect(mockApi.destroy).toHaveBeenCalledOnceWith(fakeAnnotationImport.id);
+ }));
+});
diff --git a/src/app/components/import-annotations/list/list.component.ts b/src/app/components/import-annotations/list/list.component.ts
new file mode 100644
index 000000000..83f82582c
--- /dev/null
+++ b/src/app/components/import-annotations/list/list.component.ts
@@ -0,0 +1,75 @@
+import { Component, OnInit } from "@angular/core";
+import { PageComponent } from "@helpers/page/pageComponent";
+import { List } from "immutable";
+import { AudioEventImportService } from "@baw-api/audio-event-import/audio-event-import.service";
+import { BehaviorSubject, takeUntil } from "rxjs";
+import { AudioEventImport } from "@models/AudioEventImport";
+import { Filters } from "@baw-api/baw-api.service";
+import { Id } from "@interfaces/apiInterfaces";
+import { NgbModal, NgbModalRef } from "@ng-bootstrap/ng-bootstrap";
+import { ToastrService } from "ngx-toastr";
+import { annotationsImportCategory, annotationsImportMenuItem, newAnnotationImportMenuItem } from "../import-annotations.menu";
+
+export const annotationListMenuItemActions = [newAnnotationImportMenuItem];
+
+@Component({
+ selector: "baw-import-list-annotation-imports",
+ templateUrl: "list.component.html",
+})
+class AnnotationsListComponent extends PageComponent implements OnInit {
+ public constructor(
+ private api: AudioEventImportService,
+ private notifications: ToastrService,
+ private modals: NgbModal
+ ) {
+ super();
+ }
+
+ protected filters$: BehaviorSubject>;
+ private defaultFilters: Filters = {
+ sorting: {
+ direction: "desc",
+ orderBy: "createdAt",
+ },
+ };
+
+ public ngOnInit(): void {
+ this.filters$ = new BehaviorSubject(this.defaultFilters);
+ }
+
+ protected getModels = (filters: Filters) =>
+ this.api.filter(filters);
+
+ protected async deleteEventImport(
+ template: any,
+ model: AudioEventImport
+ ): Promise {
+ const modelId: Id = model.id;
+ const modelName: string = model.name;
+
+ const ref: NgbModalRef = this.modals.open(template);
+ const success = await ref.result.catch((_) => false);
+
+ if (!success) {
+ return;
+ }
+
+ this.api
+ .destroy(modelId)
+ .pipe(takeUntil(this.unsubscribe))
+ .subscribe(() => {
+ this.filters$.next(this.defaultFilters);
+ this.notifications.success(`Successfully destroyed ${modelName}`);
+ });
+ }
+}
+
+AnnotationsListComponent.linkToRoute({
+ category: annotationsImportCategory,
+ pageRoute: annotationsImportMenuItem,
+ menus: {
+ actions: List(annotationListMenuItemActions),
+ },
+});
+
+export { AnnotationsListComponent };
diff --git a/src/app/components/import-annotations/new/new.component.spec.ts b/src/app/components/import-annotations/new/new.component.spec.ts
new file mode 100644
index 000000000..a49fd4b0e
--- /dev/null
+++ b/src/app/components/import-annotations/new/new.component.spec.ts
@@ -0,0 +1,85 @@
+import { SpectatorRouting, SpyObject, createRoutingFactory } from "@ngneat/spectator";
+import { MockBawApiModule } from "@baw-api/baw-apiMock.module";
+import { ToastrService } from "ngx-toastr";
+import { assertPageInfo } from "@test/helpers/pageRoute";
+import { AudioEventImportService } from "@baw-api/audio-event-import/audio-event-import.service";
+import { FormComponent } from "@shared/form/form.component";
+import { MockComponent } from "ng-mocks";
+import { testFormlyFields } from "@test/helpers/formly";
+import { Subject } from "rxjs";
+import { AudioEventImport } from "@models/AudioEventImport";
+import { modelData } from "@test/helpers/faker";
+import schema from "../audio-event-import.schema.json";
+import { NewAnnotationsComponent } from "./new.component";
+
+describe("NewAnnotationsComponent", () => {
+ const { fields } = schema;
+
+ let spectator: SpectatorRouting;
+ let apiSpy: SpyObject;
+
+ const createComponent = createRoutingFactory({
+ component: NewAnnotationsComponent,
+ declarations: [MockComponent(FormComponent)],
+ imports: [MockBawApiModule],
+ mocks: [ToastrService],
+ });
+
+ function setup(): void {
+ spectator = createComponent({
+ detectChanges: false,
+ });
+
+ apiSpy = spectator.inject(AudioEventImportService);
+ apiSpy.create = jasmine.createSpy("create") as any;
+ apiSpy.create.and.callFake(() => new Subject());
+
+ spectator.detectChanges();
+ }
+
+ beforeEach(() => setup());
+
+ assertPageInfo(NewAnnotationsComponent, "Import New Annotations");
+
+ describe("form", () => {
+ testFormlyFields([
+ {
+ testGroup: "Import Name Input",
+ field: fields[0],
+ key: "name",
+ label: "Import Name",
+ type: "input",
+ inputType: "text",
+ required: true,
+ },
+ {
+ testGroup: "Description Input",
+ field: fields[1],
+ key: "description",
+ label: "Description",
+ type: "textarea",
+ required: false,
+ },
+ ]);
+ });
+
+ describe("component", () => {
+ it("should create", () => {
+ expect(spectator.component).toBeInstanceOf(NewAnnotationsComponent);
+ });
+
+ it("should call the api with the correct model when the form is submitted", () => {
+ const model = new AudioEventImport({
+ name: modelData.name.jobTitle(),
+ description: modelData.description(),
+ });
+
+ spectator.component.submit(model);
+ expect(apiSpy.create).toHaveBeenCalledOnceWith(model);
+ });
+
+ it("should not call the api before the form is submitted", () => {
+ expect(apiSpy.create).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/src/app/components/import-annotations/new/new.component.ts b/src/app/components/import-annotations/new/new.component.ts
new file mode 100644
index 000000000..008c28afe
--- /dev/null
+++ b/src/app/components/import-annotations/new/new.component.ts
@@ -0,0 +1,65 @@
+import { Component } from "@angular/core";
+import { List } from "immutable";
+import { ToastrService } from "ngx-toastr";
+import { ActivatedRoute, Router } from "@angular/router";
+import {
+ FormTemplate,
+ defaultSuccessMsg,
+} from "@helpers/formTemplate/formTemplate";
+import { AudioEventImport } from "@models/AudioEventImport";
+import { AudioEventImportService } from "@baw-api/audio-event-import/audio-event-import.service";
+import schema from "../audio-event-import.schema.json";
+import {
+ annotationsImportMenuItem,
+ newAnnotationImportMenuItem,
+ annotationsImportCategory,
+} from "../import-annotations.menu";
+
+export const newAnnotationMenuItemActions = [
+ annotationsImportMenuItem,
+ newAnnotationImportMenuItem,
+];
+
+@Component({
+ selector: "baw-new-annotation-import",
+ template: `
+
+ `,
+})
+class NewAnnotationsComponent extends FormTemplate {
+ public constructor(
+ protected notifications: ToastrService,
+ protected route: ActivatedRoute,
+ protected router: Router,
+ private api: AudioEventImportService
+ ) {
+ super(notifications, route, router, {
+ successMsg: (model) => defaultSuccessMsg("created", model.name),
+ redirectUser: (model) => this.router.navigateByUrl(model.viewUrl),
+ });
+ }
+
+ public fields = schema.fields;
+
+ protected apiAction(model: Partial) {
+ return this.api.create(new AudioEventImport(model));
+ }
+}
+
+NewAnnotationsComponent.linkToRoute({
+ category: annotationsImportCategory,
+ pageRoute: newAnnotationImportMenuItem,
+ menus: {
+ actions: List(newAnnotationMenuItemActions),
+ },
+});
+
+export { NewAnnotationsComponent };
diff --git a/src/app/components/projects/pages/details/details.component.ts b/src/app/components/projects/pages/details/details.component.ts
index ea409b747..8020388cb 100644
--- a/src/app/components/projects/pages/details/details.component.ts
+++ b/src/app/components/projects/pages/details/details.component.ts
@@ -17,7 +17,6 @@ import {
projectCategory,
projectMenuItem,
projectsMenuItem,
- uploadAnnotationsProjectMenuItem,
} from "@components/projects/projects.menus";
import { deleteProjectModal } from "@components/projects/projects.modals";
import { newSiteMenuItem } from "@components/sites/sites.menus";
@@ -48,7 +47,6 @@ export const projectMenuItemActions = [
audioRecordingMenuItems.list.project,
audioRecordingMenuItems.batch.project,
harvestsMenuItem,
- uploadAnnotationsProjectMenuItem,
reportMenuItems.new.project,
];
diff --git a/src/app/components/projects/pages/upload-annotations/upload-annotations.component.html b/src/app/components/projects/pages/upload-annotations/upload-annotations.component.html
deleted file mode 100644
index 819af0dc7..000000000
--- a/src/app/components/projects/pages/upload-annotations/upload-annotations.component.html
+++ /dev/null
@@ -1,37 +0,0 @@
-
Batch Annotations Upload
-
-
- Upload your annotations in bulk from outside sources such as Raven. This
- allows you to use the workflows you find to be the most efficient.
-
-
-
diff --git a/src/app/components/projects/pages/upload-annotations/upload-annotations.component.spec.ts b/src/app/components/projects/pages/upload-annotations/upload-annotations.component.spec.ts
deleted file mode 100644
index 627586546..000000000
--- a/src/app/components/projects/pages/upload-annotations/upload-annotations.component.spec.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import { ComponentFixture, TestBed } from "@angular/core/testing";
-import { assertPageInfo } from "@test/helpers/pageRoute";
-import { UploadAnnotationsComponent } from "./upload-annotations.component";
-
-describe("UploadAnnotationsComponent", () => {
- let component: UploadAnnotationsComponent;
- let fixture: ComponentFixture;
-
- beforeEach(async () => {
- await TestBed.configureTestingModule({
- declarations: [UploadAnnotationsComponent],
- }).compileComponents();
- });
-
- beforeEach(() => {
- fixture = TestBed.createComponent(UploadAnnotationsComponent);
- component = fixture.componentInstance;
- fixture.detectChanges();
- });
-
- assertPageInfo(UploadAnnotationsComponent, "Batch Upload Annotations");
-
- it("should create", () => {
- expect(component).toBeTruthy();
- });
-});
diff --git a/src/app/components/projects/pages/upload-annotations/upload-annotations.component.ts b/src/app/components/projects/pages/upload-annotations/upload-annotations.component.ts
deleted file mode 100644
index bc3a5ae8a..000000000
--- a/src/app/components/projects/pages/upload-annotations/upload-annotations.component.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-import { Component } from "@angular/core";
-import { projectResolvers } from "@baw-api/project/projects.service";
-import {
- uploadAnnotationsProjectMenuItem,
- projectCategory,
-} from "@components/projects/projects.menus";
-import { PageComponent } from "@helpers/page/pageComponent";
-import {
- allowsOriginalDownloadWidgetMenuItem,
- permissionsWidgetMenuItem,
-} from "@menu/widget.menus";
-import { List } from "immutable";
-import { projectMenuItemActions } from "../details/details.component";
-
-const projectKey = "project";
-
-@Component({
- selector: "baw-project-upload-annotations",
- templateUrl: "./upload-annotations.component.html",
- styles: [
- `
- .alert {
- border: var(--baw-danger) 1px solid !important;
- }
- `,
- ],
-})
-class UploadAnnotationsComponent extends PageComponent {
- public showErrors: boolean;
- public errors = [
- "We could not find a column that uniquely identifies the recordings",
- "In row 64, in the name column, the value abc.wav could not be matched to any of our recordings",
- "In row 128, in the end offset column, the value 2500 is before the start offset of 2700",
- ];
-
- public constructor() {
- super();
- }
-}
-
-UploadAnnotationsComponent.linkToRoute({
- category: projectCategory,
- pageRoute: uploadAnnotationsProjectMenuItem,
- menus: {
- actions: List(projectMenuItemActions),
- actionWidgets: List([
- permissionsWidgetMenuItem,
- allowsOriginalDownloadWidgetMenuItem,
- ]),
- },
- resolvers: { [projectKey]: projectResolvers.show },
-});
-
-export { UploadAnnotationsComponent };
diff --git a/src/app/components/projects/projects.menus.ts b/src/app/components/projects/projects.menus.ts
index bc3f0a15d..414ae9a8e 100644
--- a/src/app/components/projects/projects.menus.ts
+++ b/src/app/components/projects/projects.menus.ts
@@ -9,7 +9,6 @@ import {
isAdminPredicate,
isLoggedInPredicate,
isProjectEditorPredicate,
- isWorkInProgressPredicate,
} from "src/app/app.menus";
import { CommonRouteTitles } from "src/app/stringConstants";
import {
@@ -107,14 +106,3 @@ export const assignSiteMenuItem = menuRoute({
route: projectMenuItem.route.add("assign"),
tooltip: () => "Change which sites belong to this project",
});
-
-export const uploadAnnotationsProjectMenuItem = menuRoute({
- icon: ["fas", "file-import"],
- label: "Batch Upload Annotations",
- parent: projectMenuItem,
- // TODO: Once functionality is implemented, this should be changed to isProjectWriterPredicate
- predicate: isWorkInProgressPredicate,
- route: projectMenuItem.route.add("batch-annotations"),
- tooltip: () =>
- "(UNDER DEVELOPMENT) Upload multiple annotations to this project",
-});
diff --git a/src/app/components/projects/projects.module.ts b/src/app/components/projects/projects.module.ts
index 0834f80f6..a379ea475 100644
--- a/src/app/components/projects/projects.module.ts
+++ b/src/app/components/projects/projects.module.ts
@@ -12,11 +12,9 @@ import { ListComponent } from "./pages/list/list.component";
import { NewComponent } from "./pages/new/new.component";
import { PermissionsComponent } from "./pages/permissions/permissions.component";
import { RequestComponent } from "./pages/request/request.component";
-import { UploadAnnotationsComponent } from "./pages/upload-annotations/upload-annotations.component";
import { projectsRoute } from "./projects.routes";
const components = [
- UploadAnnotationsComponent,
AssignComponent,
DetailsComponent,
EditComponent,
diff --git a/src/app/components/reports/pages/event-summary/new/new.component.ts b/src/app/components/reports/pages/event-summary/new/new.component.ts
index 1a3b082de..692508d16 100644
--- a/src/app/components/reports/pages/event-summary/new/new.component.ts
+++ b/src/app/components/reports/pages/event-summary/new/new.component.ts
@@ -169,7 +169,7 @@ class NewEventReportComponent extends PageComponent implements OnInit {
}
// we need a default filter to scope to projects, regions, sites
- private defaultFilter(): InnerFilter {
+ private defaultFilter(): InnerFilter {
// we don't need to filter for every route, we only need to filter for the lowest level
// this is because all sites have a region, all regions have a project, etc..
// so it can be logically inferred
diff --git a/src/app/components/reports/reports.routes.ts b/src/app/components/reports/reports.routes.ts
index 88f3f4fff..8bb67f292 100644
--- a/src/app/components/reports/reports.routes.ts
+++ b/src/app/components/reports/reports.routes.ts
@@ -4,7 +4,7 @@ import { pointRoute } from "@components/sites/points.routes";
import { siteRoute } from "@components/sites/sites.routes";
import { StrongRoute } from "@interfaces/strongRoute";
-const eventSummaryReportRouteName = "event-summary";
+const eventSummaryReportRouteName = "event_summary";
const summaryReportRouteQueryParamResolver = (params) =>
params
diff --git a/src/app/components/shared/formly/image-input.component.ts b/src/app/components/shared/formly/image-input.component.ts
index 66bd13284..4fb60109e 100644
--- a/src/app/components/shared/formly/image-input.component.ts
+++ b/src/app/components/shared/formly/image-input.component.ts
@@ -103,6 +103,7 @@ export class ImageInputComponent extends FieldType implements AfterViewInit {
this.formControl.setValue(images.item(0));
}
+
public removeImage(): void {
this.model.image = null;
this.imageInput.nativeElement.value = null;
diff --git a/src/app/helpers/formTemplate/formTemplate.spec.ts b/src/app/helpers/formTemplate/formTemplate.spec.ts
index c324754e1..1e4dfc167 100644
--- a/src/app/helpers/formTemplate/formTemplate.spec.ts
+++ b/src/app/helpers/formTemplate/formTemplate.spec.ts
@@ -369,7 +369,7 @@ describe("formTemplate", () => {
const modelData = { id: 1 };
const successMsg = (model: MockModel) =>
"custom success message with id: " + model.id;
- setup(undefined, { successMsg });
+ setup(undefined, { successMsg: successMsg });
stubFormResets();
spec.detectChanges();
submitForm(modelData);
@@ -379,7 +379,7 @@ describe("formTemplate", () => {
it("should display failure notification on failed submission", () => {
const failureMsg = (err: BawApiError) =>
"custom failure message with message: " + err.message;
- setup(undefined, { failureMsg });
+ setup(undefined, { failureMsg: failureMsg });
stubFormResets();
interceptApiAction(errorResponse);
spec.detectChanges();
diff --git a/src/app/helpers/page/defaultMenus.ts b/src/app/helpers/page/defaultMenus.ts
index 74fc3d785..c197a63b5 100644
--- a/src/app/helpers/page/defaultMenus.ts
+++ b/src/app/helpers/page/defaultMenus.ts
@@ -2,6 +2,7 @@ import { Injectable, InjectionToken } from "@angular/core";
import { audioAnalysesMenuItem } from "@components/audio-analysis/audio-analysis.menus";
import { dataRequestMenuItem } from "@components/data-request/data-request.menus";
import { homeCategory, homeMenuItem } from "@components/home/home.menus";
+import { annotationsImportMenuItem } from "@components/import-annotations/import-annotations.menu";
import { libraryMenuItem } from "@components/library/library.menus";
import {
myAccountMenuItem,
@@ -50,6 +51,7 @@ export class DefaultMenu {
sendAudioMenuItem,
reportProblemMenuItem,
statisticsMenuItem,
+ annotationsImportMenuItem,
]),
defaultCategory: homeCategory,
};
diff --git a/src/app/helpers/query-string-parameters/query-string-parameters.ts b/src/app/helpers/query-string-parameters/query-string-parameters.ts
index 529c052c2..c1d595567 100644
--- a/src/app/helpers/query-string-parameters/query-string-parameters.ts
+++ b/src/app/helpers/query-string-parameters/query-string-parameters.ts
@@ -189,5 +189,5 @@ function durationArrayToQueryString(value: Duration[]): string {
}
function arrayToQueryString(value: unknown[]): string {
- return value.join(",");
+ return Array.from(value).join(",");
}
diff --git a/src/app/models/AbstractModel.ts b/src/app/models/AbstractModel.ts
index 3c0eb6c8e..5366cfb01 100644
--- a/src/app/models/AbstractModel.ts
+++ b/src/app/models/AbstractModel.ts
@@ -80,6 +80,12 @@ export abstract class AbstractModelWithoutId> {
return this.toObject(this.getModelAttributes());
}
+ public hasJsonOnlyAttributes(opts?: ModelSerializationOptions): boolean {
+ return this.getModelAttributes({ ...opts, formData: false }).some((attr) =>
+ isInstantiated(this[attr])
+ );
+ }
+
/**
* Convert model to JSON compatible object containing attributes which should
* be sent in a JSON API request
@@ -114,22 +120,41 @@ export abstract class AbstractModelWithoutId> {
}
for (const attr of Object.keys(data)) {
- if (!isInstantiated(data[attr])) {
+ const dataValue = data[attr];
+
+ if (!isInstantiated(dataValue)) {
continue;
}
/*
* Do not surround attribute name in quotes, it is not a valid input and
* baw-server will return a 500 response. Attributes are in the form of
- * model_name[attribute_name]
+ * model_name[attribute_name] for scalar values and model_name[attribute_name][] for array values
*
* NOTE: For JSON data, our interceptor converts keys and values to
* snakeCase, this case is more one-off and so an interceptor has not been built
*/
const modelName = snakeCase(this.kind);
const snakeCaseAttr = snakeCase(attr);
- output.append(`${modelName}[${snakeCaseAttr}]`, data[attr]);
+
+ // file lists are commonly uploaded via FormData
+ // however, a FileList is actually an object and not an array that implements an iterator
+ // we therefore need to use a separate condition to handle this case
+ if (dataValue instanceof FileList) {
+ for (const value of dataValue) {
+ output.append(`${modelName}[${snakeCaseAttr}]`, value)
+ }
+ } else if (Array.isArray(dataValue)) {
+ if (dataValue.length === 0) {
+ continue;
+ }
+
+ output.append(`${modelName}[${snakeCaseAttr}][]`, dataValue as any);
+ } else {
+ output.append(`${modelName}[${snakeCaseAttr}]`, dataValue);
+ }
}
+
return output;
}
diff --git a/src/app/models/AttributeDecorators.ts b/src/app/models/AttributeDecorators.ts
index 02ce839ed..727d708bd 100644
--- a/src/app/models/AttributeDecorators.ts
+++ b/src/app/models/AttributeDecorators.ts
@@ -3,7 +3,11 @@ import { Id, Ids, ImageSizes, ImageUrl } from "@interfaces/apiInterfaces";
import { API_ROOT } from "@services/config/config.tokens";
import fileSize from "filesize";
import { DateTime, Duration } from "luxon";
-import { AbstractModel, AssociationInjector } from "./AbstractModel";
+import {
+ AbstractModel,
+ AbstractModelConstructor,
+ AssociationInjector,
+} from "./AbstractModel";
export interface BawAttributeOptions {
create: boolean;
@@ -64,13 +68,13 @@ export function bawPersistAttr(opts?: Partial) {
* camel case.
* ! DO NOT USE IN CONJUNCTION WITH bawPersistAttr
*/
-export function bawReadonlyConvertCase() {
+export function bawReadonlyConvertCase(convertCase = true) {
return function (model: AbstractModel, key: string): void {
persistAttr(model, key, {
create: false,
update: false,
- convertCase: true,
supportedFormats: [],
+ convertCase,
});
};
}
@@ -179,6 +183,43 @@ export function bawCollection(opts?: BawDecoratorOptions) {
});
}
+/**
+ * Converts an unstructured sub model returned by the baw-api into a classed object
+ *
+ * If the model can be fetched from the baw-api through a separate request, the `@hasOne` and `@hasMany`
+ * decorators should be used
+ */
+export function bawSubModel(
+ classConstructor: AbstractModelConstructor,
+ opts?: BawDecoratorOptions
+) {
+ return createDecorator(
+ opts,
+ (model: AssociationInjector, key: symbol, value: SubModel) =>
+ (model[key] = new classConstructor(value, model["injector"]))
+ );
+}
+
+/**
+ * Converts a list or array of unstructured sub models returned by the baw-api into a
+ * an array of classed objects
+ *
+ * If the model can be fetched from the baw-api through a seperate request, the `@hasOne` and `@hasMany`
+ * decorators should be used
+ */
+export function bawSubModelCollection(
+ classConstructor: AbstractModelConstructor,
+ opts?: BawDecoratorOptions
+) {
+ return createDecorator(
+ opts,
+ (model: AssociationInjector, key: symbol, values: SubModel[]) =>
+ (model[key] = values?.map(
+ (value) => new classConstructor(value, model["injector"])
+ ))
+ );
+}
+
/**
* Convert timestamp string into DateTimeTimezone
*/
diff --git a/src/app/models/AudioEvent.ts b/src/app/models/AudioEvent.ts
index a4ca2d407..e47b8eaa8 100644
--- a/src/app/models/AudioEvent.ts
+++ b/src/app/models/AudioEvent.ts
@@ -1,5 +1,9 @@
import { Injector } from "@angular/core";
-import { AUDIO_EVENT_PROVENANCE, AUDIO_RECORDING, TAG } from "@baw-api/ServiceTokens";
+import {
+ AUDIO_EVENT_PROVENANCE,
+ AUDIO_RECORDING,
+ TAG,
+} from "@baw-api/ServiceTokens";
import { annotationMenuItem } from "@components/library/library.menus";
import { listenRecordingMenuItem } from "@components/listen/listen.menus";
import {
@@ -73,7 +77,10 @@ export class AudioEvent
public deleter?: User;
@hasOne(AUDIO_RECORDING, "audioRecordingId")
public audioRecording?: AudioRecording;
- @hasOne(AUDIO_EVENT_PROVENANCE, "provenanceId")
+ @hasOne(
+ AUDIO_EVENT_PROVENANCE,
+ "provenanceId"
+ )
public provenance?: AudioEventProvenance;
@hasMany(TAG, "tagIds")
public tags?: Tag[];
diff --git a/src/app/models/AudioEventImport.ts b/src/app/models/AudioEventImport.ts
new file mode 100644
index 000000000..c9f25d0ad
--- /dev/null
+++ b/src/app/models/AudioEventImport.ts
@@ -0,0 +1,70 @@
+import { DateTimeTimezone, Description, Id } from "@interfaces/apiInterfaces";
+import { USER } from "@baw-api/ServiceTokens";
+import { annotationImportRoute } from "@components/import-annotations/import-annotations.routes";
+import { AbstractModel } from "./AbstractModel";
+import { bawDateTime, bawPersistAttr, bawSubModelCollection } from "./AttributeDecorators";
+import { hasOne } from "./AssociationDecorators";
+import { User } from "./User";
+import { AudioEventImportFileRead, IAudioEventImportFileRead } from "./AudioEventImport/AudioEventImportFileRead";
+import { IImportedAudioEvent, ImportedAudioEvent } from "./AudioEventImport/ImportedAudioEvent";
+
+export interface IAudioEventImport {
+ id?: Id;
+ name?: string;
+ description?: Description;
+ descriptionHtml?: Description;
+ descriptionHtmlTagline?: Description;
+ createdAt?: DateTimeTimezone;
+ updatedAt?: DateTimeTimezone;
+ deletedAt?: DateTimeTimezone;
+ creatorId?: Id;
+ deleterId?: Id;
+ updaterId?: Id;
+ files?: IAudioEventImportFileRead[];
+ importedEvents?: IImportedAudioEvent[];
+}
+
+/**
+ * ! Due to planned api changes, this model is subject to change
+ * @see https://github.com/QutEcoacoustics/baw-server/issues/664
+ */
+export class AudioEventImport
+ extends AbstractModel
+ implements IAudioEventImport
+{
+ public readonly kind = "audio_event_import";
+ public readonly id?: Id;
+ @bawPersistAttr()
+ public readonly name?: string;
+ @bawPersistAttr()
+ public readonly description?: Description;
+ public readonly descriptionHtml?: Description;
+ public readonly descriptionHtmlTagline?: Description;
+ @bawDateTime()
+ public readonly createdAt?: DateTimeTimezone;
+ @bawDateTime()
+ public readonly updatedAt?: DateTimeTimezone;
+ @bawDateTime()
+ public readonly deletedAt?: DateTimeTimezone;
+ public readonly creatorId?: Id;
+ public readonly deleterId?: Id;
+ public readonly updaterId?: Id;
+ @bawSubModelCollection(AudioEventImportFileRead)
+ public readonly files?: AudioEventImportFileRead[];
+ @bawSubModelCollection(ImportedAudioEvent)
+ public readonly importedEvents?: ImportedAudioEvent[];
+
+ // Associations
+ @hasOne(USER, "creatorId")
+ public creator?: User;
+ @hasOne(USER, "deleterId")
+ public deleter?: User;
+ @hasOne(USER, "updaterId")
+ public updater?: User;
+
+ public get viewUrl(): string {
+ return annotationImportRoute.format({
+ annotationId: this.id,
+ });
+ }
+}
diff --git a/src/app/models/AudioEventImport/AudioEventImportFileRead.ts b/src/app/models/AudioEventImport/AudioEventImportFileRead.ts
new file mode 100644
index 000000000..5c0103b03
--- /dev/null
+++ b/src/app/models/AudioEventImport/AudioEventImportFileRead.ts
@@ -0,0 +1,36 @@
+import { TAG } from "@baw-api/ServiceTokens";
+import { CollectionIds, DateTimeTimezone } from "@interfaces/apiInterfaces";
+import { AbstractModelWithoutId } from "@models/AbstractModel";
+import { hasMany } from "@models/AssociationDecorators";
+import { bawCollection, bawDateTime } from "@models/AttributeDecorators";
+import { Tag } from "@models/Tag";
+
+export interface IAudioEventImportFileRead {
+ name: string;
+ importedAt: DateTimeTimezone;
+ additionalTags: CollectionIds;
+}
+
+/**
+ * ! This model is subject to change due to planned api changes
+ * @see https://github.com/QutEcoacoustics/baw-server/issues/664
+ */
+export class AudioEventImportFileRead
+ extends AbstractModelWithoutId
+ implements IAudioEventImportFileRead
+{
+ public readonly kind = "audio_event_import";
+ public readonly name: string;
+ @bawDateTime()
+ public readonly importedAt: DateTimeTimezone;
+ @bawCollection()
+ public readonly additionalTags: CollectionIds;
+
+ // associations
+ @hasMany(TAG, "additionalTags")
+ public additionalTagModels?: Tag[];
+
+ public get viewUrl(): string {
+ return "";
+ }
+}
diff --git a/src/app/models/AudioEventImport/AudioEventImportFileWrite.ts b/src/app/models/AudioEventImport/AudioEventImportFileWrite.ts
new file mode 100644
index 000000000..63594dc10
--- /dev/null
+++ b/src/app/models/AudioEventImport/AudioEventImportFileWrite.ts
@@ -0,0 +1,41 @@
+import { TAG } from "@baw-api/ServiceTokens";
+import { CollectionIds, Id } from "@interfaces/apiInterfaces";
+import { AbstractModel } from "@models/AbstractModel";
+import { hasMany } from "@models/AssociationDecorators";
+import { bawPersistAttr } from "@models/AttributeDecorators";
+import { Tag } from "@models/Tag";
+
+export interface IAudioEventImportFileWrite {
+ id: Id;
+ file: File;
+ additionalTagIds: CollectionIds;
+ commit: boolean;
+}
+
+/**
+ * A write only model used to add files to an audio event import
+ *
+ * ! This model is subject to change due to planned api changes
+ * @see https://github.com/QutEcoacoustics/baw-server/issues/664
+ */
+export class AudioEventImportFileWrite
+ extends AbstractModel
+ implements IAudioEventImportFileWrite
+{
+ public readonly kind = "import";
+ public readonly id: Id;
+ @bawPersistAttr({ supportedFormats: ["formData"] })
+ public readonly file: File;
+ @bawPersistAttr({ supportedFormats: ["formData"] })
+ public readonly additionalTagIds: CollectionIds;
+ @bawPersistAttr({ supportedFormats: ["formData"] })
+ public readonly commit: boolean;
+
+ // associations
+ @hasMany(TAG, "additionalTagIds")
+ public additionalTagModels?: Tag[];
+
+ public get viewUrl(): string {
+ return "";
+ }
+}
diff --git a/src/app/models/AudioEventImport/ImportedAudioEvent.ts b/src/app/models/AudioEventImport/ImportedAudioEvent.ts
new file mode 100644
index 000000000..898d90ab2
--- /dev/null
+++ b/src/app/models/AudioEventImport/ImportedAudioEvent.ts
@@ -0,0 +1,55 @@
+import { AUDIO_EVENT_IMPORT, AUDIO_RECORDING } from "@baw-api/ServiceTokens";
+import { Id } from "@interfaces/apiInterfaces";
+import { AbstractModel } from "@models/AbstractModel";
+import { hasOne } from "@models/AssociationDecorators";
+import { bawPersistAttr } from "@models/AttributeDecorators";
+import { IAudioEvent } from "@models/AudioEvent";
+import { AudioEventImport } from "@models/AudioEventImport";
+import { AudioRecording } from "@models/AudioRecording";
+import { ITag } from "@models/Tag";
+
+export interface AudioEventError {
+ [key: string]: string[];
+}
+
+export interface IImportedAudioEvent extends IAudioEvent {
+ errors?: AudioEventError[];
+ tags?: ITag[];
+}
+
+export class ImportedAudioEvent
+ extends AbstractModel
+ implements IImportedAudioEvent
+{
+ public readonly king = "Imported Audio Event";
+ @bawPersistAttr()
+ public audioRecordingId?: Id;
+ @bawPersistAttr()
+ public channel?: number;
+ @bawPersistAttr()
+ public startTimeSeconds?: number;
+ @bawPersistAttr()
+ public endTimeSeconds?: number;
+ @bawPersistAttr()
+ public lowFrequencyHertz?: number;
+ @bawPersistAttr()
+ public highFrequencyHertz?: number;
+ @bawPersistAttr()
+ public audioEventImportId?: Id;
+ @bawPersistAttr()
+ public isReference?: boolean;
+ public creatorId?: Id;
+ public context?: Record;
+ public errors?: AudioEventError[];
+ public tags?: ITag[];
+
+ // Associations
+ @hasOne(AUDIO_RECORDING, "audioRecordingId")
+ public audioRecording?: AudioRecording;
+ @hasOne(AUDIO_EVENT_IMPORT, "audioEventImportId")
+ public audioEventImport?: AudioEventImport;
+
+ public get viewUrl(): string {
+ throw new Error("Method not implemented.");
+ }
+}
diff --git a/src/app/models/EventSummaryReport.ts b/src/app/models/EventSummaryReport.ts
index cb2033642..5a94a072f 100644
--- a/src/app/models/EventSummaryReport.ts
+++ b/src/app/models/EventSummaryReport.ts
@@ -8,7 +8,6 @@ import {
CollectionIds,
DateTimeTimezone,
Id,
- Ids,
Param,
} from "@interfaces/apiInterfaces";
import { EventSummaryReportParameters } from "@components/reports/pages/event-summary/EventSummaryReportParameters";
@@ -30,10 +29,10 @@ export interface IEventSummaryReport {
generatedDate: DateTimeTimezone | string;
statistics: IAudioEventSummaryReportStatistics;
eventGroups: EventGroup[];
- siteIds: Id[] | Ids;
- regionIds: Id[] | Ids;
- tagIds: Id[] | Ids;
- provenanceIds: Id[] | Ids;
+ siteIds: CollectionIds;
+ regionIds: CollectionIds;
+ tagIds: CollectionIds;
+ provenanceIds: CollectionIds;
graphs: IEventSummaryGraphs;
}
diff --git a/src/app/pipes/date/date.pipe.spec.ts b/src/app/pipes/date/date.pipe.spec.ts
index e3c6d0a20..853821ff6 100644
--- a/src/app/pipes/date/date.pipe.spec.ts
+++ b/src/app/pipes/date/date.pipe.spec.ts
@@ -1,7 +1,7 @@
import { SpectatorPipe, createPipeFactory } from "@ngneat/spectator";
-import { DateTime } from "luxon";
+import { DateTime, Settings } from "luxon";
import { DatePipe } from "@angular/common"; // Import the DatePipe from Angular common module
-import { DateTimePipe } from "./date.pipe";
+import { DateTimePipe, DateTimePipeOptions } from "./date.pipe";
describe("DatePipe", () => {
let spectator: SpectatorPipe;
@@ -10,12 +10,19 @@ describe("DatePipe", () => {
providers: [DatePipe],
});
- function setup(value: DateTime) {
- spectator = createPipe("
{{ value | dateTime }}
", {
- hostProps: { value },
+ function setup(value: DateTime, options?: DateTimePipeOptions) {
+ spectator = createPipe("
{{ value | dateTime : options }}
", {
+ hostProps: { value, options },
});
}
+ beforeEach(() => {
+ // we do this so that the tests are not affected by the timezone of the user running the tests
+ // if this is not done correctly, the tests will fail in CI
+ const mockUserTimeZone = "Australia/Perth"; // +08:00 UTC
+ Settings.defaultZone = mockUserTimeZone;
+ });
+
it("should handle undefined value", () => {
setup(undefined);
expect(spectator.element).toHaveExactText("");
@@ -26,10 +33,33 @@ describe("DatePipe", () => {
expect(spectator.element).toHaveExactText("");
});
- it("should display date in the correct format", () => {
+ it("should display date in the correct format for utc dates", () => {
const date = DateTime.fromObject({ year: 2020, month: 1, day: 1 });
setup(date);
expect(spectator.element).toHaveExactText("2020-01-01");
});
+
+ it("should emit times if 'includeTime' is set", () => {
+ const expectedDateTime = "2020-01-01 18:43:11";
+ const date = DateTime.fromObject({
+ year: 2020,
+ month: 1,
+ day: 1,
+ hour: 18,
+ minute: 43,
+ second: 11,
+ });
+
+ setup(date, { includeTime: true });
+ expect(spectator.element).toHaveExactText(expectedDateTime);
+ });
+
+ it("should emit dateTime's in the correct format for local dates", () => {
+ const inputDateTime = DateTime.fromISO("2020-01-01T00:00:00.000Z");
+ const expectedDateTime = "2020-01-01 08:00:00";
+
+ setup(inputDateTime, { includeTime: true, localTime: true });
+ expect(spectator.element).toHaveExactText(expectedDateTime);
+ });
});
diff --git a/src/app/pipes/date/date.pipe.ts b/src/app/pipes/date/date.pipe.ts
index 3d971ca32..e802e3b03 100644
--- a/src/app/pipes/date/date.pipe.ts
+++ b/src/app/pipes/date/date.pipe.ts
@@ -2,6 +2,11 @@ import { Pipe, PipeTransform } from "@angular/core";
import { isInstantiated } from "@helpers/isInstantiated/isInstantiated";
import { DateTime } from "luxon";
+export interface DateTimePipeOptions {
+ includeTime?: boolean;
+ localTime?: boolean;
+}
+
// we use a custom pipe to format dates because the date pipe supported by Angular doesn't support Luxon dates
// this pipe is used to convert a Luxon DateTime object into a JavaScript Date object before passing it to the Angular date pipe
// this retains all the functionality and options of the Angular date pipe while allowing us to use Luxon dates without
@@ -13,7 +18,7 @@ import { DateTime } from "luxon";
export class DateTimePipe implements PipeTransform {
public constructor() {}
- public transform(value?: DateTime, includeTime = false, localTime = false): string {
+ public transform(value?: DateTime, options?: DateTimePipeOptions): string {
if (!isInstantiated(value)) {
return "";
}
@@ -21,8 +26,10 @@ export class DateTimePipe implements PipeTransform {
const dateFormat = "yyyy-MM-dd";
const dateTimeFormat = "yyyy-MM-dd HH:mm:ss";
- const localizedDate = localTime ? value.toLocal() : value;
+ const localizedDate = options?.localTime === true ? value.toLocal() : value;
- return localizedDate.toFormat(includeTime ? dateTimeFormat : dateFormat);
+ return localizedDate.toFormat(
+ options?.includeTime === true ? dateTimeFormat : dateFormat
+ );
}
}
diff --git a/src/app/services/baw-api/ServiceProviders.ts b/src/app/services/baw-api/ServiceProviders.ts
index 5a3cd3b1f..549427275 100644
--- a/src/app/services/baw-api/ServiceProviders.ts
+++ b/src/app/services/baw-api/ServiceProviders.ts
@@ -11,6 +11,7 @@ import {
analysisJobResolvers,
AnalysisJobsService,
} from "./analysis/analysis-jobs.service";
+import { AudioEventImportService, audioEventImportResolvers } from "./audio-event-import/audio-event-import.service";
import {
audioEventResolvers,
AudioEventsService,
@@ -279,6 +280,11 @@ const serviceList = [
service: EventSummaryReportService,
resolvers: eventSummaryResolvers,
},
+ {
+ serviceToken: Tokens.AUDIO_EVENT_IMPORT,
+ service: AudioEventImportService,
+ resolvers: audioEventImportResolvers,
+ },
];
const services = serviceList.map(({ service }) => service);
diff --git a/src/app/services/baw-api/ServiceTokens.ts b/src/app/services/baw-api/ServiceTokens.ts
index 2e92d7bad..c873df4ca 100644
--- a/src/app/services/baw-api/ServiceTokens.ts
+++ b/src/app/services/baw-api/ServiceTokens.ts
@@ -37,6 +37,7 @@ import type { TagGroup } from "@models/TagGroup";
import type { User } from "@models/User";
import { AudioEventProvenance } from "@models/AudioEventProvenance";
import { EventSummaryReport } from "@models/EventSummaryReport";
+import { AudioEventImport } from "@models/AudioEventImport";
import type { AccountsService } from "./account/accounts.service";
import type { AnalysisJobItemsService } from "./analysis/analysis-job-items.service";
import type { AnalysisJobsService } from "./analysis/analysis-jobs.service";
@@ -86,6 +87,7 @@ import type { UserService } from "./user/user.service";
import type { AnalysisJobItemResultsService } from "./analysis/analysis-job-item-result.service";
import { AudioEventProvenanceService } from "./AudioEventProvenance/AudioEventProvenance.service";
import { EventSummaryReportService } from "./reports/event-report/event-summary-report.service";
+import { AudioEventImportService } from "./audio-event-import/audio-event-import.service";
/**
* Wrapper for InjectionToken class. This is required because of
@@ -205,3 +207,4 @@ export const TAGGING = new ServiceToken("TAGGING");
export const USER = new ServiceToken("USER");
export const AUDIO_EVENT_PROVENANCE = new ServiceToken("AUDIO_EVENT_PROVENANCE");
export const AUDIO_EVENT_SUMMARY_REPORT = new ServiceToken("AUDIO_EVENT_SUMMARY_REPORT");
+export const AUDIO_EVENT_IMPORT = new ServiceToken("AUDIO_EVENT_IMPORT");
diff --git a/src/app/services/baw-api/audio-event-import/audio-event-import.service.spec.ts b/src/app/services/baw-api/audio-event-import/audio-event-import.service.spec.ts
new file mode 100644
index 000000000..8ad1dfb53
--- /dev/null
+++ b/src/app/services/baw-api/audio-event-import/audio-event-import.service.spec.ts
@@ -0,0 +1 @@
+//TODO
diff --git a/src/app/services/baw-api/audio-event-import/audio-event-import.service.ts b/src/app/services/baw-api/audio-event-import/audio-event-import.service.ts
new file mode 100644
index 000000000..03b97cfac
--- /dev/null
+++ b/src/app/services/baw-api/audio-event-import/audio-event-import.service.ts
@@ -0,0 +1,85 @@
+import { Injectable } from "@angular/core";
+import {
+ IdOr,
+ IdParamOptional,
+ StandardApi,
+ emptyParam,
+ filterParam,
+ id,
+ option,
+} from "@baw-api/api-common";
+import { BawApiService, Filters } from "@baw-api/baw-api.service";
+import { Resolvers } from "@baw-api/resolver-common";
+import { stringTemplate } from "@helpers/stringTemplate/stringTemplate";
+import { AudioEventImport } from "@models/AudioEventImport";
+import { AudioEventImportFileWrite } from "@models/AudioEventImport/AudioEventImportFileWrite";
+import { Observable } from "rxjs";
+
+const eventImportId: IdParamOptional = id;
+const endpoint = stringTemplate`/audio_event_imports/${eventImportId}${option}`;
+
+/**
+ * Audio event import service.
+ * Handles API routes pertaining to audio event imports (e.g. annotation imports).
+ *
+ * ! This service is subject to change due to forecasted breaking api changes.
+ * @see https://github.com/QutEcoacoustics/baw-server/issues/664
+ */
+@Injectable()
+export class AudioEventImportService implements StandardApi {
+ public constructor(private api: BawApiService) {}
+
+ public list(): Observable {
+ return this.api.list(AudioEventImport, endpoint(emptyParam, emptyParam));
+ }
+
+ public filter(
+ filters: Filters
+ ): Observable {
+ return this.api.filter(
+ AudioEventImport,
+ endpoint(emptyParam, filterParam),
+ filters
+ );
+ }
+
+ public show(model: IdOr): Observable {
+ return this.api.show(AudioEventImport, endpoint(model, emptyParam));
+ }
+
+ public create(model: AudioEventImport): Observable {
+ return this.api.create(
+ AudioEventImport,
+ endpoint(emptyParam, emptyParam),
+ (event) => endpoint(event, emptyParam),
+ model
+ );
+ }
+
+ public update(model: AudioEventImport): Observable {
+ return this.api.update(
+ AudioEventImport,
+ endpoint(model, emptyParam),
+ model
+ );
+ }
+
+ public importFile(model: AudioEventImportFileWrite): Observable {
+ return this.api.update(
+ AudioEventImport,
+ endpoint(model.id, emptyParam),
+ model
+ );
+ }
+
+ public destroy(
+ model: IdOr
+ ): Observable {
+ return this.api.destroy(endpoint(model, emptyParam));
+ }
+}
+
+export const audioEventImportResolvers = new Resolvers(
+ [AudioEventImportService],
+ "annotationId"
+).create("AudioEventImport");
diff --git a/src/app/services/baw-api/baw-api.service.ts b/src/app/services/baw-api/baw-api.service.ts
index 2f1c67f20..ae500508c 100644
--- a/src/app/services/baw-api/baw-api.service.ts
+++ b/src/app/services/baw-api/baw-api.service.ts
@@ -18,8 +18,8 @@ import { withCacheLogging } from "@services/cache/cache-logging.service";
import { CacheSettings, CACHE_SETTINGS } from "@services/cache/cache-settings";
import { API_ROOT } from "@services/config/config.tokens";
import { ToastrService } from "ngx-toastr";
-import { Observable, throwError } from "rxjs";
-import { catchError, map, mergeMap, switchMap, tap } from "rxjs/operators";
+import { Observable, iif, of, throwError } from "rxjs";
+import { catchError, concatMap, map, switchMap, tap } from "rxjs/operators";
import { IS_SERVER_PLATFORM } from "src/app/app.helper";
import { BawSessionService } from "./baw-session.service";
@@ -258,26 +258,40 @@ export class BawApiService<
model: AbstractModel,
opts?: NotificationsOpt
): Observable {
- const jsonData = model?.getJsonAttributes?.({ create: true });
+ const jsonData = model.getJsonAttributes?.({ create: true });
+ const formData = model.getFormDataOnlyAttributes({ create: true });
const body = model.kind
? { [model.kind]: jsonData ?? model }
: jsonData ?? model;
- const request = this.httpPost(createPath, body).pipe(
- map(this.handleSingleResponse(classBuilder))
- );
-
- if (model?.hasFormDataOnlyAttributes({ create: true })) {
- const formData = model.getFormDataOnlyAttributes({ create: true });
- return request.pipe(
- mergeMap((data) =>
- this.httpPut(updatePath(data), formData, multiPartApiHeaders)
- ),
- map(this.handleSingleResponse(classBuilder))
- );
- }
-
- return request.pipe(
+ // as part of the multi part request, if there is only a JSON body, we want to return the output of the JSON POST request
+ // if there is only a formData body, we want to return the output of the formData PUT request
+ // if there is both a JSON body and formData, we want to return the output of the last request sent (formData PUT request)
+ // we default to returning null if there is no JSON or formData body
+ return of(null).pipe(
+ concatMap(
+ model.hasJsonOnlyAttributes({ create: true })
+ ? () => this.httpPost(createPath, body).pipe()
+ : (data) => of(data)
+ ),
+ // we create a class from the POST response so that we can construct an update route for the formData PUT request
+ // using the updatePath callback. We do this before the concatMap below because the updatePath callback is dependent
+ // on the instantiated class from the POST response object
+ map(this.handleSingleResponse(classBuilder)),
+ // using concatMap here ensures that the httpPost request completes before the httpPut request is made
+ concatMap((data) =>
+ // we use an if statement here because we want to create a new observable and apply a map function to it
+ // using ternary logic here (similar to the update function) would result in poor readability and a lot of nesting
+ iif(
+ () => model.hasFormDataOnlyAttributes({ create: true }),
+ this.httpPut(updatePath(data), formData, multiPartApiHeaders).pipe(
+ map(this.handleSingleResponse(classBuilder))
+ ),
+ of(data)
+ )
+ ),
+ // there is no map function here, because the handleSingleResponse method is invoked on the POST and PUT requests
+ // individually. Moving the handleSingleResponse mapping here would result in the response object being instantiated twice
catchError((err) => this.handleError(err, opts?.disableNotification))
);
}
@@ -297,23 +311,30 @@ export class BawApiService<
opts?: NotificationsOpt
): Observable {
const jsonData = model.getJsonAttributes?.({ update: true });
+ const formData = model.getFormDataOnlyAttributes({ update: true });
const body = model.kind
? { [model.kind]: jsonData ?? model }
: jsonData ?? model;
- const request = this.httpPatch(path, body).pipe(
- map(this.handleSingleResponse(classBuilder))
- );
-
- if (model?.hasFormDataOnlyAttributes({ update: true })) {
- const formData = model.getFormDataOnlyAttributes({ update: true });
- return request.pipe(
- mergeMap(() => this.httpPut(path, formData, multiPartApiHeaders)),
- map(this.handleSingleResponse(classBuilder))
- );
- }
-
- return request.pipe(
+ // as part of the multi part request, if there is only a JSON body, we want to return the output of the JSON PATCH request
+ // if there is only a formData body, we want to return the output of the formData PUT request
+ // if there is both a JSON body and formData, we want to return the output of the last request sent (formData PUT request)
+ // we default to returning null if there is no JSON or formData body
+ return of(null).pipe(
+ concatMap(
+ // we use (data) => of(data) here instead of the identity function because the identify function
+ // returns a value, and not an observable. Because we use concatMap below, we need the existing
+ // value to be emitted as an observable instead. Therefore, we create a static observable using of()
+ model.hasJsonOnlyAttributes({ update: true })
+ ? () => this.httpPatch(path, body)
+ : (data) => of(data)
+ ),
+ concatMap(
+ model.hasFormDataOnlyAttributes({ update: true })
+ ? () => this.httpPut(path, formData, multiPartApiHeaders)
+ : (data) => of(data)
+ ),
+ map(this.handleSingleResponse(classBuilder)),
catchError((err) => this.handleError(err, opts?.disableNotification))
);
}
diff --git a/src/app/styles/_forms.scss b/src/app/styles/_forms.scss
new file mode 100644
index 000000000..2dd012747
--- /dev/null
+++ b/src/app/styles/_forms.scss
@@ -0,0 +1,4 @@
+// this mimics the required star used on ng-formly forms
+.required::after {
+ content: "*";
+}
diff --git a/src/app/test/fakes/AudioEventImport.ts b/src/app/test/fakes/AudioEventImport.ts
new file mode 100644
index 000000000..c418f5ce3
--- /dev/null
+++ b/src/app/test/fakes/AudioEventImport.ts
@@ -0,0 +1,49 @@
+import { IAudioEventImport } from "@models/AudioEventImport";
+import { IAudioEventImportFileRead } from "@models/AudioEventImport/AudioEventImportFileRead";
+import { modelData } from "@test/helpers/faker";
+
+export function generateAudioEventImport(
+ data?: Partial
+): Required {
+ return {
+ id: modelData.id(),
+ name: modelData.param(),
+ description: modelData.description(),
+ descriptionHtml: modelData.description(),
+ descriptionHtmlTagline: modelData.description(),
+ createdAt: modelData.dateTime(),
+ updatedAt: modelData.dateTime(),
+ deletedAt: modelData.dateTime(),
+ creatorId: modelData.id(),
+ deleterId: modelData.id(),
+ updaterId: modelData.id(),
+ files: modelData.randomArray(10, 20, () =>
+ Object({
+ name: modelData.param(),
+ importedAt: modelData.dateTime(),
+ additionalTags: modelData.ids(),
+ }),
+ ),
+ importedEvents: modelData.randomArray(10, 20, () =>
+ Object({
+ id: modelData.id(),
+ audioRecordingId: modelData.id(),
+ startTimeSeconds: modelData.datatype.number(),
+ endTimeSeconds: modelData.datatype.number(),
+ lowFrequencyHertz: modelData.datatype.number(),
+ highFrequencyHertz: modelData.datatype.number(),
+ isReference: modelData.datatype.boolean(),
+ taggings: modelData.randomArray(10, 20, () =>
+ Object({
+ tagId: modelData.id(),
+ startTimeSeconds: modelData.datatype.number(),
+ endTimeSeconds: modelData.datatype.number(),
+ }),
+ ),
+ provenanceId: modelData.id(),
+ errors: [],
+ })
+ ),
+ ...data,
+ };
+}
diff --git a/src/styles.scss b/src/styles.scss
index 7a99ad440..16d02dca0 100644
--- a/src/styles.scss
+++ b/src/styles.scss
@@ -1,6 +1,7 @@
@import "bootstrap/index";
@import "ngx-datatable";
@import "images";
+@import "forms";
@import "functions";
@import "callout";
@import "@fortawesome/fontawesome-svg-core/styles.css";