From 56dd61be305bd5e2f1bee3d46f480b6c6b3b0853 Mon Sep 17 00:00:00 2001 From: hudson-newey Date: Tue, 7 Nov 2023 13:14:55 +1000 Subject: [PATCH] Added page to admin dashboard to view all uploads fixes: #2051 --- src/app/components/admin/admin.menus.ts | 10 ++ src/app/components/admin/admin.module.ts | 2 + .../all-uploads/all-uploads.component.spec.ts | 112 ++++++++++++++++++ .../all-uploads/all-uploads.component.ts | 34 ++++++ .../admin/dashboard/dashboard.component.ts | 2 + src/app/components/harvest/harvest.module.ts | 4 - .../harvest/pages/list/list.component.html | 44 ++++++- .../harvest/pages/list/list.component.spec.ts | 64 +++++++++- .../harvest/pages/list/list.component.ts | 34 +++--- .../components/shared/shared.components.ts | 4 + src/app/pipes/date/date.pipe.ts | 15 +-- 11 files changed, 289 insertions(+), 36 deletions(-) create mode 100644 src/app/components/admin/all-uploads/all-uploads.component.spec.ts create mode 100644 src/app/components/admin/all-uploads/all-uploads.component.ts diff --git a/src/app/components/admin/admin.menus.ts b/src/app/components/admin/admin.menus.ts index ec28c6fa6..eb7a8bcfd 100644 --- a/src/app/components/admin/admin.menus.ts +++ b/src/app/components/admin/admin.menus.ts @@ -59,4 +59,14 @@ export const adminThemeMenuItem = menuRoute({ route: adminRoute.add("theme"), tooltip: () => "View and experiment with website theme", parent: adminDashboardMenuItem, + predicate: isAdminPredicate, +}); + +export const adminUploadsMenuItem = menuRoute({ + icon: ["fas", "cloud"], + label: "All Recording Uploads", + route: adminRoute.add("all_uploads"), + tooltip: () => "View all uploads", + parent: adminDashboardMenuItem, + predicate: isAdminPredicate, }); diff --git a/src/app/components/admin/admin.module.ts b/src/app/components/admin/admin.module.ts index 09a004a3f..accc07bd4 100644 --- a/src/app/components/admin/admin.module.ts +++ b/src/app/components/admin/admin.module.ts @@ -12,6 +12,7 @@ import { TagGroupsModule } from "./tag-group/tag-groups.module"; import { TagsModule } from "./tags/tags.module"; import { AdminThemeTemplateComponent } from "./theme-template/theme-template.component"; import { AdminUserListComponent } from "./users/user.component"; +import { AllUploadsComponent } from "./all-uploads/all-uploads.component"; const modules = [ AnalysisJobsModule, @@ -25,6 +26,7 @@ const components = [ AdminDashboardComponent, AdminUserListComponent, AdminThemeTemplateComponent, + AllUploadsComponent, ]; const routes = adminRoute.compileRoutes(getRouteConfigForPage); diff --git a/src/app/components/admin/all-uploads/all-uploads.component.spec.ts b/src/app/components/admin/all-uploads/all-uploads.component.spec.ts new file mode 100644 index 000000000..025d41dc6 --- /dev/null +++ b/src/app/components/admin/all-uploads/all-uploads.component.spec.ts @@ -0,0 +1,112 @@ +import { assertPageInfo } from "@test/helpers/pageRoute"; +import { Spectator, SpyObject, createRoutingFactory } from "@ngneat/spectator"; +import { MockBawApiModule } from "@baw-api/baw-apiMock.module"; +import { SharedModule } from "@shared/shared.module"; +import { ToastrService } from "ngx-toastr"; +import { ConfirmationComponent } from "@components/harvest/components/modal/confirmation.component"; +import { LoadingComponent } from "@shared/loading/loading.component"; +import { UserLinkComponent } from "@shared/user-link/user-link/user-link.component"; +import { Injector } from "@angular/core"; +import { SHALLOW_HARVEST } from "@baw-api/ServiceTokens"; +import { Harvest } from "@models/Harvest"; +import { Project } from "@models/Project"; +import { generateProject } from "@test/fakes/Project"; +import { of } from "rxjs"; +import { User } from "@models/User"; +import { generateUser } from "@test/fakes/User"; +import { ShallowHarvestsService } from "@baw-api/harvest/harvest.service"; +import { generateHarvest } from "@test/fakes/Harvest"; +import { AllUploadsComponent } from "./all-uploads.component"; + +// the functionality that the project names are shown in the harvest list +// and the correct api calls are made is asserted in the harvest list component +// these tests assert that the harvest list component is extended correctly +describe("AllUploadsComponent", () => { + let spectator: Spectator; + let fakeHarvest: Harvest; + let fakeHarvestApi: SpyObject; + + const createComponent = createRoutingFactory({ + declarations: [LoadingComponent, ConfirmationComponent, UserLinkComponent], + component: AllUploadsComponent, + imports: [MockBawApiModule, SharedModule], + mocks: [ToastrService], + }); + + function setup(): void { + fakeHarvest = new Harvest(generateHarvest({ status: "uploading" })); + + spectator = createComponent({ detectChanges: false }); + + const injector = spectator.inject(Injector); + fakeHarvest["injector"] = injector; + + fakeHarvestApi = spectator.inject(SHALLOW_HARVEST.token); + fakeHarvest.addMetadata({ + paging: { items: 1, page: 0, total: 1, maxPage: 5 }, + }); + + // since the harvest creator is a resolved model, we need to mock the creator property + const fakeUser: User = new User(generateUser()); + spyOnProperty(fakeHarvest, "creator").and.callFake(() => fakeUser); + + const mockHarvestProject: Project = new Project(generateProject()); + spyOnProperty(fakeHarvest, "project").and.callFake( + () => mockHarvestProject + ); + + // mock the harvest service filter API to populate the + // list component ngx-datatable + const mockResponse = of([fakeHarvest]); + fakeHarvestApi.filter.and.callFake(() => mockResponse); + fakeHarvestApi.transitionStatus.and.callFake(() => of(fakeHarvest)); + + spectator.detectChanges(); + } + + beforeEach(() => setup()); + + assertPageInfo(AllUploadsComponent, ["Recording Uploads", "All Recording Uploads"]); + + it("should create", () => { + expect(spectator.component).toBeInstanceOf(AllUploadsComponent); + }); + + it("should return 'null' for the project", () => { + expect(spectator.component.project).toBeNull(); + }); + + it("should have the harvest list table", () => { + const datatableElement: HTMLElement = + spectator.query("ngx-datatable"); + expect(datatableElement).toExist(); + }); + + it("should make the correct api calls", () => { + expect(fakeHarvestApi.transitionStatus).not.toHaveBeenCalled(); + + // test that the filter object that the filter service was called with did not contain a filter key + expect(fakeHarvestApi.filter).not.toHaveBeenCalledWith( + jasmine.objectContaining({ + filter: jasmine.any(Object), + }) + ); + + // assert that projection conditions were still applied to the request body + expect(fakeHarvestApi.filter).toHaveBeenCalledWith( + jasmine.objectContaining({ + projection: { + include: [ + "id", + "projectId", + "name", + "createdAt", + "creatorId", + "streaming", + "status", + ], + }, + }), + ); + }); +}); diff --git a/src/app/components/admin/all-uploads/all-uploads.component.ts b/src/app/components/admin/all-uploads/all-uploads.component.ts new file mode 100644 index 000000000..26820cc1c --- /dev/null +++ b/src/app/components/admin/all-uploads/all-uploads.component.ts @@ -0,0 +1,34 @@ +import { Component } from "@angular/core"; +import { List } from "immutable"; +import { ListComponent } from "@components/harvest/pages/list/list.component"; +import { ActivatedRoute } from "@angular/router"; +import { ShallowHarvestsService } from "@baw-api/harvest/harvest.service"; +import { NgbModal } from "@ng-bootstrap/ng-bootstrap"; +import { adminCategory, adminUploadsMenuItem } from "../admin.menus"; +import { adminMenuItemActions } from "../dashboard/dashboard.component"; + +@Component({ + selector: "baw-all-uploads", + templateUrl: "../../harvest/pages/list/list.component.html", +}) +class AllUploadsComponent extends ListComponent { + public constructor( + private modal: NgbModal, + private api: ShallowHarvestsService, + private activatedRoute: ActivatedRoute + ) { + super(modal, api, activatedRoute); + } + + public override get project() { + return null; + } +} + +AllUploadsComponent.linkToRoute({ + category: adminCategory, + pageRoute: adminUploadsMenuItem, + menus: { actions: List(adminMenuItemActions) }, +}); + +export { AllUploadsComponent }; diff --git a/src/app/components/admin/dashboard/dashboard.component.ts b/src/app/components/admin/dashboard/dashboard.component.ts index d200f7a45..99db04b88 100644 --- a/src/app/components/admin/dashboard/dashboard.component.ts +++ b/src/app/components/admin/dashboard/dashboard.component.ts @@ -8,6 +8,7 @@ import { adminDashboardMenuItem, adminJobStatusMenuItem, adminThemeMenuItem, + adminUploadsMenuItem, adminUserListMenuItem, } from "../admin.menus"; import { adminOrphansMenuItem } from "../orphan/orphans.menus"; @@ -27,6 +28,7 @@ export const adminMenuItemActions = [ adminTagsMenuItem, adminThemeMenuItem, adminUserListMenuItem, + adminUploadsMenuItem, ]; @Component({ diff --git a/src/app/components/harvest/harvest.module.ts b/src/app/components/harvest/harvest.module.ts index ce2c3452c..e95d11cd5 100644 --- a/src/app/components/harvest/harvest.module.ts +++ b/src/app/components/harvest/harvest.module.ts @@ -8,7 +8,6 @@ import { FileRowComponent } from "./components/metadata-review/file-row.componen import { FolderRowComponent } from "./components/metadata-review/folder-row.component"; import { LoadMoreComponent } from "./components/metadata-review/load-more.component"; import { WhitespaceComponent } from "./components/metadata-review/whitespace.component"; -import { ConfirmationComponent } from "./components/modal/confirmation.component"; import { CanCloseDialogComponent } from "./components/shared/can-close-dialog.component"; import { EtaComponent } from "./components/shared/eta.component"; import { StatisticGroupComponent } from "./components/shared/statistics/group.component"; @@ -50,9 +49,6 @@ const internalComponents = [ TitleComponent, UploadUrlComponent, - // Modals - ConfirmationComponent, - // Widgets ValidationsWidgetComponent, diff --git a/src/app/components/harvest/pages/list/list.component.html b/src/app/components/harvest/pages/list/list.component.html index c272e9605..672e463e9 100644 --- a/src/app/components/harvest/pages/list/list.component.html +++ b/src/app/components/harvest/pages/list/list.component.html @@ -1,6 +1,18 @@ -

