diff --git a/client/src/app/app.module.ts b/client/src/app/app.module.ts index 311f5f43..9d260f53 100644 --- a/client/src/app/app.module.ts +++ b/client/src/app/app.module.ts @@ -67,6 +67,7 @@ import {AuthService} from './shared/services/auth.service'; import {UuidComponent} from './shared/components/uuid/uuid.component'; import {AutofillProjectService} from './project-registration/services/autofill-project.service'; import {ProjectCacheService} from './project-registration/services/project-cache.service'; +import {MatMenuModule} from '@angular/material/menu'; const BROWSER_LOCALE = navigator.language; @@ -103,24 +104,25 @@ const BROWSER_LOCALE = navigator.language; UuidComponent ], imports: [ + AaiSecurity, BrowserModule, + BrowserAnimationsModule, + FlexLayoutModule, FormsModule, HttpClientModule, - RouterModule.forRoot(ROUTES), - SharedModule, - ReactiveFormsModule, - NoopAnimationsModule, - NgxDatatableModule, - FlexLayoutModule, - BrowserAnimationsModule, + MatDialogModule, MaterialModule, - AaiSecurity, + MatMenuModule, MetadataSchemaFormModule, - ProjectRegistrationModule, - TemplateQuestionnaireModule, - MatDialogModule, NgxGraphModule, - NgxChartsModule + NgxChartsModule, + NgxDatatableModule, + NoopAnimationsModule, + ProjectRegistrationModule, + ReactiveFormsModule, + RouterModule.forRoot(ROUTES), + SharedModule, + TemplateQuestionnaireModule ], providers: [ { diff --git a/client/src/app/http-interceptors/http-error-interceptor.ts b/client/src/app/http-interceptors/http-error-interceptor.ts index 0213241e..245770b0 100644 --- a/client/src/app/http-interceptors/http-error-interceptor.ts +++ b/client/src/app/http-interceptors/http-error-interceptor.ts @@ -13,6 +13,7 @@ import { Router} from '@angular/router'; /** Handle http error response in one place. */ @Injectable() export class HttpErrorInterceptor implements HttpInterceptor { + MAX_RETRIES = 5; constructor(private router: Router) {} @@ -21,7 +22,7 @@ export class HttpErrorInterceptor implements HttpInterceptor { next: HttpHandler ): Observable> { - return next.handle(req).pipe(retry(5), catchError( + return next.handle(req).pipe(retry(this.MAX_RETRIES), catchError( (error: HttpErrorResponse) => { if (this.router.url.startsWith('/error')) { diff --git a/client/src/app/project/project.component.ts b/client/src/app/project/project.component.ts index 970c11e2..033ebe36 100644 --- a/client/src/app/project/project.component.ts +++ b/client/src/app/project/project.component.ts @@ -194,7 +194,6 @@ export class ProjectComponent implements OnInit { onSwitchUpload() { this.upload = !this.upload; - } canSubmit(project: Project) { diff --git a/client/src/app/shared/components/upload/upload.component.html b/client/src/app/shared/components/upload/upload.component.html index ae352787..13740bef 100644 --- a/client/src/app/shared/components/upload/upload.component.html +++ b/client/src/app/shared/components/upload/upload.component.html @@ -5,14 +5,8 @@

Upload a Spreadsheet


-

- - Create - Update - -

diff --git a/client/src/app/shared/components/upload/upload.component.spec.ts b/client/src/app/shared/components/upload/upload.component.spec.ts new file mode 100644 index 00000000..dc0010b4 --- /dev/null +++ b/client/src/app/shared/components/upload/upload.component.spec.ts @@ -0,0 +1,127 @@ +import {ProjectFormComponent} from '../../../project-form/project-form.component'; +import {async, ComponentFixture, TestBed} from '@angular/core/testing'; +import {of, throwError} from 'rxjs'; +import {RouterTestingModule} from '@angular/router/testing'; +import {ROUTES} from '../../../app.routes'; +import {HttpClientTestingModule} from '@angular/common/http/testing'; +import {Router} from '@angular/router'; +import {AlertService} from '../../services/alert.service'; +import {LoaderService} from '../../services/loader.service'; +import SpyObj = jasmine.SpyObj; +import {UploadComponent} from './upload.component'; +import {BrokerService} from '../../services/broker.service'; +import {ElementRef} from '@angular/core'; + + +describe('UploadComponent', () => { + let component: UploadComponent; + let fixture: ComponentFixture; + + let brokerSvc: SpyObj; + let alertSvc: SpyObj; + let loaderSvc: SpyObj; + let router: SpyObj; + + beforeEach(async(() => { + brokerSvc = jasmine.createSpyObj('BrokerService', [ + 'uploadSpreadsheet' + ]); + alertSvc = jasmine.createSpyObj('AlertService', [ + 'success', + 'clear', + 'error' + ]); + loaderSvc = jasmine.createSpyObj('LoaderService', [ + 'display' + ]); + router = jasmine.createSpyObj('Router', [ + 'navigate' + ]); + + brokerSvc.uploadSpreadsheet.and.returnValue(of({message: '', details: {submission_uuid: 'submission-uuid'}})); + + TestBed.configureTestingModule({ + imports: [ + RouterTestingModule.withRoutes(ROUTES), + HttpClientTestingModule + ], + providers: [ + {provide: AlertService, useValue: alertSvc}, + {provide: BrokerService, useValue: brokerSvc}, + {provide: LoaderService, useValue: loaderSvc}, + {provide: Router, useValue: router} + ], + declarations: [ProjectFormComponent] + }) + .compileComponents(); + })); + + function makeSpreadsheetBlob() { + const blob = new Blob([''], {type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}); + blob['lastModifiedDate'] = ''; + blob['name'] = 'filename.xlsx'; + return blob; + } + + beforeEach(() => { + fixture = TestBed.createComponent(UploadComponent); + component = fixture.componentInstance; + const mockNativeElement = { + get files() { + const blob = makeSpreadsheetBlob(); + const file = blob; + const fileList = { + 0: file + }; + return fileList; + } + }; + + component.fileInput = new ElementRef(mockNativeElement); + component.projectUuid = 'project-uuid'; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should display successful when upload successful', () => { + component.upload(); + expect(brokerSvc.uploadSpreadsheet).toHaveBeenCalled(); + expect(alertSvc.success).toHaveBeenCalled(); + expect(loaderSvc.display.calls.allArgs()).toEqual([[true], [false]]); + expect(router.navigate).toHaveBeenCalledWith(['/submissions/detail'], Object({ + queryParams: Object({ + uuid: 'submission-uuid', + project: 'project-uuid' + }) + })); + }); + + it('should display error when no file', () => { + const mockNativeElement = { + get files() { + return {}; + } + }; + component.fileInput = new ElementRef(mockNativeElement); + component.upload(); + + expect(brokerSvc.uploadSpreadsheet).toHaveBeenCalledTimes(0); + expect(alertSvc.clear).toHaveBeenCalled(); + expect(alertSvc.error).toHaveBeenCalled(); + expect(router.navigate).toHaveBeenCalledTimes(0); + + }); + + it('should display error when broker svc upload has error', () => { + brokerSvc.uploadSpreadsheet.and.returnValue(throwError({status: 500})); + component.upload(); + + expect(brokerSvc.uploadSpreadsheet).toHaveBeenCalled(); + expect(alertSvc.error).toHaveBeenCalled(); + expect(loaderSvc.display.calls.allArgs()).toEqual([[true], [false]]); + expect(router.navigate).toHaveBeenCalledTimes(0); + }); +}); diff --git a/client/src/app/shared/components/upload/upload.component.ts b/client/src/app/shared/components/upload/upload.component.ts index 0aa82de1..f9eab8f0 100644 --- a/client/src/app/shared/components/upload/upload.component.ts +++ b/client/src/app/shared/components/upload/upload.component.ts @@ -12,30 +12,26 @@ import {LoaderService} from '../../services/loader.service'; styleUrls: ['./upload.component.css'], encapsulation: ViewEncapsulation.None }) -export class UploadComponent implements OnInit { +export class UploadComponent { - @ViewChild('fileInput', { static: true }) fileInput; - @ViewChild('projectUuidInput', { static: true }) projectIdInput; + @ViewChild('fileInput', {static: true}) fileInput; error$: Observable; uploadResults$: Observable; @Input() projectUuid; + @Input() submissionUuid; + @Input() isUpdate = false; @Output() fileUpload = new EventEmitter(); - isUpdate = false; - constructor(private brokerService: BrokerService, private router: Router, private alertService: AlertService, private loaderService: LoaderService) { - this.isUpdate = false; } - ngOnInit() {} - upload() { const fileBrowser = this.fileInput.nativeElement; if (fileBrowser.files && fileBrowser.files[0]) { @@ -43,19 +39,21 @@ export class UploadComponent implements OnInit { const formData = new FormData(); formData.append('file', fileBrowser.files[0]); - const projectUuid = this.projectIdInput.nativeElement.value; + if (this.projectUuid) { + formData.append('projectUuid', this.projectUuid); + } - if (projectUuid) { - formData.append('projectUuid', projectUuid ); + if (this.submissionUuid) { + formData.append('submissionUuid', this.submissionUuid); } this.brokerService.uploadSpreadsheet(formData, this.isUpdate).subscribe({ next: data => { this.uploadResults$ = data; - const submissionId = this.uploadResults$['details']['submission_id']; + const submissionUuid = this.uploadResults$['details']['submission_uuid']; this.loaderService.display(false); this.alertService.success('Upload Success', this.uploadResults$['message'], true, true); - this.router.navigate(['/submissions/detail'], { queryParams: { id: submissionId, project: this.projectUuid } } ); + this.router.navigate(['/submissions/detail'], {queryParams: {uuid: submissionUuid, project: this.projectUuid}}); }, error: err => { this.error$ = err; diff --git a/client/src/app/submission/submission.component.html b/client/src/app/submission/submission.component.html index a348dcc5..bebcf926 100644 --- a/client/src/app/submission/submission.component.html +++ b/client/src/app/submission/submission.component.html @@ -22,38 +22,60 @@

href="{{submissionEnvelope?._links?.self?.href}}"> open_in_new + + + + {{submissionState}} + + + +
+
+
{{projectTitle}}
+
{{getContributors(project)}}
+
+
+
-
{{projectTitle}}
-
{{getContributors(project)}}
-

Your validation returned {{validationSummary.totalInvalid}} error(s). Review and fix them below.

+

Your validation returned {{validationSummary.totalInvalid}} error(s). Review + and fix them below.

+
Project is Invalid. Please go back and edit the project.
@@ -124,6 +152,24 @@

+ + + +
+

Import a spreadsheet

+

You could import a spreadsheet to update the metadata in this submission. + The spreadsheet should contain the uuid's of the metadata you want to update.

+

Please note that updating the linking between entities is not supported via spreadsheets yet.

+ +
+ +
+
+
+
+
+ - +
- + ; private MAX_ERRORS = 1; + submissionTab = SubmissionTab; constructor( private alertService: AlertService, @@ -90,99 +123,107 @@ export class SubmissionComponent implements OnInit, OnDestroy { connectSubmissionEnvelope() { this.submissionDataSource = new SimpleDataSource(this.submissionEnvelopeEndpoint.bind(this)); this.submissionDataSource.connect(true, 15000).subscribe(submissionEnvelope => { - // NOTE: this should be broken up and/or just use dataSource attributes directly in template but - // we're getting rid of submissions anyway. - this.submissionEnvelope = submissionEnvelope; - this.submissionEnvelopeId = SubmissionComponent.getSubmissionId(submissionEnvelope); - this.isValid = this.checkIfValid(submissionEnvelope); - this.submissionState = submissionEnvelope['submissionState']; - this.isSubmitted = this.isStateSubmitted(submissionEnvelope.submissionState); - this.submitLink = this.getLink(submissionEnvelope, 'submit'); - this.exportLink = this.getLink(submissionEnvelope, 'export'); - this.cleanupLink = this.getLink(submissionEnvelope, 'cleanup'); - this.url = this.getLink(submissionEnvelope, 'self'); - - this.submissionErrors = submissionEnvelope['errors']; - if (this.submissionErrors.length > 0) { - this.alertService.clear(); - } - if (this.submissionErrors.length > this.MAX_ERRORS) { - const link = this.submissionEnvelope._links.submissionEnvelopeErrors.href; - const message = `Cannot show more than ${this.MAX_ERRORS} errors.`; - this.alertService.error( - `${this.submissionErrors.length - this.MAX_ERRORS} Other Errors`, - `${message}
View all ${this.submissionErrors.length} errors.`, - false, - false); - } - let errors_displayed = 0; - for (const err of this.submissionErrors) { - if (errors_displayed >= this.MAX_ERRORS) { - break; - } - this.alertService.error(err['title'], err['detail'], false, false); - errors_displayed++; - } - - this.manifest = submissionEnvelope['manifest']; - const actualLinks = this.manifest['actualLinks']; - const expectedLinks = this.manifest['expectedLinks']; - if (!expectedLinks || (actualLinks === expectedLinks)) { - this.isLinkingDone = true; - } - + this.initSubmissionAttributes(submissionEnvelope); + this.displaySubmissionErrors(submissionEnvelope); + this.checkFromManifestIfLinkingIsDone(submissionEnvelope); this.validationSummary = submissionEnvelope['summary']; - this.initDataSources(); this.connectProject(this.submissionEnvelopeId); }); } + private checkFromManifestIfLinkingIsDone(submissionEnvelope: SubmissionEnvelope) { + this.manifest = submissionEnvelope['manifest']; + const actualLinks = this.manifest['actualLinks']; + const expectedLinks = this.manifest['expectedLinks']; + if (!expectedLinks || (actualLinks === expectedLinks)) { + this.isLinkingDone = true; + } + } + + private displaySubmissionErrors(submissionEnvelope: SubmissionEnvelope) { + this.submissionErrors = submissionEnvelope['errors']; + if (this.submissionErrors.length > 0) { + this.alertService.clear(); + } + if (this.submissionErrors.length > this.MAX_ERRORS) { + const link = this.submissionEnvelope._links.submissionEnvelopeErrors.href; + const message = `Cannot show more than ${this.MAX_ERRORS} errors.`; + this.alertService.error( + `${this.submissionErrors.length - this.MAX_ERRORS} Other Errors`, + `${message} View all ${this.submissionErrors.length} errors.`, + false, + false); + } + let errors_displayed = 0; + for (const err of this.submissionErrors) { + if (errors_displayed >= this.MAX_ERRORS) { + break; + } + this.alertService.error(err['title'], err['detail'], false, false); + errors_displayed++; + } + } + + private initSubmissionAttributes(submissionEnvelope: SubmissionEnvelope) { + // NOTE: this should be broken up and/or just use dataSource attributes directly in template but + // we're getting rid of submissions anyway. + this.submissionEnvelope = submissionEnvelope; + this.submissionEnvelopeId = SubmissionComponent.getSubmissionId(submissionEnvelope); + this.isValid = this.checkIfValid(submissionEnvelope); + this.submissionState = submissionEnvelope['submissionState']; + this.isSubmitted = this.isStateSubmitted(SubmissionState[submissionEnvelope.submissionState]); + this.submitLink = this.getLink(submissionEnvelope, 'submit'); + this.exportLink = this.getLink(submissionEnvelope, 'export'); + this.cleanupLink = this.getLink(submissionEnvelope, 'cleanup'); + this.url = this.getLink(submissionEnvelope, 'self'); + } + private submissionEnvelopeEndpoint() { if (!this.submissionEnvelopeId && !this.submissionEnvelopeUuid) { throw new Error('No ID or UUID for submissionEnvelope.'); } const submissionEnvelope$ = this.submissionEnvelopeId ? - this.ingestService.getSubmission(this.submissionEnvelopeId) : - this.ingestService.getSubmissionByUuid(this.submissionEnvelopeUuid); + this.ingestService.getSubmission(this.submissionEnvelopeId) : + this.ingestService.getSubmissionByUuid(this.submissionEnvelopeUuid); return submissionEnvelope$.pipe( - mergeMap( - this.submissionErrorsEndpoint.bind(this), - (submissionEnvelope, errors) => ({ ...submissionEnvelope, errors }) - ), - mergeMap( - this.submissionManifestEndpoint.bind(this), - (submissionEnvelope, manifest) => ({ ...submissionEnvelope, manifest }) - ), - mergeMap( - submissionEnvelope => this.ingestService.getSubmissionSummary(SubmissionComponent.getSubmissionId(submissionEnvelope)), - (submissionEnvelope, summary) => ({ ...submissionEnvelope, summary }) - ) + mergeMap( + this.submissionErrorsEndpoint.bind(this), + (submissionEnvelope, errors) => ({...submissionEnvelope, errors}) + ), + mergeMap( + this.submissionManifestEndpoint.bind(this), + (submissionEnvelope, manifest) => ({...submissionEnvelope, manifest}) + ), + mergeMap( + submissionEnvelope => this.ingestService.getSubmissionSummary(SubmissionComponent.getSubmissionId(submissionEnvelope)), + (submissionEnvelope, summary) => ({...submissionEnvelope, summary}) + ) ); } private submissionErrorsEndpoint(submissionEnvelope) { return this.ingestService.get(submissionEnvelope['_links']['submissionEnvelopeErrors']['href']).pipe( - map(data => { - return data['_embedded'] ? data['_embedded']['submissionErrors'] : []; - }) + map(data => { + return data['_embedded'] ? data['_embedded']['submissionErrors'] : []; + }) ); } private submissionManifestEndpoint(submissionEnvelope) { return this.ingestService.get(submissionEnvelope['_links']['submissionManifest']['href']).pipe( - catchError(err => { - if (err instanceof HttpErrorResponse && err.status === 404) { - // do nothing, the endpoint throws error when no submission manifest is found - this.isLinkingDone = true; - } else { - console.error(err); - } - // do nothing - return of([]); - }) + catchError(err => { + if (err instanceof HttpErrorResponse && err.status === 404) { + // do nothing, the endpoint throws error when no submission manifest is found + this.isLinkingDone = true; + } else { + console.error(err); + } + // do nothing + return of([]); + }) ); } @@ -206,7 +247,7 @@ export class SubmissionComponent implements OnInit, OnDestroy { checkIfValid(submission) { const status = submission['submissionState']; - return (status === 'Valid' || this.isStateSubmitted(status)); + return (status === 'Valid' || this.isStateSubmitted(SubmissionState[status])); } setProject(project) { @@ -232,9 +273,8 @@ export class SubmissionComponent implements OnInit, OnDestroy { return this.project && this.project['uuid'] ? this.project['uuid']['uuid'] : ''; } - isStateSubmitted(state) { - const submittedStates = ['Submitted', 'Processing', 'Archiving', 'Archived', 'Exporting', 'Exported', 'Cleanup', 'Complete']; - return (submittedStates.indexOf(state) >= 0); + isStateSubmitted(state: SubmissionState) { + return (SUBMITTED_STATES.indexOf(state) >= 0); } getLink(submissionEnvelope, linkName) { @@ -244,6 +284,7 @@ export class SubmissionComponent implements OnInit, OnDestroy { downloadFile() { const uuid = this.submissionEnvelope['uuid']['uuid']; + this.loaderService.display(true); this.brokerService.downloadSpreadsheet(uuid).subscribe(response => { const filename = response['filename']; const newBlob = new Blob([response['data']]); @@ -263,6 +304,8 @@ export class SubmissionComponent implements OnInit, OnDestroy { window.URL.revokeObjectURL(data); link.remove(); }, 100); + + this.loaderService.display(false); }); } @@ -308,16 +351,16 @@ export class SubmissionComponent implements OnInit, OnDestroy { if (archiveSubmission) { const entitiesUrl = archiveSubmission['_links']['entities']['href']; this.archiveEntityDataSource = new PaginatedDataSource( - params => this.ingestService.get(entitiesUrl, {params}).pipe( - // TODO: This gets done a lot, refactor - map(data => data as ListResult), - map(data => { - return { - data: data && data._embedded ? data._embedded['archiveEntities'] : [], - page: data.page - }; - }) - ) + params => this.ingestService.get(entitiesUrl, {params}).pipe( + // TODO: This gets done a lot, refactor + map(data => data as ListResult), + map(data => { + return { + data: data && data._embedded ? data._embedded['archiveEntities'] : [], + page: data.page + }; + }) + ) ); } } @@ -337,10 +380,33 @@ export class SubmissionComponent implements OnInit, OnDestroy { }); } - navigateToTab(index: number, sourceFilter?: { dataSource?: MetadataDataSource, filterState?: string }): void { + navigateToTab(tabName: SubmissionTab, sourceFilter?: { dataSource?: MetadataDataSource, filterState?: string }): void { + const index = tabName.valueOf(); this.selectedIndex = index; if (sourceFilter && sourceFilter.dataSource && sourceFilter.filterState) { sourceFilter.dataSource.filterByState(sourceFilter.filterState); } } + + displaySubmitTab(): boolean { + return [ + SubmissionState.Submitted, + SubmissionState.Processing, + SubmissionState.Archiving, + SubmissionState.Exporting, + SubmissionState.Cleanup, + SubmissionState.Complete + ].indexOf(SubmissionState[this.submissionState]) < 0; + } + + displayAccessionTab(): boolean { + return [ + SubmissionState.Archiving, + SubmissionState.Archived, + SubmissionState.Exported, + SubmissionState.Cleanup, + SubmissionState.Cleanup, + SubmissionState.Complete + ].indexOf(SubmissionState[this.submissionState]) >= 0; + } } diff --git a/client/src/app/submission/submit/submit.component.html b/client/src/app/submission/submit/submit.component.html index ff950a57..15a90789 100644 --- a/client/src/app/submission/submit/submit.component.html +++ b/client/src/app/submission/submit/submit.component.html @@ -1,6 +1,5 @@ -
-

What's next?

+

What's next?

You have entered all necessary information per Human Cell Atlas standards. That’s awesome!

If everything looks correct, feel free to Submit your data.