Skip to content

Commit

Permalink
customer/uk-it-6 (#429)
Browse files Browse the repository at this point in the history
* ufal/fe-oversized-file-upload-message (#424)

* If the file exceeds the upload max file size the uploading will be stopped before starting and the user will see proper error message.

* Fixed unit tests - added configurationDataService

* ufal/fe-item-view-license-box (#427)

* Do not show licenses if the Item doesn't have any file.

---------

Co-authored-by: Jozef Misutka <332350+vidiecan@users.noreply.github.com>
  • Loading branch information
milanmajchrak and vidiecan authored Dec 20, 2023
1 parent 6b6219e commit 691ca71
Show file tree
Hide file tree
Showing 9 changed files with 147 additions and 32 deletions.
44 changes: 23 additions & 21 deletions src/app/item-page/simple/item-page.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,29 +10,31 @@
<ds-view-tracker [object]="item"></ds-view-tracker>
<ds-listable-object-component-loader *ngIf="!item.isWithdrawn || (isAdmin$|async)" [object]="item" [viewMode]="viewMode"></ds-listable-object-component-loader>
<ds-item-versions class="mt-2" [item]="item" [displayActions]="false"></ds-item-versions>
<ds-clarin-license-info class="mt-3 d-block" [item]="item"></ds-clarin-license-info>
<h6><i class="fa fa-paperclip">&nbsp;</i>{{'item.page.files.head' | translate}}</h6>
<div class="pb-3">
<span class="pr-1">
<a class="btn btn-download" (click)="setCommandline()" style="text-decoration: none"
*ngIf="canShowCurlDownload">
<i class="fa fa-download fa-3x" style="display: block">&nbsp;</i>
{{'item.page.download.button.command.line' | translate}}
</a>
</span>
<div id="command-div" *ngIf="isCommandLineVisible">
<button class="repo-copy-btn pull-right" data-clipboard-target="#command-div"></button>
<pre style="background-color: #d9edf7; color: #3a87ad">{{ command }}</pre>
<div *ngIf="(hasFiles | async) === true">
<ds-clarin-license-info class="mt-3 d-block" [item]="item"></ds-clarin-license-info>
<h6><i class="fa fa-paperclip">&nbsp;</i>{{'item.page.files.head' | translate}}</h6>
<div class="pb-3">
<span class="pr-1">
<a class="btn btn-download" (click)="setCommandline()" style="text-decoration: none"
*ngIf="canShowCurlDownload">
<i class="fa fa-download fa-3x" style="display: block">&nbsp;</i>
{{'item.page.download.button.command.line' | translate}}
</a>
</span>
<div id="command-div" *ngIf="isCommandLineVisible">
<button class="repo-copy-btn pull-right" data-clipboard-target="#command-div"></button>
<pre style="background-color: #d9edf7; color: #3a87ad">{{ command }}</pre>
</div>
<span>
<a *ngIf="canDownloadAllFiles" class="btn btn-download" id="download-all-button" (click)="downloadFiles()"
style="visibility: visible">
<i style="display: block" class="fa fa-download fa-3x">&nbsp;</i>
{{'item.page.download.button.all.files.zip' | translate}} ({{ totalFileSizes }})
</a>
</span>
</div>
<span>
<a *ngIf="canDownloadAllFiles" class="btn btn-download" id="download-all-button" (click)="downloadFiles()"
style="visibility: visible">
<i style="display: block" class="fa fa-download fa-3x">&nbsp;</i>
{{'item.page.download.button.all.files.zip' | translate}} ({{ totalFileSizes }})
</a>
</span>
<ds-preview-section [item]="item"></ds-preview-section>
</div>
<ds-preview-section [item]="item"></ds-preview-section>
</div>
</div>
<ds-error *ngIf="itemRD?.hasFailed" message="{{'error.item' | translate}}"></ds-error>
Expand Down
20 changes: 19 additions & 1 deletion src/app/item-page/simple/item-page.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,16 @@ import { MetadataBitstreamDataService } from 'src/app/core/data/metadata-bitstre
import { getMockTranslateService } from 'src/app/shared/mocks/translate.service.mock';
import { ConfigurationProperty } from '../../core/shared/configuration-property.model';
import { HALEndpointService } from '../../core/shared/hal-endpoint.service';
import { MetadataValue } from '../../core/shared/metadata.models';

const mockItem: Item = Object.assign(new Item(), {
bundles: createSuccessfulRemoteDataObject$(createPaginatedList([])),
metadata: [],
metadata: {
'local.has.files': [Object.assign(new MetadataValue(), {
value: 'yes',
language: undefined
})]
},
relationships: createRelationshipsObservable()
});

Expand Down Expand Up @@ -207,4 +213,16 @@ describe('ItemPageComponent', () => {
});
});

describe('when the item has the file', () => {
it('should display license and files section', waitForAsync(async () => {
comp.itemRD$ = createSuccessfulRemoteDataObject$(mockItem);
fixture.detectChanges();

void fixture.whenStable().then(() => {
const objectLoader = fixture.debugElement.query(By.css('ds-clarin-license-info'));
expect(objectLoader.nativeElement).toBeDefined();
});
}));
});

});
25 changes: 22 additions & 3 deletions src/app/item-page/simple/item-page.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { AuthorizationDataService } from '../../core/data/feature-authorization/
import { redirectOn4xx } from '../../core/shared/authorized.operators';
import { RegistryService } from 'src/app/core/registry/registry.service';
import { MetadataBitstream } from 'src/app/core/metadata/metadata-bitstream.model';
import { Observable} from 'rxjs';
import { BehaviorSubject, Observable } from 'rxjs';
import { HALEndpointService } from '../../core/shared/hal-endpoint.service';