Upload History

+

+ + All Uploads + -

+ + Upload History + +

+ + +

This project does not allow uploading audio, {{ contactUs.label }} to request permission to upload audio. @@ -37,6 +49,28 @@

Upload History

+ + + + Project + + + + + + + + + + {{ value.project.name }} + + + + + Upload Type @@ -52,18 +86,18 @@

Upload History

- + Action - + {{ asHarvest(row).status !== "complete" ? "Continue" : "View" }} Abort diff --git a/src/app/components/harvest/pages/list/list.component.spec.ts b/src/app/components/harvest/pages/list/list.component.spec.ts index e9236d442..19e56fd4b 100644 --- a/src/app/components/harvest/pages/list/list.component.spec.ts +++ b/src/app/components/harvest/pages/list/list.component.spec.ts @@ -6,7 +6,7 @@ import { tick, } from "@angular/core/testing"; import { MockBawApiModule } from "@baw-api/baw-apiMock.module"; -import { HARVEST } from "@baw-api/ServiceTokens"; +import { SHALLOW_HARVEST } from "@baw-api/ServiceTokens"; import { ConfirmationComponent } from "@components/harvest/components/modal/confirmation.component"; import { Harvest } from "@models/Harvest"; import { Project } from "@models/Project"; @@ -39,7 +39,7 @@ describe("ListComponent", () => { mocks: [ToastrService], }); - function setup(project: Project, mockHarvest: Harvest) { + function setup(project: Project | null, mockHarvest: Harvest) { spec = createComponent({ detectChanges: false, data: { @@ -48,10 +48,16 @@ describe("ListComponent", () => { }); const injector = spec.inject(Injector); - project["injector"] = injector; + + if (project) { + project["injector"] = injector; + } + mockHarvest["injector"] = injector; - const mockHarvestApi = spec.inject(HARVEST.token); + spyOnProperty(spec.component, "project").and.callFake(() => project); + + const mockHarvestApi = spec.inject(SHALLOW_HARVEST.token); mockHarvest.addMetadata({ paging: { items: 1, page: 0, total: 1, maxPage: 5 }, }); @@ -59,6 +65,9 @@ describe("ListComponent", () => { // since the harvest creator is a resolved model, we need to mock the creator property spyOnProperty(mockHarvest, "creator").and.callFake(() => defaultUser); + const mockHarvestProject: Project = project ? project : new Project(generateProject()); + spyOnProperty(mockHarvest, "project").and.callFake(() => mockHarvestProject); + // inject the NgbModal service so that we can // dismiss all modals at the end of every test modalService = spec.inject(NgbModal); @@ -166,7 +175,10 @@ describe("ListComponent", () => { getModalNextButton().click(); tick(); - expect(harvestApi.transitionStatus).toHaveBeenCalledWith(defaultHarvest, "complete"); + expect(harvestApi.transitionStatus).toHaveBeenCalledWith( + defaultHarvest, + "complete" + ); discardPeriodicTasks(); flush(); })); @@ -229,7 +241,6 @@ describe("ListComponent", () => { ], }, }), - defaultProject ); }); @@ -239,4 +250,45 @@ describe("ListComponent", () => { const harvestApi = setup(defaultProject, defaultHarvest); expect(harvestApi.filter).toHaveBeenCalledTimes(1); }); + + it("should make the correct api calls for a harvest list scoped to a project", () => { + const harvestApi = setup(defaultProject, defaultHarvest); + expect(harvestApi.filter).toHaveBeenCalledWith( + jasmine.objectContaining({ + filter: { + projectId: { + eq: defaultProject.id, + }, + }, + }), + ); + }); + + it("should make the correct api calls for an unscoped harvest list", () => { + // to unscope the harvest list, we return `null` from the project getter + const harvestApi = setup(null, defaultHarvest); + expect(harvestApi.filter).not.toHaveBeenCalledWith( + jasmine.objectContaining({ + filter: jasmine.any(Object), + }) + ); + }); + + it("should not display the harvest project name in the project column if the harvest list is scoped to a project", () => { + setup(defaultProject, defaultHarvest); + const projectColumnHeader = + getElementByInnerText("Project"); + expect(projectColumnHeader).not.toExist(); + }); + + it("should display the harvest project name in the project column if the harvest list is not scoped to a project", () => { + setup(null, defaultHarvest); + + const expectedProject: Project = defaultHarvest.project; + const expectedProjectName: string = expectedProject.name; + + const projectNameColumnValue: HTMLTableCellElement = getElementByInnerText(expectedProjectName); + + expect(projectNameColumnValue).toExist(); + }); }); diff --git a/src/app/components/harvest/pages/list/list.component.ts b/src/app/components/harvest/pages/list/list.component.ts index d5d9fd2b9..91c826360 100644 --- a/src/app/components/harvest/pages/list/list.component.ts +++ b/src/app/components/harvest/pages/list/list.component.ts @@ -1,7 +1,7 @@ import { Component, OnInit } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; import { Filters } from "@baw-api/baw-api.service"; -import { HarvestsService } from "@baw-api/harvest/harvest.service"; +import { ShallowHarvestsService } from "@baw-api/harvest/harvest.service"; import { projectResolvers } from "@baw-api/project/projects.service"; import { contactUsMenuItem } from "@components/about/about.menus"; import { @@ -16,12 +16,7 @@ import { List } from "immutable"; import { DateTime } from "luxon"; import { NgbModal } from "@ng-bootstrap/ng-bootstrap"; import { BawApiError } from "@helpers/custom-errors/baw-api-error"; -import { - BehaviorSubject, - catchError, - takeUntil, - throwError -} from "rxjs"; +import { BehaviorSubject, catchError, takeUntil, throwError } from "rxjs"; import { CLIENT_TIMEOUT } from "@baw-api/api.interceptor.service"; export const harvestsMenuItemActions = [newHarvestMenuItem]; @@ -33,21 +28,31 @@ const projectKey = "project"; }) class ListComponent extends PageComponent implements OnInit { public contactUs = contactUsMenuItem; - public project: Project; public filters$: BehaviorSubject>; public canCreateHarvestCapability: boolean; public constructor( public modals: NgbModal, - private harvestsApi: HarvestsService, + private harvestsApi: ShallowHarvestsService, private route: ActivatedRoute ) { super(); } + // this is in a getter so that we can override it in the AllUploadsComponent + public get project(): Project { + return this.route.snapshot.data[projectKey].model; + } + public ngOnInit(): void { - this.project = this.route.snapshot.data[projectKey].model; - this.canCreateHarvestCapability = this.project.can("createHarvest").can; + this.canCreateHarvestCapability = this.project?.can("createHarvest")?.can; + + // we cannot use the project.id directly in the filter because + // if the project is null, then the filter will be { projectId: null } + // this will cause nothing to return because every harvest should have a projectId + const filterByProject: Filters = { filter: { projectId: { eq: this.project?.id } } }; + const projectScopeFilter: Filters = this.project ? filterByProject : {}; + // A BehaviorSubject is need on filters$ to update the ngx-datatable harvest list & models // The this.filters$ is triggered in abortUpload() this.filters$ = new BehaviorSubject({ @@ -66,8 +71,9 @@ class ListComponent extends PageComponent implements OnInit { "creatorId", "streaming", "status", - ] - } + ], + }, + ...projectScopeFilter, }); } @@ -98,7 +104,7 @@ class ListComponent extends PageComponent implements OnInit { } public getModels = (filters: Filters) => - this.harvestsApi.filter(filters, this.project); + this.harvestsApi.filter(filters); public asHarvest(model: any): Harvest { return model; diff --git a/src/app/components/shared/shared.components.ts b/src/app/components/shared/shared.components.ts index 0f0f7e96e..b1473f773 100644 --- a/src/app/components/shared/shared.components.ts +++ b/src/app/components/shared/shared.components.ts @@ -21,6 +21,7 @@ import { DateValueAccessorModule } from "angular-date-value-accessor"; import { NgxCaptchaModule } from "ngx-captcha"; import { ToastrModule } from "ngx-toastr"; import { DirectivesModule } from "src/app/directives/directives.module"; +import { ConfirmationComponent } from "@components/harvest/components/modal/confirmation.component"; import { AnnotationDownloadComponent } from "./annotation-download/annotation-download.component"; import { BawClientModule } from "./baw-client/baw-client.module"; import { BreadcrumbModule } from "./breadcrumb/breadcrumb.module"; @@ -65,6 +66,9 @@ export const sharedComponents = [ TypeaheadInputComponent, ChartComponent, InlineListComponent, + + // modals + ConfirmationComponent, ]; export const internalComponents = []; diff --git a/src/app/pipes/date/date.pipe.ts b/src/app/pipes/date/date.pipe.ts index e38e09f42..3d971ca32 100644 --- a/src/app/pipes/date/date.pipe.ts +++ b/src/app/pipes/date/date.pipe.ts @@ -1,4 +1,3 @@ -import { DatePipe } from "@angular/common"; import { Pipe, PipeTransform } from "@angular/core"; import { isInstantiated } from "@helpers/isInstantiated/isInstantiated"; import { DateTime } from "luxon"; @@ -9,19 +8,21 @@ import { DateTime } from "luxon"; // having to cast to a JavaScript Date object in each component // using this pipe also allows us to standardise the date format throughout the client @Pipe({ - name: "dateTime" + name: "dateTime", }) export class DateTimePipe implements PipeTransform { - public constructor( - private angularDatePipe: DatePipe - ) {} + public constructor() {} - public transform(value?: DateTime): string { + public transform(value?: DateTime, includeTime = false, localTime = false): string { if (!isInstantiated(value)) { return ""; } const dateFormat = "yyyy-MM-dd"; - return this.angularDatePipe.transform(value.toJSDate(), dateFormat); + const dateTimeFormat = "yyyy-MM-dd HH:mm:ss"; + + const localizedDate = localTime ? value.toLocal() : value; + + return localizedDate.toFormat(includeTime ? dateTimeFormat : dateFormat); } }