diff --git a/cypress/integration/datasets-attachment.spec.js b/cypress/integration/datasets-attachment.spec.js index c9d65354d..89665a2cf 100644 --- a/cypress/integration/datasets-attachment.spec.js +++ b/cypress/integration/datasets-attachment.spec.js @@ -1,24 +1,20 @@ /// +var path = require("path"); -describe("Datasets", () => { +describe("Dataset attachments", () => { beforeEach(() => { cy.login(Cypress.config("username"), Cypress.config("password")); - cy.createDataset("raw"); - cy.intercept("POST", "/api/v3/Datasets/**/*").as("upload"); }); - afterEach(() => { - cy.login( - Cypress.config("secondaryUsername"), - Cypress.config("secondaryPassword") - ); + after(() => { cy.removeDatasets(); }); - describe("Add Attachment", () => { + describe("Attachment tests", () => { it("should go to dataset details and add an attachment using the dropzone", () => { + cy.createDataset("raw"); cy.visit("/datasets"); cy.get(".dataset-table mat-table mat-header-row").should("exist"); @@ -55,6 +51,8 @@ describe("Datasets", () => { cy.wait("@upload").then(({ request, response }) => { expect(request.method).to.eq("POST"); expect(response.statusCode).to.eq(201); + + cy.get(".snackbar-success").should("exist"); }); cy.get(".attachment-card #caption").should( @@ -62,5 +60,64 @@ describe("Datasets", () => { "SciCatLogo.png" ); }); + + it("should be able to download dataset attachment", () => { + cy.visit("/datasets"); + + cy.get(".dataset-table mat-table mat-header-row").should("exist"); + + cy.finishedLoading(); + + cy.get('input[type="search"][data-placeholder="Text Search"]') + .clear() + .type("Cypress"); + + cy.isLoading(); + + cy.finishedLoading(); + + cy.get(".mat-row").contains("Cypress Dataset").first().click(); + + cy.isLoading(); + + cy.finishedLoading(); + + cy.get(".mat-tab-link").contains("Attachments").click(); + + cy.get(".download-button").click(); + + const downloadsFolder = Cypress.config("downloadsFolder"); + cy.readFile(path.join(downloadsFolder, "SciCatLogo.png")).should("exist"); + }); + + it("should be able to delete dataset attachment", () => { + cy.visit("/datasets"); + + cy.get(".dataset-table mat-table mat-header-row").should("exist"); + + cy.finishedLoading(); + + cy.get('input[type="search"][data-placeholder="Text Search"]') + .clear() + .type("Cypress"); + + cy.isLoading(); + + cy.finishedLoading(); + + cy.get(".mat-row").contains("Cypress Dataset").first().click(); + + cy.isLoading(); + + cy.finishedLoading(); + + cy.get(".mat-tab-link").contains("Attachments").click(); + + cy.get(".delete-button").click(); + + cy.get(".attachment-card #caption").should("not.exist"); + + cy.get(".snackbar-success").should("exist"); + }); }); }); diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 876868266..815a9dc25 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -1,84 +1,90 @@ -import { userReducer } from "state-management/reducers/user.reducer"; -import { AppComponent } from "./app.component"; -import { AppConfigModule } from "app-config.module"; -import { AppRoutingModule, routes } from "app-routing/app-routing.module"; -import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; -import { BrowserModule, Title } from "@angular/platform-browser"; -import { EffectsModule } from "@ngrx/effects"; -import { HttpClientModule } from "@angular/common/http"; -import { APP_INITIALIZER, NgModule } from "@angular/core"; -import { RouterModule } from "@angular/router"; -import { SampleApi, SDKBrowserModule } from "shared/sdk/index"; -import { StoreModule } from "@ngrx/store"; -import { UserApi } from "shared/sdk/services"; -import { routerReducer } from "@ngrx/router-store"; -import { extModules } from "./build-specifics"; -import { MatNativeDateModule } from "@angular/material/core"; -import { MatProgressSpinnerModule } from "@angular/material/progress-spinner"; -import { MatSnackBarModule } from "@angular/material/snack-bar"; -import { LayoutModule } from "_layout/layout.module"; -import { AppConfigService } from "app-config.service"; -import { AppThemeService } from "app-theme.service"; - -const appConfigInitializerFn = (appConfig: AppConfigService) => { - return () => appConfig.loadAppConfig(); -}; - -const appThemeInitializerFn = (appTheme: AppThemeService) => { - return () => appTheme.loadTheme(); -}; - -@NgModule({ - declarations: [AppComponent], - imports: [ - AppConfigModule, - AppRoutingModule, - BrowserAnimationsModule, - BrowserModule, - HttpClientModule, - LayoutModule, - MatProgressSpinnerModule, - MatSnackBarModule, - SDKBrowserModule.forRoot(), - StoreModule.forRoot( - { router: routerReducer, users: userReducer }, - { - runtimeChecks: { - strictStateImmutability: false, - strictActionImmutability: false, - strictStateSerializability: false, - strictActionSerializability: false, - }, - } - ), - extModules, - RouterModule.forRoot(routes, { - useHash: false, - relativeLinkResolution: "legacy", - }), - EffectsModule.forRoot([]), - ], - exports: [MatNativeDateModule], - providers: [ - AppConfigService, - { - provide: APP_INITIALIZER, - useFactory: appConfigInitializerFn, - multi: true, - deps: [AppConfigService], - }, - { - provide: APP_INITIALIZER, - useFactory: appThemeInitializerFn, - multi: true, - deps: [AppThemeService], - }, - AppThemeService, - UserApi, - SampleApi, - Title, - MatNativeDateModule, - ], - bootstrap: [AppComponent], -}) -export class AppModule { } +import { userReducer } from "state-management/reducers/user.reducer"; +import { AppComponent } from "./app.component"; +import { AppConfigModule } from "app-config.module"; +import { AppRoutingModule, routes } from "app-routing/app-routing.module"; +import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; +import { BrowserModule, Title } from "@angular/platform-browser"; +import { EffectsModule } from "@ngrx/effects"; +import { HttpClientModule, HTTP_INTERCEPTORS } from "@angular/common/http"; +import { APP_INITIALIZER, NgModule } from "@angular/core"; +import { RouterModule } from "@angular/router"; +import { SampleApi, SDKBrowserModule } from "shared/sdk/index"; +import { StoreModule } from "@ngrx/store"; +import { UserApi } from "shared/sdk/services"; +import { routerReducer } from "@ngrx/router-store"; +import { extModules } from "./build-specifics"; +import { MatNativeDateModule } from "@angular/material/core"; +import { MatProgressSpinnerModule } from "@angular/material/progress-spinner"; +import { MatSnackBarModule } from "@angular/material/snack-bar"; +import { LayoutModule } from "_layout/layout.module"; +import { AppConfigService } from "app-config.service"; +import { AppThemeService } from "app-theme.service"; +import { SnackbarInterceptor } from "shared/interceptors/snackbar.interceptor"; + +const appConfigInitializerFn = (appConfig: AppConfigService) => { + return () => appConfig.loadAppConfig(); +}; + +const appThemeInitializerFn = (appTheme: AppThemeService) => { + return () => appTheme.loadTheme(); +}; + +@NgModule({ + declarations: [AppComponent], + imports: [ + AppConfigModule, + AppRoutingModule, + BrowserAnimationsModule, + BrowserModule, + HttpClientModule, + LayoutModule, + MatProgressSpinnerModule, + MatSnackBarModule, + SDKBrowserModule.forRoot(), + StoreModule.forRoot( + { router: routerReducer, users: userReducer }, + { + runtimeChecks: { + strictStateImmutability: false, + strictActionImmutability: false, + strictStateSerializability: false, + strictActionSerializability: false, + }, + } + ), + extModules, + RouterModule.forRoot(routes, { + useHash: false, + relativeLinkResolution: "legacy", + }), + EffectsModule.forRoot([]), + ], + exports: [MatNativeDateModule], + providers: [ + AppConfigService, + { + provide: APP_INITIALIZER, + useFactory: appConfigInitializerFn, + multi: true, + deps: [AppConfigService], + }, + { + provide: APP_INITIALIZER, + useFactory: appThemeInitializerFn, + multi: true, + deps: [AppThemeService], + }, + { + provide: HTTP_INTERCEPTORS, + useClass: SnackbarInterceptor, + multi: true, + }, + AppThemeService, + UserApi, + SampleApi, + Title, + MatNativeDateModule, + ], + bootstrap: [AppComponent], +}) +export class AppModule {} diff --git a/src/app/shared/interceptors/snackbar.interceptor.ts b/src/app/shared/interceptors/snackbar.interceptor.ts new file mode 100644 index 000000000..53213cb4e --- /dev/null +++ b/src/app/shared/interceptors/snackbar.interceptor.ts @@ -0,0 +1,46 @@ +import { Injectable } from "@angular/core"; +import { + HttpRequest, + HttpHandler, + HttpEvent, + HttpInterceptor, + HttpResponse, +} from "@angular/common/http"; +import { Observable, throwError } from "rxjs"; +import { catchError, tap } from "rxjs/operators"; +import { MatSnackBar } from "@angular/material/snack-bar"; + +@Injectable() +export class SnackbarInterceptor implements HttpInterceptor { + constructor(private snackBar: MatSnackBar) {} + + intercept( + request: HttpRequest, + next: HttpHandler + ): Observable> { + return next.handle(request).pipe( + tap((e) => { + if ( + request.method == "POST" || + request.method == "PUT" || + request.method == "PATCH" || + request.method == "DELETE" + ) { + if (e instanceof HttpResponse && (e.status == 200 || e.status == 201)) { + this.snackBar.open("Success", "close", { + duration: 3000, + panelClass: "snackbar-success", + }); + } + } + }), + catchError((error) => { + this.snackBar.open("Error occurred", "close", { + duration: 3000, + panelClass: "snackbar-error", + }); + return throwError(error); + }) + ); + } +} diff --git a/src/app/shared/modules/file-uploader/file-uploader.component.html b/src/app/shared/modules/file-uploader/file-uploader.component.html index cb3688bd6..d49b1e738 100644 --- a/src/app/shared/modules/file-uploader/file-uploader.component.html +++ b/src/app/shared/modules/file-uploader/file-uploader.component.html @@ -1,53 +1,72 @@ - - -
-
Drop a file here
-
or
-
- -
-
-
- - -
Choose an image to upload
- - -
- - - - - -
- - - - -
- -
-
-
+ + +
+
Drop a file here
+
or
+
+ +
+
+ Accepted file formats: image/*, application/pdf +
+
+
+ + +
+ Choose an image to upload +
+ Accepted file formats: image/*, application/pdf +
+
+ + +
+ + + + + +
+ + + + +
+ + +
+
+
diff --git a/src/app/shared/modules/file-uploader/file-uploader.component.scss b/src/app/shared/modules/file-uploader/file-uploader.component.scss index dc6fe44ee..3cd51a4bc 100644 --- a/src/app/shared/modules/file-uploader/file-uploader.component.scss +++ b/src/app/shared/modules/file-uploader/file-uploader.component.scss @@ -1,54 +1,70 @@ -.attachment-card { - width: 22em; - float: left; - margin: 1em 2em 1em 0; - - img { - width: 20em; - height: 20em; - } - - button { - float: right; - } - - .submit-button { - margin-top: 0.5em; - } -} -.file-uploader { - margin: 1em; -} - -@media only screen and (min-width: 1280px) { - .dropzone { - align-items: center; - background: #dcdcdc; - border: dashed 1px grey; - display: flex; - justify-content: center; - height: 350px; - width: 500px; - - input { - display: none; - } - - .instructions { - margin: 0.5em; - font-size: 1.25em; - flex: 1 0 100px; - text-align: center; - } - } - - .file-picker { - display: none; - } -} - -@media only screen and (max-width: 1279px) { - .dropzone { - display: none; - } -} +.attachment-card { + width: 22em; + float: left; + margin: 1em 2em 1em 0; + + img { + width: 20em; + height: 20em; + } + + button { + float: right; + } + + .submit-button { + margin-top: 0.5em; + } + + .download-button { + min-width: auto; + margin: 0; + padding: 0 5px; + } + + .delete-button { + min-width: auto; + margin: 0; + padding: 0 5px; + } +} +.file-uploader { + margin: 1em; +} + +.font-small { + font-size: small !important; +} + +@media only screen and (min-width: 1280px) { + .dropzone { + align-items: center; + background: #dcdcdc; + border: dashed 1px grey; + display: flex; + justify-content: center; + height: 350px; + width: 500px; + + input { + display: none; + } + + .instructions { + margin: 0.5em; + font-size: 1.25em; + flex: 1 0 100px; + text-align: center; + } + } + + .file-picker { + display: none; + } +} + +@media only screen and (max-width: 1279px) { + .dropzone { + display: none; + } +} diff --git a/src/app/shared/modules/file-uploader/file-uploader.component.ts b/src/app/shared/modules/file-uploader/file-uploader.component.ts index e00f84cbd..0ed9f48f0 100644 --- a/src/app/shared/modules/file-uploader/file-uploader.component.ts +++ b/src/app/shared/modules/file-uploader/file-uploader.component.ts @@ -1,68 +1,108 @@ -import { Component, Output, EventEmitter, Input } from "@angular/core"; -import { Attachment } from "shared/sdk"; - -export interface PickedFile { - content: string; - name: string; - size: number; - type: string; -} - -export interface SubmitCaptionEvent { - attachmentId: string; - caption: string; -} - -@Component({ - selector: "app-file-uploader", - templateUrl: "./file-uploader.component.html", - styleUrls: ["./file-uploader.component.scss"], -}) -export class FileUploaderComponent { - @Input() attachments: Attachment[] = []; - - @Output() filePicked = new EventEmitter(); - @Output() submitCaption = new EventEmitter(); - @Output() deleteAttachment = new EventEmitter(); - - async onFileDropped(event: unknown) { - const files = Array.from(event as FileList); - - if (files.length > 0) { - await Promise.all( - files.map(async (file) => { - const buffer = await file.arrayBuffer(); - let binary = ""; - const bytes = new Uint8Array(buffer); - const bytesLength = bytes.byteLength; - for (let i = 0; i < bytesLength; i++) { - binary += String.fromCharCode(bytes[i]); - } - const pickedFile: PickedFile = { - content: "data:" + file.type + ";base64," + btoa(binary), - name: file.name, - size: file.size, - type: file.type, - }; - this.filePicked.emit(pickedFile); - }) - ); - } - } - - onFilePicked(files: FileList) { - this.onFileDropped(files); - } - - onSubmitCaption(attachmentId: string, caption: string) { - const event: SubmitCaptionEvent = { - attachmentId, - caption, - }; - this.submitCaption.emit(event); - } - - onDeleteAttachment(attachmentId: string) { - this.deleteAttachment.emit(attachmentId); - } -} +import { Component, Output, EventEmitter, Input } from "@angular/core"; +import saveAs from "file-saver"; +import { Attachment } from "shared/sdk"; + +export interface PickedFile { + content: string; + name: string; + size: number; + type: string; +} + +export interface SubmitCaptionEvent { + attachmentId: string; + caption: string; +} + +@Component({ + selector: "app-file-uploader", + templateUrl: "./file-uploader.component.html", + styleUrls: ["./file-uploader.component.scss"], +}) +export class FileUploaderComponent { + @Input() attachments: Attachment[] = []; + + @Output() filePicked = new EventEmitter(); + @Output() submitCaption = new EventEmitter(); + @Output() deleteAttachment = new EventEmitter(); + + async onFileDropped(event: unknown) { + const files = Array.from(event as FileList); + + if (files.length > 0) { + await Promise.all( + files.map(async (file) => { + const buffer = await file.arrayBuffer(); + let binary = ""; + const bytes = new Uint8Array(buffer); + const bytesLength = bytes.byteLength; + for (let i = 0; i < bytesLength; i++) { + binary += String.fromCharCode(bytes[i]); + } + const pickedFile: PickedFile = { + content: "data:" + file.type + ";base64," + btoa(binary), + name: file.name, + size: file.size, + type: file.type, + }; + this.filePicked.emit(pickedFile); + }) + ); + } + } + + onFilePicked(files: FileList) { + this.onFileDropped(files); + } + + onSubmitCaption(attachmentId: string, caption: string) { + const event: SubmitCaptionEvent = { + attachmentId, + caption, + }; + this.submitCaption.emit(event); + } + + onDeleteAttachment(attachmentId: string) { + this.deleteAttachment.emit(attachmentId); + } + + base64MimeType(encoded: string): string { + var result = null; + + if (typeof encoded !== "string") { + return result; + } + + var mime = encoded.match(/data:([a-zA-Z0-9]+\/[a-zA-Z0-9-.+]+).*,.*/); + + if (mime && mime.length) { + result = mime[1]; + } + + return result; + } + + onDownloadAttachment(attachment: Attachment) { + const mimeType = this.base64MimeType(attachment.thumbnail); + if (!mimeType) { + throw new Error( + "File type of the downloading file can not be determined" + ); + } + + const splitMimeType = mimeType.split("/"); + const fileType = splitMimeType[splitMimeType.length - 1]; + + if (!fileType) { + throw new Error( + "File type of the downloading file can not be determined" + ); + } + + saveAs( + attachment.thumbnail, + attachment.caption || `${attachment.id}.${fileType}` + ); + } +} diff --git a/src/styles.scss b/src/styles.scss index 694de783f..13f87937f 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -1,22 +1,27 @@ // Custom Theming for Angular Material // For more information: https://material.angular.io/guide/theming -@use '@angular/material' as mat; +@use "@angular/material" as mat; @use "./app/app-theme" as app; @use "./app/_layout/app-header/app-header-theme" as app-header; @use "./app/datasets/batch-view/batch-view-theme" as batch-view; @use "./app/datasets/dashboard/dashboard-theme" as dashboard; @use "./app/datasets/datafiles/datafiles-theme" as datafiles; @use "./app/datasets/dataset-detail/dataset-detail-theme" as dataset-detail; -@use "./app/datasets/dataset-details-dashboard/dataset-details-dashboard-theme" as dataset-details-dashboard; -@use "./app/datasets/dataset-lifecycle/dataset-lifecycle-theme" as dataset-lifecycle; +@use "./app/datasets/dataset-details-dashboard/dataset-details-dashboard-theme" + as dataset-details-dashboard; +@use "./app/datasets/dataset-lifecycle/dataset-lifecycle-theme" as + dataset-lifecycle; @use "./app/datasets/dataset-table/dataset-table-theme" as dataset-table; -@use "./app/datasets/dataset-table-settings/dataset-table-settings-theme" as dataset-table-settings; +@use "./app/datasets/dataset-table-settings/dataset-table-settings-theme" as + dataset-table-settings; @use "./app/datasets/reduce/reduce-theme" as reduce; -@use "./app/instruments/instrument-details/instrument-details-theme" as instrument-details; +@use "./app/instruments/instrument-details/instrument-details-theme" as + instrument-details; @use "./app/logbooks/logbooks-detail/logbooks-detail-theme" as logbooks-detail; @use "./app/logbooks/logbooks-table/logbooks-table-theme" as logbooks-table; @use "./app/proposals/proposal-detail/proposal-detail-theme" as proposal-detail; -@use "./app/publisheddata/publisheddata-details/publisheddata-details-theme" as publisheddata-details; +@use "./app/publisheddata/publisheddata-details/publisheddata-details-theme" as + publisheddata-details; @use "./app/samples/sample-dashboard/sample-dashboard-theme" as sample-dashboard; @use "./app/samples/sample-detail/sample-detail-theme" as sample-detail; @use "./app/shared/modules/breadcrumb/breadcrumb-theme" as breadcrumb; @@ -44,8 +49,8 @@ $theme-primary: ( contrast: ( default: var(--theme-primary-default-contrast), lighter: var(--theme-primary-lighter-contrast), - darker: var(--theme-primary-darker-contrast) - ) + darker: var(--theme-primary-darker-contrast), + ), ); $theme-accent: ( @@ -55,8 +60,11 @@ $theme-accent: ( contrast: ( default: var(--theme-accent-default-contrast), lighter: var(--theme-accent-lighter-contrast), - darker: var (--theme-accent-darker-contrast) - ) + darker: var + ( + --theme-accent-darker-contrast, + ), + ), ); $theme-warn: ( @@ -66,8 +74,8 @@ $theme-warn: ( contrast: ( default: var(--theme-warn-default-contrast), lighter: var(--theme-warn-lighter-contrast), - darker: var(--theme-warn-darker-contrast) - ) + darker: var(--theme-warn-darker-contrast), + ), ); $theme-warn-2: ( @@ -77,8 +85,8 @@ $theme-warn-2: ( contrast: ( default: var(--theme-warn-2-default-contrast), lighter: var(--theme-warn-2-lighter-contrast), - darker: var(--theme-warn-2-darker-contrast) - ) + darker: var(--theme-warn-2-darker-contrast), + ), ); $theme-header-1: ( @@ -88,8 +96,8 @@ $theme-header-1: ( contrast: ( default: var(--theme-header-1-default-contrast), lighter: var(--theme-header-1-lighter-contrast), - darker: var(--theme-header-1-darker-contrast) - ) + darker: var(--theme-header-1-darker-contrast), + ), ); $theme-header-2: ( @@ -99,8 +107,8 @@ $theme-header-2: ( contrast: ( default: var(--theme-header-2-default-contrast), lighter: var(--theme-header-2-lighter-contrast), - darker: var(--theme-header-2-darker-contrast) - ) + darker: var(--theme-header-2-darker-contrast), + ), ); $theme-header-3: ( @@ -110,8 +118,8 @@ $theme-header-3: ( contrast: ( default: var(--theme-header-3-default-contrast), lighter: var(--theme-header-3-lighter-contrast), - darker: var(--theme-header-3-darker-contrast) - ) + darker: var(--theme-header-3-darker-contrast), + ), ); $theme-header-4: ( @@ -121,8 +129,8 @@ $theme-header-4: ( contrast: ( default: var(--theme-header-4-default-contrast), lighter: var(--theme-header-4-lighter-contrast), - darker: var(--theme-header-4-darker-contrast) - ) + darker: var(--theme-header-4-darker-contrast), + ), ); $theme-hover: ( @@ -132,8 +140,8 @@ $theme-hover: ( contrast: ( default: var(--theme-hover-default-contrast), lighter: var(--theme-hover-lighter-contrast), - darker: var(--theme-hover-darker-contrast) - ) + darker: var(--theme-hover-darker-contrast), + ), ); $primary: mat.define-palette($theme-primary, "default", "lighter", "darker"); @@ -213,8 +221,8 @@ $theme: custom-light-theme( @include user-settings.theme($theme); /* You can add global styles to this file, and also import other style files */ -@import 'assets/styles/titillium-web.scss'; -@import 'assets/styles/material-icons.scss'; +@import "assets/styles/titillium-web.scss"; +@import "assets/styles/material-icons.scss"; html, body { @@ -238,8 +246,16 @@ a:hover { .snackbar-success { background-color: mat.get-color-from-palette($primary, "default"); + + button { + color: white; + } } .snackbar-error { background-color: mat.get-color-from-palette($warn, "default"); + + button { + color: white; + } }