diff --git a/.eslintrc.json b/.eslintrc.json index 1ca22d8cf..a6207db92 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -38,6 +38,12 @@ "@typescript-eslint/no-inferrable-types": "off", "@typescript-eslint/prefer-as-const": "off", // beginning of explicitly enabled rules + "max-len": [ + "error", + { + "code": 140 + } + ], "no-console": [ "error", { diff --git a/.vscode/settings.json b/.vscode/settings.json index 09091c2e9..7737e67e8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,7 +5,9 @@ "datatable", "luxon", "toastr", + "taggings", "typeahead", - "Ecoacoustics" + "Ecoacoustics", + "datatable" ] } diff --git a/src/app/app.menus.ts b/src/app/app.menus.ts index a49bf8cc8..ff846b5dd 100644 --- a/src/app/app.menus.ts +++ b/src/app/app.menus.ts @@ -70,4 +70,4 @@ export const isAdminPredicate = (user: User): boolean => * @param user User session data. This will be used to check if the user is an admin */ export const isWorkInProgressPredicate = (user: User): boolean => - environment.production && isAdminPredicate(user); + !environment.production || isAdminPredicate(user); diff --git a/src/app/app.module.ts b/src/app/app.module.ts index ac3da9216..13c50ee1a 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -22,6 +22,7 @@ import { formlyConfig } from "@shared/formly/custom-inputs.module"; import { ToastrModule } from "ngx-toastr"; import { environment } from "src/environments/environment"; import { TitleStrategy } from "@angular/router"; +import { AnnotationsImportModule } from "@components/import-annotations/import-annotations.module"; import { AppRoutingModule } from "./app-routing.module"; import { AppComponent, PageTitleStrategy } from "./app.component"; import { toastrRoot } from "./app.helper"; @@ -62,6 +63,7 @@ export const appImports = [ DataRequestModule, HarvestModule, ReportsModule, + AnnotationsImportModule, LibraryModule, ListenModule, MyAccountModule, diff --git a/src/app/components/harvest/screens/metadata-review/metadata-review.component.spec.ts b/src/app/components/harvest/screens/metadata-review/metadata-review.component.spec.ts index 14286342e..7e0e1f694 100644 --- a/src/app/components/harvest/screens/metadata-review/metadata-review.component.spec.ts +++ b/src/app/components/harvest/screens/metadata-review/metadata-review.component.spec.ts @@ -354,5 +354,4 @@ describe("MetadataReviewComponent", () => { flush(); discardPeriodicTasks(); })); - }); diff --git a/src/app/components/import-annotations/audio-event-import.schema.json b/src/app/components/import-annotations/audio-event-import.schema.json new file mode 100644 index 000000000..3a3822e17 --- /dev/null +++ b/src/app/components/import-annotations/audio-event-import.schema.json @@ -0,0 +1,24 @@ +{ + "fields": [ + { + "key": "name", + "type": "input", + "props": { + "type": "text", + "label": "Import Name", + "required": true, + "minLength": 2 + } + }, + { + "key": "description", + "type": "textarea", + "props": { + "label": "Description", + "required": false, + "rows": 8, + "description": "Description uses markdown formatting allowing you to apply some limited styling to the model. You can find a guide on the basics here: https://markdown-guide.readthedocs.io/en/latest/basics.html" + } + } + ] +} diff --git a/src/app/components/import-annotations/details/details.component.html b/src/app/components/import-annotations/details/details.component.html new file mode 100644 index 000000000..0dedbed24 --- /dev/null +++ b/src/app/components/import-annotations/details/details.component.html @@ -0,0 +1,236 @@ +

Import Annotations: {{ audioEventImport.name }}

+ +

+ +
+
+

Events

+ + + + Audio Recording + + + + + + + + {{ value.audioRecording.id }} + + + + + + + + Created At + + + + + {{ value | dateTime : { includeTime: true, localTime: true } }} + + + + + + + Associated Tags + + + + + + + + + + + + +
+ +
+

Files

+ + + + + + + + + + + + + + + + + +
File NameDate ImportedAdditional Tags
{{ file.name }} + + {{ file.importedAt | dateTime : { includeTime: true, localTime: true } }} + + + +
+
+
+ +
+

Add more annotations

+
+
+
+ + + +
+
+ + + + +
+
+ + +
+

Import Group {{ index + 1 }}

+ +
+
+
    +
  • + {{ error }} +
  • +
+
+
+ + + + +
+ +
+ +
+

Identified Events

+ +
+ + + + + + + + + + + + + + +
Audio RecordingTags
+ {{ audioEvent.audioRecordingId }} + + +
+
+
+ + +
+
+ + + + + + + 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 @@ +

Batch Annotation Import History

+ + + + + Name + + + {{ value }} + + + + + + Created at + + + + {{ value | dateTime : { includeTime: true, localTime: true } }} + + + + + + + Created By + + + + + + + + + Action + + + + View + + + + Delete + + + + + + + + + + + + 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. -

- -
-
- - -
- -
- We had trouble reading your file, the errors we found were: - -
    -
  • - {{ error }} -
  • -
-
- - -
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";