diff --git a/angular.json b/angular.json index 9cd3b9c..4dc0255 100644 --- a/angular.json +++ b/angular.json @@ -23,10 +23,6 @@ "projects/sonar/src/assets" ], "styles": [ - "node_modules/primeng/resources/themes/bootstrap4-light-blue/theme.css", - "node_modules/primeng/resources/primeng.min.css", - "node_modules/primeicons/primeicons.css", - "node_modules/primeflex/primeflex.min.css", "projects/sonar/src/styles.scss" ], "scripts": [], @@ -47,13 +43,13 @@ "with": "projects/sonar/src/environments/environment.prod.ts" } ], - "optimization": true, "outputHashing": "none", "sourceMap": false, "namedChunks": false, "extractLicenses": true, "vendorChunk": false, "buildOptimizer": true, + "optimization": true, "budgets": [ { "type": "initial", @@ -102,10 +98,6 @@ "projects/sonar/src/assets" ], "styles": [ - "node_modules/primeng/resources/themes/bootstrap4-light-blue/theme.css", - "node_modules/primeng/resources/primeng.min.css", - "node_modules/primeicons/primeicons.css", - "node_modules/primeflex/primeflex.min.css", "projects/sonar/src/styles.scss" ], "scripts": [] diff --git a/package-lock.json b/package-lock.json index 7e055f1..d0964a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,10 +19,10 @@ "@angular/platform-browser-dynamic": "^17.3.10", "@angular/router": "^17.3.10", "@biesbjerg/ngx-translate-extract-marker": "^1.0.0", - "@ngx-formly/core": "^6.3.3", - "@ngx-formly/primeng": "^6.3.3", + "@ngx-formly/core": "6.3.2", + "@ngx-formly/primeng": "6.3.2", "@ngx-translate/core": "^15.0.0", - "@rero/ng-core": "^17.1.0", + "@rero/ng-core": "^17.2.1", "@types/marked": "^4.0.0", "@vpoppy/ngx-translate-extract": "^9.0.0", "bootstrap": "^4.6.2", @@ -3765,9 +3765,9 @@ } }, "node_modules/@ngx-formly/core": { - "version": "6.3.3", - "resolved": "https://registry.npmjs.org/@ngx-formly/core/-/core-6.3.3.tgz", - "integrity": "sha512-Dlbxzxrq5J9oNghwJnljtt/rvIeyfqwPQcCkLfsIc04yyztHl8M5cfkQtqqj2VjyEOqQb/XGjt0eQhAXz3xnNg==", + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/@ngx-formly/core/-/core-6.3.2.tgz", + "integrity": "sha512-rPnPDkZp+ns6FxpFBGOJLw3p2JYbIZlM+azNlsmDly4E/WlzX4YOPJNh87nNBINH4OqU99yLURkJFIcGGa9Qcg==", "dependencies": { "tslib": "^2.0.0" }, @@ -3777,14 +3777,14 @@ } }, "node_modules/@ngx-formly/primeng": { - "version": "6.3.3", - "resolved": "https://registry.npmjs.org/@ngx-formly/primeng/-/primeng-6.3.3.tgz", - "integrity": "sha512-f30KsmzX3OorruRU9p+E9UpDWiLLw65EIvMXWtJ3SIY9HLxT6nYje9YD5Fa0BEu0o1S6zMxQQzNla3F0A30mtw==", + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/@ngx-formly/primeng/-/primeng-6.3.2.tgz", + "integrity": "sha512-F5bo60eq/UYOASZa5fVFRmgL2p+i8y8gKr6+MGZUd+Mnugf/OXBpaK4PgYkajD99Tt35U3N6vwS/31ALStL+QQ==", "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { - "@ngx-formly/core": "6.3.3", + "@ngx-formly/core": "6.3.2", "primeng": ">=13.0.0" } }, @@ -4295,9 +4295,9 @@ } }, "node_modules/@rero/ng-core": { - "version": "17.2.0", - "resolved": "https://registry.npmjs.org/@rero/ng-core/-/ng-core-17.2.0.tgz", - "integrity": "sha512-XL+DsSr7aYAuuEFws5iF1spop41JGcUD8O2EJNqxQnI00Rc9A/Dw1GhbT4VSQhTjYsKD4jaycCe+C3I07D/9dQ==", + "version": "17.2.1", + "resolved": "https://registry.npmjs.org/@rero/ng-core/-/ng-core-17.2.1.tgz", + "integrity": "sha512-LYPFgIGgCgGvLKEhlgw+7HAAP2RHKgJ6udNWctQQzxE7T6sOXtf8S7AsVJGfpC1xmU32t12fd5J8QzwhJAnpjg==", "dependencies": { "tslib": "^2.3.0" }, @@ -4305,8 +4305,8 @@ "@angular/common": "^17.1.0", "@angular/core": "^17.1.0", "@biesbjerg/ngx-translate-extract-marker": "^1.0.0", - "@ngx-formly/core": "^6.2.2", - "@ngx-formly/primeng": "^6.2.2", + "@ngx-formly/core": "6.3.2", + "@ngx-formly/primeng": "6.3.2", "@ngx-translate/core": "^15.0.0", "@types/marked": "^4.0.8", "crypto-js": "^4.2.0", @@ -12392,16 +12392,16 @@ "integrity": "sha512-jK3Et9UzwzTsd6tzl2RmwrVY/b8raJ3QZLzoDACj+oTJ0oX7L9Hy+XnVwgo4QVKlKpnP/Ur13SXV/pVh4LzaDw==" }, "node_modules/primeng": { - "version": "17.17.0", - "resolved": "https://registry.npmjs.org/primeng/-/primeng-17.17.0.tgz", - "integrity": "sha512-+lIfG2nVve5GJQXGBDi2YeVabg6E9RmG67LDw9Ol8XvMWuHwJXQAGfO+AKPhPPzFSdb1j2v44uJemuNcJLXUiw==", + "version": "17.18.1", + "resolved": "https://registry.npmjs.org/primeng/-/primeng-17.18.1.tgz", + "integrity": "sha512-pMuXOgLQw5Xz0w9d3YTp2DAlYR8svK1Jz5gSWAhk6AyH7u7akwL1JX96RXVzLS8v2YeLUCvkMM+QROOvR3yKug==", "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { - "@angular/common": "^17.0.0", - "@angular/core": "^17.0.0", - "@angular/forms": "^17.0.0", + "@angular/common": "^17.0.0 || ^18.0.0", + "@angular/core": "^17.0.0 || ^18.0.0", + "@angular/forms": "^17.0.0 || ^18.0.0", "rxjs": "^6.0.0 || ^7.8.1", "zone.js": "~0.14.0" } diff --git a/package.json b/package.json index 2150171..ea8c927 100644 --- a/package.json +++ b/package.json @@ -42,10 +42,10 @@ "@angular/platform-browser-dynamic": "^17.3.10", "@angular/router": "^17.3.10", "@biesbjerg/ngx-translate-extract-marker": "^1.0.0", - "@ngx-formly/core": "^6.3.3", - "@ngx-formly/primeng": "^6.3.3", + "@ngx-formly/core": "6.3.2", + "@ngx-formly/primeng": "6.3.2", "@ngx-translate/core": "^15.0.0", - "@rero/ng-core": "^17.1.0", + "@rero/ng-core": "^17.2.1", "@types/marked": "^4.0.0", "@vpoppy/ngx-translate-extract": "^9.0.0", "bootstrap": "^4.6.2", diff --git a/projects/sonar/src/app/app-config.service.ts b/projects/sonar/src/app/app-config.service.ts index 18bca4c..87e80e5 100644 --- a/projects/sonar/src/app/app-config.service.ts +++ b/projects/sonar/src/app/app-config.service.ts @@ -28,6 +28,9 @@ export class AppConfigService extends CoreConfigService { globalviewName: string; + // maximum upload file size + maxFileSize = 500 * 1024 * 1024; + // Languages map. languagesMap = [ { diff --git a/projects/sonar/src/app/app.module.ts b/projects/sonar/src/app/app.module.ts index a53514d..3eedb5b 100644 --- a/projects/sonar/src/app/app.module.ts +++ b/projects/sonar/src/app/app.module.ts @@ -15,9 +15,9 @@ * along with this program. If not, see . */ import { DatePipe } from '@angular/common'; -import { HttpClient, HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http'; +import { HTTP_INTERCEPTORS, HttpClient, HttpClientModule } from '@angular/common/http'; import { APP_INITIALIZER, CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; -import { ReactiveFormsModule } from '@angular/forms'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { BrowserModule } from '@angular/platform-browser'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { FormlyFieldConfig } from '@ngx-formly/core'; @@ -30,6 +30,13 @@ import { TabsModule } from 'ngx-bootstrap/tabs'; import { TooltipModule } from 'ngx-bootstrap/tooltip'; import { NgxDropzoneModule } from 'ngx-dropzone'; import { ToastrModule } from 'ngx-toastr'; +import { DividerModule } from 'primeng/divider'; +import { DropdownModule } from 'primeng/dropdown'; +import { FileUploadModule } from 'primeng/fileupload'; +import { InputTextModule } from 'primeng/inputtext'; +import { OrderListModule } from 'primeng/orderlist'; +import { PanelModule } from 'primeng/panel'; +import { AdminComponent } from './_layout/admin/admin.component'; import { AppConfigService } from './app-config.service'; import { AppInitializerService } from './app-initializer.service'; import { AppRoutingModule } from './app-routing.module'; @@ -57,6 +64,8 @@ import { DetailComponent as DocumentDetailComponent } from './record/document/de import { DocumentComponent } from './record/document/document.component'; import { FileComponent } from './record/document/file/file.component'; import { PublicationPipe } from './record/document/publication.pipe'; +import { FileItemComponent } from './record/files/file-item/file-item.component'; +import { UploadFilesComponent } from './record/files/upload-files/upload-files.component'; import { DetailComponent as HepvsProjectDetailComponent } from './record/hepvs/project/detail/detail.component'; import { IdentifierComponent } from './record/identifier/identifier.component'; import { DetailComponent as OrganisationDetailComponent } from './record/organisation/detail/detail.component'; @@ -69,7 +78,6 @@ import { DetailComponent as UserDetailComponent } from './record/user/detail/det import { UserComponent } from './record/user/user.component'; import { ValidationComponent } from './record/validation/validation.component'; import { UserService } from './user.service'; -import { AdminComponent } from './_layout/admin/admin.component'; export function appInitializerFactory(appInitializerService: AppInitializerService): () => Promise { return () => appInitializerService.initialize().toPromise(); @@ -114,7 +122,9 @@ export function minElementError(err: any, field: FormlyFieldConfig) { SubdivisionDetailComponent, ContributorsPipe, ContributionsComponent, - ContributionComponent + ContributionComponent, + UploadFilesComponent, + FileItemComponent ], imports: [ BrowserModule, @@ -134,10 +144,17 @@ export function minElementError(err: any, field: FormlyFieldConfig) { defaultLanguage: 'en' }), ReactiveFormsModule, + FormsModule, BrowserAnimationsModule, ToastrModule.forRoot(), NgxDropzoneModule, - RecordModule + RecordModule, + InputTextModule, + FileUploadModule, + OrderListModule, + DropdownModule, + PanelModule, + DividerModule ], providers: [ { diff --git a/projects/sonar/src/app/deposit/upload/upload.component.html b/projects/sonar/src/app/deposit/upload/upload.component.html index ff7a4a3..4461c17 100644 --- a/projects/sonar/src/app/deposit/upload/upload.component.html +++ b/projects/sonar/src/app/deposit/upload/upload.component.html @@ -1,18 +1,18 @@ Statistics - - - - - - - - + diff --git a/projects/sonar/src/app/record/files/file-item/file-item.component.html b/projects/sonar/src/app/record/files/file-item/file-item.component.html new file mode 100644 index 0000000..9ac5040 --- /dev/null +++ b/projects/sonar/src/app/record/files/file-item/file-item.component.html @@ -0,0 +1,106 @@ + + + + + + + + + + + +
+
+
+ +
+ + +
+ + +
+
+ +
+ + @if (file().versions) { +
Versions
+
    + @for (version of file()?.versions; track version) { +
  • + +
  • + } +
+ } @else { + No previous version + } +
+
+
+ + + @if (file?.links?.self) { + {{ name }} + } + diff --git a/projects/sonar/src/app/record/files/file-item/file-item.component.ts b/projects/sonar/src/app/record/files/file-item/file-item.component.ts new file mode 100644 index 0000000..838732f --- /dev/null +++ b/projects/sonar/src/app/record/files/file-item/file-item.component.ts @@ -0,0 +1,141 @@ +/* + * SONAR User Interface + * Copyright (C) 2024 RERO + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { + Component, + computed, + effect, + inject, + input, + output, +} from '@angular/core'; +import { FormGroup } from '@angular/forms'; +import { FormlyFormOptions } from '@ngx-formly/core'; +import { FormlyJsonschema } from '@ngx-formly/core/json-schema'; +import { + JSONSchemaService, + processJsonSchema, + resolve$ref, +} from '@rero/ng-core'; +import { AppConfigService } from '../../../app-config.service'; +@Component({ + selector: 'sonar-file-item', + templateUrl: './file-item.component.html' +}) +export class FileItemComponent { + // file to display + file = input.required(); + // editor JSONSchema + schema = input.required(); + // event when a file should be deleted + delete = output(); + // event when the file metadata should be updated + update = output(); + // event when a new version of the file should be saved + upload = output(); + + // maximum upload file size + maxFileSize: number; + + // formly jsonschema service + formlyJSONSchema = inject(FormlyJsonschema); + // ng-core jsonschema service + jsonschemaService = inject(JSONSchemaService); + // application configuration service + appConfigService = inject(AppConfigService); + + // the formly form + form: FormGroup = new FormGroup({}); + // editor value + model: any = {}; + // editor options + options: FormlyFormOptions = {}; + // formly editor fields + fields = computed(() => this.createForm(this.schema())); + + /** + * constructor + */ + constructor() { + this.maxFileSize = this.appConfigService.maxFileSize; + effect(() => { + // set the form model from the file content + this.model = this.file().metadata; + }); + } + + /** + * Get the download URL for a given file + * + * @param file to generate the URL + * @returns the URL as string + */ + downloadURL(file): string { + const urlObj = new URL(file.links.self); + let baseUrl = urlObj.pathname; + return `${baseUrl}/${file.key}?download&versionId=${file.version_id}`; + } + + /** + * Delete a given file. + * + * @param file to delete + */ + deleteFile(file) { + this.delete.emit(file); + } + + /** + * Update the file metadata. + */ + save() { + this.update.emit(this.model); + } + + /** + * Upload a new version of a file + * + * @param event + */ + uploadHandler(event) { + this.upload.emit({ file: this.file(), fileUpload: event.files[0] }); + } + + /** + * Create the form editor. + * + * @param schema editor JSONSchema + * @returns the formly fields. + */ + private createForm(schema: any) { + schema = processJsonSchema(resolve$ref(schema, schema.properties)); + // form configuration + const editorConfig = { + longMode: false, + }; + return [ + this.formlyJSONSchema.toFieldConfig(schema, { + map: (field: any, fieldSchema: any) => { + field = this.jsonschemaService.processField(field, fieldSchema); + field.props.editorConfig = editorConfig; + field.props.getRoot = () => this.fields()[0]; + return field; + }, + }), + ]; + } +} diff --git a/projects/sonar/src/app/record/files/upload-files/upload-files.component.html b/projects/sonar/src/app/record/files/upload-files/upload-files.component.html new file mode 100644 index 0000000..1f68438 --- /dev/null +++ b/projects/sonar/src/app/record/files/upload-files/upload-files.component.html @@ -0,0 +1,102 @@ + + + +

+ {{ 'Loading...' | translate }} + @if (fileUpload?.files?.length > 0) { + ({{ nUploadedFiles }} / {{ fileUpload.files.length }}) + } +

+
+ +@if (files != null) { +
+
+
Upload Files
+ + +
+
+ + By uploading a file, I declare that I am aware of the terms and + conditions of the copyright transfer agreement governing the + publication of the respective document, and that the deposit of its + full-text content in the current platform is compatible with those. + +
+
+ + + + + @if (reachMaxFileLimit) { + + } @else { + Drag and drop files. + } + +
+
+ + + + + + +
+} @else { + +} diff --git a/projects/sonar/src/app/record/files/upload-files/upload-files.component.scss b/projects/sonar/src/app/record/files/upload-files/upload-files.component.scss new file mode 100644 index 0000000..e2b00c9 --- /dev/null +++ b/projects/sonar/src/app/record/files/upload-files/upload-files.component.scss @@ -0,0 +1,35 @@ +/* + * SONAR User Interface + * Copyright (C) 2024 RERO + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + + +@import 'node_modules/bootstrap/scss/_functions'; +@import 'node_modules/bootstrap/scss/_variables'; +:host ::ng-deep { + .p-orderlist .p-orderlist-list .p-orderlist-item.p-highlight { + background-color: rgba($blue, 0.5); + } + .p-orderlist .p-orderlist-list .p-orderlist-item.p-highlight.p-focus { + background-color: rgba($blue, 0.75); + } + .p-orderlist-item, .p-orderlist-list { + overflow: visible; + } + .p-tabview-title { + font-weight: normal; + font-size: 0.9rem; + } +} diff --git a/projects/sonar/src/app/record/files/upload-files/upload-files.component.ts b/projects/sonar/src/app/record/files/upload-files/upload-files.component.ts new file mode 100644 index 0000000..3567d79 --- /dev/null +++ b/projects/sonar/src/app/record/files/upload-files/upload-files.component.ts @@ -0,0 +1,460 @@ +/* + * SONAR User Interface + * Copyright (C) 2024 RERO + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { HttpClient } from '@angular/common/http'; +import { Component, ViewChild, effect, inject, input } from '@angular/core'; +import { toObservable, toSignal } from '@angular/core/rxjs-interop'; +import { TranslateService } from '@ngx-translate/core'; +import { DialogService, RecordService } from '@rero/ng-core'; +import { NgxSpinnerService } from 'ngx-spinner'; +import { ToastrService } from 'ngx-toastr'; +import { FileUpload } from 'primeng/fileupload'; +import { OrderList } from 'primeng/orderlist'; +import { + Observable, + catchError, + combineLatest, + concatMap, + from, + map, + of, + switchMap, + tap, + toArray, +} from 'rxjs'; +import { AppConfigService } from '../../../app-config.service'; + +@Component({ + selector: 'sonar-upload-files', + templateUrl: './upload-files.component.html', + styleUrl: './upload-files.component.scss', +}) +export class UploadFilesComponent { + // resource pid + pid = input.required(); + // record type such as documents + recordType = input.required(); + + // initial record from pid and recordType + initialRecord = toSignal( + combineLatest(toObservable(this.pid), toObservable(this.recordType)).pipe( + switchMap(([pid, recordType]) => + pid && recordType + ? this.fileService + .get(`/api/${recordType}/${pid}`) + .pipe(map((rec: any) => (rec = rec.metadata))) + : of(null) + ) + ) + ); + // current record + record: any = {}; + + // initial files form record + initialFiles = toSignal( + toObservable(this.initialRecord).pipe( + switchMap((record) => (record ? this.getFiles(record) : of([]))) + ) + ); + + // current list of files + files: any = []; + + // record JSONSchema for the editor + fileSchema = toSignal( + toObservable(this.recordType).pipe( + switchMap((recordType) => + recordType + ? this.recordService + .getSchemaForm(recordType) + .pipe(map((res) => res.schema.properties._files.items)) + : of([]) + ) + ) + ); + + // the maximum number of files by file record + maxFiles = 500; + + // the primeng file upload component + @ViewChild('fileUpload') + fileUpload: FileUpload; + + //------------- Services ------------- + // file service + fileService = inject(HttpClient); + // record service + recordService = inject(RecordService); + // translate service + translateService = inject(TranslateService); + // toaster service + toastrService = inject(ToastrService); + // dialog service + dialogService = inject(DialogService); + // spinner service + spinner = inject(NgxSpinnerService); + // number of uploaded files + nUploadedFiles = 0; + // application configuration service + appConfigService = inject(AppConfigService); + + // maximum upload file size + maxFileSize: number; + + // primeng order list for search query reset + @ViewChild('orderList') orderList: OrderList; + + /** + * constructor + */ + constructor() { + this.maxFileSize = this.appConfigService.maxFileSize; + // update the current record and files when the inputs change + effect(() => { + this.record = this.initialRecord(); + this.files = this.initialFiles(); + }); + } + + /** + * Update the file metadata. + * + * @param file the file object to update the label. + * @param metadata the new metadata. + */ + update(file, metadata) { + // remove useless spaces + metadata.label = metadata.label.trim(); + + let indexToUpdate = this.record._files.findIndex( + (item) => item.key === file.key + ); + if (indexToUpdate >= 0) { + this.fileService + .put(`/api/documents/${this.pid()}`, this.record) + .subscribe((record: any) => { + // update the current record + this.record = record.metadata; + file.metadata = this._getFileInRecord(file.key); + file.label = file.metadata.label; + this.toastrService.success( + this.translateService.instant( + 'Metadata have been saved successfully.' + ) + ); + }); + } + } + + // True if the maxiumum number of files is reached. + get reachMaxFileLimit(): boolean { + return this.files.length >= this.maxFiles; + } + + /** + * Upload a new file. + * + * @param event the standard event. + * @param _ unused. + */ + uploadHandler(event, _) { + if (event.files.length > 0) { + this.spinner.show('file-upload'); + let obs: Observable = this.generateCreateRequests(event); + obs + .pipe( + catchError((e: any) => { + let msg = this.translateService.instant('Server error'); + if (e.error.message) { + msg = `${msg}: ${e.error.message}`; + } + this.toastrService.error(msg); + return of([]); + }), + tap(() => { + this.getRecord(); + this.resetFilter(); + this.fileUpload.clear(); + this.toastrService.success( + this.translateService.instant('File uploaded successfully.') + ); + this.nUploadedFiles = 0; + }) + ) + .subscribe(() => this.spinner.hide('file-upload')); + } + } + + /** + * Upload a new version of a given file. + * @param event - dict with the file and the fileUpload stream. + */ + uploadNewVersion(event) { + let file = event.file; + let fileUpload: File = event.fileUpload; + this.spinner.show('file-upload'); + this.fileService + .put(`/api/documents/${this.pid()}/files/${file.key}`, fileUpload) + .pipe( + catchError((e: any) => { + let msg = this.translateService.instant('Server error'); + if (e.error.message) { + msg = `${msg}: ${e.error.message}`; + } + this.toastrService.error(msg); + return of(null); + }), + map((file: any) => { + // update the record and the files + this.getRecord(); + }), + tap(() => { + this.resetFilter(); + this.toastrService.success( + this.translateService.instant('File uploaded successfully.') + ); + }) + ) + .subscribe(() => this.spinner.hide('file-upload')); + } + + /** + * Get the record and the files from the backend. + */ + getRecord() { + this.fileService + .get(`/api/${this.recordType()}/${this.pid()}`) + .pipe( + map((rec: any) => (rec = rec.metadata)), + tap((record) => (this.record = record)), + switchMap((record) => this.getFiles(record)), + tap((files) => (this.files = files)) + ) + .subscribe(); + } + + /** + * Generate the sequential http requests. + * + * @param event the standard event. + * @returns an observable of sequential http requests + */ + private generateCreateRequests(event): Observable { + return from(event.files).pipe( + concatMap((f: any) => + this.fileService.put(`/api/documents/${this.pid()}/files/${f.name}`, f) + ), + map((file: any) => { + this.nUploadedFiles += 1; + this.files = this.processFiles([ + { label: file.key, ...file }, + ...this.files, + ]); + }), + // like a forkJoin + toArray() + ); + } + + /** + * Filter the uploaded files. + * + * @param event the standard event. + * @param _ unused. + */ + onSelect(event, _) { + const existingFileNames = []; + for (let i = 0; i < event.files.length; i++) { + const fileName = event.files[i].name; + if (this.files.some((v) => v.key == fileName)) { + existingFileNames.push(fileName); + } else { + event.files[i].label = fileName; + } + } + if (existingFileNames.length > 0) { + this.fileUpload.msgs.push({ + severity: 'error', + summary: 'This filename already exists.', + detail: `${existingFileNames.join(', ')}`, + }); + this.fileUpload.files = this.fileUpload.files.filter( + (v) => !existingFileNames.some((n) => n == v.name) + ); + } + const numberOfMaxUploadedFiles = this.maxFiles - this.files.length; + if (numberOfMaxUploadedFiles < this.fileUpload.files.length) { + this.fileUpload.files = this.fileUpload.files.slice( + 0, + numberOfMaxUploadedFiles + ); + } + } + + /** + * Removes a given file. + * + * @param file - the file to delete. + */ + deleteFile(file: any) { + // dialog confirmation + this.dialogService + .show({ + ignoreBackdropClick: true, + initialState: { + title: this.translateService.instant('Confirmation'), + body: this.translateService.instant( + 'Do you really want to remove this file and all versions?' + ), + confirmButton: true, + confirmTitleButton: this.translateService.instant('OK'), + cancelTitleButton: this.translateService.instant('Cancel'), + }, + }) + .pipe( + switchMap((confirm: boolean) => { + if (confirm === true) { + // remove the file + return this.fileService + .delete(`/api/documents/${this.pid()}/files/${file.key}`) + .pipe( + map((res) => { + this.files = this.files.filter((f) => f.key !== file.key); + this.resetFilter(); + this.toastrService.success( + this.translateService.instant('File removed successfully.') + ); + return true; + }) + ); + } + return of(false); + }) + ) + .subscribe(); + } + + /** + * Reset the query to filter the file list. + */ + resetFilter() { + this.orderList.resetFilter(); + } + + /** + * Observable for loading record and files. + * + * @returns Observable emitting files + */ + private getFiles(record): Observable { + return this.fileService + .get(`/api/documents/${record.pid}/files?versions`) + .pipe( + map((record: any) => { + if (record?.contents) { + return record.contents; + } + return of([]); + }), + map((files) => { + return files.map((item: any) => { + item.metadata = this._getFileInRecord(item.key); + if (item?.label == null) { + item.label = item?.metadata?.label + ? item.metadata.label + : item.key; + } + return item; + }); + }), + map((files) => { + return this.processFiles(files); + }), + catchError(() => { + return of([]); + }) + ); + } + + /** + * Process the list of files from the backend. + * @param files the files to process + * @returns the processed files + */ + private processFiles(files) { + // get old versions + let versions = {}; + files.map((file) => { + if (file?.metadata?.type === 'file' && file.is_head === false) { + if (!(file.key in versions)) versions[file.key] = []; + versions[file.key].push(file); + } + }); + // get head files only + let headFiles = []; + files.map((file) => { + if (file?.metadata?.type === 'file' && file.is_head) { + // add versions if exists + if (versions[file.key]) { + let fileVersions = versions[file.key]; + fileVersions.sort((a, b) => a.metadata.created - b.metadata.created); + file.versions = fileVersions; + } + headFiles.push(file); + } + }); + headFiles.sort((a, b) => a.metadata.order - b.metadata.order); + return headFiles; + } + + /** + * Reorder the files. + */ + reorder() { + this.files.map((file, index) => { + let recordFile = this._getFileInRecord(file.key); + recordFile.order = index + 1; + }); + this.fileService + .put(`/api/documents/${this.pid()}`, this.record) + .subscribe((record: any) => { + this.record = record.metadata; + this.files.map((file) => { + file.metadata = this._getFileInRecord(file.key); + }); + }); + } + + /** + * Get files metadata corresponding to file key, stored in record. + * + * @param fileKey File key. + * @returns Metadata object for the file. + */ + private _getFileInRecord(fileKey: string): any { + if (!this.record._files) { + return null; + } + + // Get metadata stored in record. + const metadata = this.record._files.filter( + (item: any) => fileKey === item.key + ); + + return metadata.length > 0 ? metadata[0] : null; + } +} diff --git a/projects/sonar/src/styles.scss b/projects/sonar/src/styles.scss index 0fea380..f7b5358 100644 --- a/projects/sonar/src/styles.scss +++ b/projects/sonar/src/styles.scss @@ -16,83 +16,104 @@ */ // Font awesome -$fa-font-path: "~font-awesome/fonts"; +$fa-font-path: '~font-awesome/fonts'; // Bootstrap -$font-family-base: "Roboto", sans-serif; +$font-family-base: 'Roboto', sans-serif; $primary: #205078 !default; $secondary: rgb(246, 130, 17) !default; -@import url("https://fonts.googleapis.com/css?family=Roboto:300,700|Roboto+Condensed:300,700"); -@import "font-awesome/scss/font-awesome"; -@import "bootstrap/scss/bootstrap"; -@import "ngx-toastr/toastr-bs4-alert"; -// TODO: remove `node_modules` when this will be fixed in ngx-bootstrap -@import "node_modules/ngx-bootstrap/datepicker/bs-datepicker"; -@import "easymde/dist/easymde.min"; - -pre { - white-space: pre-wrap; -} - -.string { - color: $success; -} - -.number { - color: $secondary; -} - -.boolean { - color: $primary; -} - -.null { - color: $warning; -} +@import url('https://fonts.googleapis.com/css?family=Roboto:300,700|Roboto+Condensed:300,700'); +@import 'font-awesome/scss/font-awesome'; +@layer bootstrap { + @import 'bootstrap/scss/bootstrap'; + @import 'ngx-toastr/toastr-bs4-alert'; + @import 'node_modules/ngx-bootstrap/datepicker/bs-datepicker'; + // TODO: remove `node_modules` when this will be fixed in ngx-bootstrap + @import 'easymde/dist/easymde.min'; + body, + html { + line-height: normal; + font-size: 0.9rem; + } + pre { + white-space: pre-wrap; + } -.key { - color: $danger; -} + .string { + color: $success; + } -.nav-item > a { - cursor: pointer; -} + .number { + color: $secondary; + } -.text-small { - font-size: 14px; -} + .boolean { + color: $primary; + } -.card.wrapper { - border: none; - @extend .py-1; + .null { + color: $warning; + } - .card-header { - border: none; - background: none; - padding-left: 0; + .key { + color: $danger; } - .card-body { - padding-left: 0; + .nav-item > a { + cursor: pointer; } - .border-left, .object { - padding-left: map_get($spacers, 3) !important; - margin-bottom: map_get($spacers, 3) !important; - border-left: 3px solid #dee2e6 !important; + .text-small { + font-size: 14px; } - .array-item { - margin-left: 0 !important; + .card.wrapper { + border: none; + @extend .py-1; + + .card-header { + border: none; + background: none; + padding-left: 0; + } + + .card-body { + padding-left: 0; + } + .border-left, .object { - padding-left: 0 !important; + padding-left: map_get($spacers, 3) !important; + margin-bottom: map_get($spacers, 3) !important; + border-left: 3px solid #dee2e6 !important; } + + .array-item { + margin-left: 0 !important; + + .object { + padding-left: 0 !important; + } + } + } + + // Markdown editor + .CodeMirror, + .CodeMirror-scroll { + min-height: 120px; } } -// Markdown editor -.CodeMirror, .CodeMirror-scroll { - min-height: 120px; +@import 'primeng/resources/themes/bootstrap4-light-blue/theme'; +@import 'primeng/resources/primeng'; +@layer primengother { + @import 'primeicons/primeicons'; + @import 'primeflex/primeflex'; +} + +@layer custom { + .p-inputtext { + font-size: 0.9rem; + } }