/**
Expand Down Expand Up @@ -103,6 +103,11 @@ export class ItemPageComponent implements OnInit {

canShowCurlDownload = false;

/**
* True if the item has files, false otherwise.
*/
hasFiles: BehaviorSubject<boolean> = new BehaviorSubject(false);

constructor(
protected route: ActivatedRoute,
private router: Router,
Expand All @@ -127,7 +132,7 @@ export class ItemPageComponent implements OnInit {
map((item) => getItemPageRoute(item))
);

this.showTombstone();
this.processItem();

this.registryService
.getMetadataBitstream(this.itemHandle, 'ORIGINAL,TEXT,THUMBNAIL')
Expand All @@ -139,6 +144,14 @@ export class ItemPageComponent implements OnInit {
});
}

/**
* Check if the item has files and assign the result into the `hasFiles` variable.
* */
private checkIfItemHasFiles(item: Item) {
const hasFilesMetadata = item.metadata?.['local.has.files']?.[0]?.value;
this.hasFiles.next(hasFilesMetadata !== 'no');
}

sumFileSizes() {
const sizeUnits = {
B: 1,
Expand Down Expand Up @@ -167,7 +180,10 @@ export class ItemPageComponent implements OnInit {
this.totalFileSizes = totalBytes.toFixed(2) + ' ' + finalUnit;
}

showTombstone() {
/**
* Process the tombstone of the Item and check if it has files or not.
*/
processItem() {
// if the item is withdrawn
let isWithdrawn = false;
// metadata value from `dc.relation.isreplacedby`
Expand All @@ -181,6 +197,9 @@ export class ItemPageComponent implements OnInit {
this.itemHandle = item.handle;
isWithdrawn = item.isWithdrawn;
isReplaced = item.metadata['dc.relation.isreplacedby']?.[0]?.value;

// check if the item has files
this.checkIfItemHasFiles(item);
});

// do not show tombstone for non withdrawn items
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import { CookieServiceMock } from '../../shared/mocks/cookie.service.mock';
import { HttpXsrfTokenExtractorMock } from '../../shared/mocks/http-xsrf-token-extractor.mock';
import { getMockEntityTypeService } from './my-dspace-new-submission-dropdown/my-dspace-new-submission-dropdown.component.spec';
import { EntityTypeDataService } from '../../core/data/entity-type-data.service';
import { of } from 'rxjs';
import { ConfigurationDataService } from '../../core/data/configuration-data.service';

describe('MyDSpaceNewSubmissionComponent test', () => {

Expand All @@ -35,6 +37,10 @@ describe('MyDSpaceNewSubmissionComponent test', () => {
uploadAll: jasmine.createSpy('uploadAll').and.stub()
});

const configurationServiceSpy = jasmine.createSpyObj('configurationService', {
findByPropertyName: of({}),
});

beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [
Expand Down Expand Up @@ -64,6 +70,7 @@ describe('MyDSpaceNewSubmissionComponent test', () => {
{ provide: CookieService, useValue: new CookieServiceMock() },
{ provide: HostWindowService, useValue: new HostWindowServiceStub(800) },
{ provide: EntityTypeDataService, useValue: getMockEntityTypeService() },
{ provide: ConfigurationDataService, useValue: configurationServiceSpy },
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
Expand Down
7 changes: 7 additions & 0 deletions src/app/shared/upload/uploader/uploader.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,19 @@ import { HttpXsrfTokenExtractor } from '@angular/common/http';
import { CookieService } from '../../../core/services/cookie.service';
import { CookieServiceMock } from '../../mocks/cookie.service.mock';
import { HttpXsrfTokenExtractorMock } from '../../mocks/http-xsrf-token-extractor.mock';
import { of } from 'rxjs';
import { ConfigurationDataService } from '../../../core/data/configuration-data.service';

describe('Chips component', () => {

let testComp: TestComponent;
let testFixture: ComponentFixture<TestComponent>;
let html;

const configurationServiceSpy = jasmine.createSpyObj('configurationService', {
findByPropertyName: of({}),
});

// waitForAsync beforeEach
beforeEach(waitForAsync(() => {

Expand All @@ -40,6 +46,7 @@ describe('Chips component', () => {
DragService,
{ provide: HttpXsrfTokenExtractor, useValue: new HttpXsrfTokenExtractorMock('mock-token') },
{ provide: CookieService, useValue: new CookieServiceMock() },
{ provide: ConfigurationDataService, useValue: configurationServiceSpy },
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
});
Expand Down
64 changes: 60 additions & 4 deletions src/app/shared/upload/uploader/uploader.component.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, HostListener, Input, Output, ViewEncapsulation, } from '@angular/core';

import { of as observableOf } from 'rxjs';
import { FileUploader } from 'ng2-file-upload';
import { firstValueFrom, Observable, of as observableOf } from 'rxjs';
import { FileItem, FileUploader } from 'ng2-file-upload';
import uniqueId from 'lodash/uniqueId';
import { ScrollToService } from '@nicky-lenaers/ngx-scroll-to';

Expand All @@ -12,7 +12,14 @@ import { HttpXsrfTokenExtractor } from '@angular/common/http';
import { XSRF_COOKIE, XSRF_REQUEST_HEADER, XSRF_RESPONSE_HEADER } from '../../../core/xsrf/xsrf.interceptor';
import { CookieService } from '../../../core/services/cookie.service';
import { DragService } from '../../../core/drag.service';
import {ConfigurationDataService} from '../../../core/data/configuration-data.service';
import { map } from 'rxjs/operators';
import { getFirstCompletedRemoteData } from '../../../core/shared/operators';
import { RemoteData } from '../../../core/data/remote-data';
import { ConfigurationProperty } from '../../../core/shared/configuration-property.model';
import { TranslateService } from '@ngx-translate/core';

export const MAX_UPLOAD_FILE_SIZE_CFG_PROPERTY = 'spring.servlet.multipart.max-file-size';
@Component({
selector: 'ds-uploader',
templateUrl: 'uploader.component.html',
Expand Down Expand Up @@ -90,7 +97,9 @@ export class UploaderComponent {
private scrollToService: ScrollToService,
private dragService: DragService,
private tokenExtractor: HttpXsrfTokenExtractor,
private cookieService: CookieService
private cookieService: CookieService,
private configurationService: ConfigurationDataService,
private translate: TranslateService
) {
}

Expand Down Expand Up @@ -129,7 +138,20 @@ export class UploaderComponent {
if (isUndefined(this.onBeforeUpload)) {
this.onBeforeUpload = () => {return;};
}
this.uploader.onBeforeUploadItem = (item) => {
this.uploader.onBeforeUploadItem = async (item) => {
// Check if the file size is within the maximum upload size
const canUpload = await this.checkFileSizeLimit(item);
// If the file size is too large, emit an error and cancel all uploads
if (!canUpload) {
this.onUploadError.emit({
item: item,
response: this.translate.instant('submission.sections.upload.upload-failed.size-limit-exceeded'),
status: 400,
headers: {}
});
this.uploader.cancelAll();
return;
}
if (item.url !== this.uploader.options.url) {
item.url = this.uploader.options.url;
}
Expand Down Expand Up @@ -225,4 +247,38 @@ export class UploaderComponent {
this.cookieService.set(XSRF_COOKIE, token);
}

// Check if the file size is within the maximum upload size
private async checkFileSizeLimit(item: FileItem): Promise<boolean> {
const maxFileUploadSize = await firstValueFrom(this.getMaxFileUploadSizeFromCfg());
if (maxFileUploadSize) {
const maxSizeInGigabytes = parseInt(maxFileUploadSize?.[0], 10);
const maxSizeInBytes = this.gigabytesToBytes(maxSizeInGigabytes);
// If maxSizeInBytes is -1, it means the value in the config is invalid. The file won't be uploaded and the user
// will see error messages in the UI.
if (maxSizeInBytes === -1) {
return false;
}
return item?.file?.size <= maxSizeInBytes;
}
return false;
}

// Convert gigabytes to bytes
private gigabytesToBytes(gigabytes: number): number {
if (typeof gigabytes !== 'number' || isNaN(gigabytes) || !isFinite(gigabytes) || gigabytes < 0) {
return -1;
}
return gigabytes * Math.pow(2, 30); // 2^30 bytes in a gigabyte
}

// Get the maximum file upload size from the configuration
public getMaxFileUploadSizeFromCfg(): Observable<string[]> {
return this.configurationService.findByPropertyName(MAX_UPLOAD_FILE_SIZE_CFG_PROPERTY).pipe(
getFirstCompletedRemoteData(),
map((propertyRD: RemoteData<ConfigurationProperty>) => {
return propertyRD.hasSucceeded ? propertyRD.payload.values : [];
})
);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@
[onBeforeUpload]="onBeforeUpload"
[uploadFilesOptions]="uploadFilesOptions"
(onCompleteItem)="onCompleteItem($event)"
(onUploadError)="onUploadError()"></ds-uploader>
(onUploadError)="onUploadError($event)"></ds-uploader>
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,12 @@ export class SubmissionUploadFilesComponent implements OnChanges {
/**
* Show error notification on upload fails
*/
public onUploadError() {
this.notificationsService.error(null, this.translate.get('submission.sections.upload.upload-failed'));
public onUploadError(event: any) {
const errorMessageUploadLimit = this.translate.instant('submission.sections.upload.upload-failed.size-limit-exceeded');
const defaultErrorMessage = this.translate.instant('submission.sections.upload.upload-failed');
const errorMessage = event?.response === errorMessageUploadLimit ? errorMessageUploadLimit : defaultErrorMessage;

this.notificationsService.error(null, errorMessage);
}

/**
Expand Down
2 changes: 2 additions & 0 deletions src/assets/i18n/en.json5
Original file line number Diff line number Diff line change
Expand Up @@ -5081,6 +5081,8 @@

"submission.sections.upload.upload-successful": "Upload successful",

"submission.sections.upload.upload-failed.size-limit-exceeded": "File size exceeds the maximum upload size",

"submission.sections.accesses.form.discoverable-description": "When checked, this item will be discoverable in search/browse. When unchecked, the item will only be available via a direct link and will never appear in search/browse.",

"submission.sections.accesses.form.discoverable-label": "Discoverable",
Expand Down

0 comments on commit 691ca71

Please sign in to comment.