Merge pull request #2677 from dpalou/MOBILE-3651-2

MOBILE-3651 core: Implement attachments, files and local-file components
main
Dani Palou 2021-02-12 11:34:23 +01:00 committed by GitHub
commit 836a7bb812
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 545 additions and 0 deletions

View File

@ -0,0 +1,155 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, Input, OnInit } from '@angular/core';
import { FileEntry } from '@ionic-native/file/ngx';
import { CoreFileUploader, CoreFileUploaderTypeList } from '@features/fileuploader/services/fileuploader';
import { CoreSites } from '@services/sites';
import { CoreTextUtils } from '@services/utils/text';
import { CoreWSExternalFile } from '@services/ws';
import { Translate } from '@singletons';
import { CoreApp } from '@services/app';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreFileUploaderHelper } from '@features/fileuploader/services/fileuploader-helper';
/**
* Component to render attachments, allow adding more and delete the current ones.
*
* All the changes done will be applied to the "files" input array, no file will be uploaded. The component using this
* component should be the one uploading and moving the files.
*
* All the files added will be copied to the app temporary folder, so they should be deleted after uploading them
* or if the user cancels the action.
*
* <core-attachments [files]="files" [maxSize]="configs.maxsubmissionsizebytes" [maxSubmissions]="configs.maxfilesubmissions"
* [component]="component" [componentId]="assign.cmid" [acceptedTypes]="configs.filetypeslist" [allowOffline]="allowOffline">
* </core-attachments>
*/
@Component({
selector: 'core-attachments',
templateUrl: 'core-attachments.html',
})
export class CoreAttachmentsComponent implements OnInit {
@Input() files?: (CoreWSExternalFile | FileEntry)[]; // List of attachments. New attachments will be added to this array.
@Input() maxSize?: number; // Max size for attachments. -1 means unlimited, 0 means user max size, not defined means unknown.
@Input() maxSubmissions?: number; // Max number of attachments. -1 means unlimited, not defined means unknown limit.
@Input() component?: string; // Component the downloaded files will be linked to.
@Input() componentId?: string | number; // Component ID.
@Input() allowOffline?: boolean | string; // Whether to allow selecting files in offline.
@Input() acceptedTypes?: string; // List of supported filetypes. If undefined, all types supported.
@Input() required?: boolean; // Whether to display the required mark.
maxSizeReadable?: string;
maxSubmissionsReadable?: string;
unlimitedFiles?: boolean;
fileTypes?: CoreFileUploaderTypeList;
/**
* Component being initialized.
*/
ngOnInit(): void {
this.files = this.files || [];
this.maxSize = this.maxSize !== null ? Number(this.maxSize) : NaN;
if (this.maxSize === 0) {
const currentSite = CoreSites.instance.getCurrentSite();
const siteInfo = currentSite?.getInfo();
if (siteInfo?.usermaxuploadfilesize) {
this.maxSize = siteInfo.usermaxuploadfilesize;
this.maxSizeReadable = CoreTextUtils.instance.bytesToSize(this.maxSize, 2);
} else {
this.maxSizeReadable = Translate.instance.instant('core.unknown');
}
} else if (this.maxSize > 0) {
this.maxSizeReadable = CoreTextUtils.instance.bytesToSize(this.maxSize, 2);
} else if (this.maxSize === -1) {
this.maxSizeReadable = Translate.instance.instant('core.unlimited');
} else {
this.maxSizeReadable = Translate.instance.instant('core.unknown');
}
if (this.maxSubmissions === undefined || this.maxSubmissions < 0) {
this.maxSubmissionsReadable = this.maxSubmissions === undefined ?
Translate.instance.instant('core.unknown') : undefined;
this.unlimitedFiles = true;
} else {
this.maxSubmissionsReadable = String(this.maxSubmissions);
}
this.acceptedTypes = this.acceptedTypes?.trim();
if (this.acceptedTypes && this.acceptedTypes != '*') {
this.fileTypes = CoreFileUploader.instance.prepareFiletypeList(this.acceptedTypes);
}
}
/**
* Add a new attachment.
*/
async add(): Promise<void> {
const allowOffline = !!this.allowOffline && this.allowOffline !== 'false';
if (!allowOffline && !CoreApp.instance.isOnline()) {
CoreDomUtils.instance.showErrorModal('core.fileuploader.errormustbeonlinetoupload', true);
return;
}
const mimetypes = this.fileTypes && this.fileTypes.mimetypes;
try {
const result = await CoreFileUploaderHelper.instance.selectFile(this.maxSize, allowOffline, undefined, mimetypes);
this.files?.push(result);
} catch (error) {
CoreDomUtils.instance.showErrorModalDefault(error, 'Error selecting file.');
}
}
/**
* Delete a file from the list.
*
* @param index The index of the file.
* @param askConfirm Whether to ask confirm.
*/
async delete(index: number, askConfirm?: boolean): Promise<void> {
if (askConfirm) {
try {
await CoreDomUtils.instance.showDeleteConfirm('core.confirmdeletefile');
} catch {
// User cancelled.
return;
}
}
// Remove the file from the list.
this.files?.splice(index, 1);
}
/**
* A file was renamed.
*
* @param index Index of the file.
* @param data The data received.
*/
renamed(index: number, data: { file: FileEntry }): void {
this.files![index] = data.file;
}
}

View File

@ -0,0 +1,38 @@
<ion-item class="ion-text-wrap">
<ion-label>
<span *ngIf="maxSubmissionsReadable">
{{ 'core.maxsizeandattachments' | translate:{$a: {size: maxSizeReadable, attachments: maxSubmissionsReadable} } }}
</span>
<span *ngIf="!maxSubmissionsReadable">{{ 'core.maxfilesize' | translate:{$a: maxSizeReadable} }}</span>
<span [core-mark-required]="required" class="core-mark-required"></span>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="fileTypes && fileTypes.mimetypes && fileTypes.mimetypes.length">
<ion-label>
<p>{{ 'core.fileuploader.filesofthesetypes' | translate }}</p>
<ul class="list-with-style">
<li *ngFor="let typeInfo of fileTypes.info">
<strong *ngIf="typeInfo.name">{{typeInfo.name}} </strong>{{typeInfo.extlist}}
</li>
</ul>
</ion-label>
</ion-item>
<div *ngFor="let file of files; let index=index">
<!-- Files already attached to the submission, either in online or in offline. -->
<core-file *ngIf="!file.name" [file]="file" [component]="component" [componentId]="componentId"
[canDelete]="true" (onDelete)="delete(index, true)" [canDownload]="!file.offline">
</core-file>
<!-- Files added to draft but not attached to submission yet. -->
<core-local-file *ngIf="file.name" [file]="file" [manage]="true" (onDelete)="delete(index, false)"
(onRename)="renamed(index, $event)">
</core-local-file>
</div>
<!-- Button to add more files. -->
<ion-button expand="block"
*ngIf="unlimitedFiles || (maxSubmissions !== undefined && maxSubmissions >= 0 && files && files.length < maxSubmissions)"
class="ion-text-wrap ion-margin" (click)="add()">
<ion-icon name="fas-plus" slot="start"></ion-icon>
{{ 'core.fileuploader.addfiletext' | translate }}
</ion-button>

View File

@ -48,6 +48,9 @@ import { CoreNavigationBarComponent } from './navigation-bar/navigation-bar';
import { CoreDirectivesModule } from '@directives/directives.module';
import { CorePipesModule } from '@pipes/pipes.module';
import { CoreAttachmentsComponent } from './attachments/attachments';
import { CoreFilesComponent } from './files/files';
import { CoreLocalFileComponent } from './local-file/local-file';
@NgModule({
declarations: [
@ -78,6 +81,9 @@ import { CorePipesModule } from '@pipes/pipes.module';
CoreSendMessageFormComponent,
CoreTimerComponent,
CoreNavigationBarComponent,
CoreAttachmentsComponent,
CoreFilesComponent,
CoreLocalFileComponent,
],
imports: [
CommonModule,
@ -115,6 +121,9 @@ import { CorePipesModule } from '@pipes/pipes.module';
CoreSendMessageFormComponent,
CoreTimerComponent,
CoreNavigationBarComponent,
CoreAttachmentsComponent,
CoreFilesComponent,
CoreLocalFileComponent,
],
})
export class CoreComponentsModule {}

View File

@ -0,0 +1,12 @@
<ng-container *ngIf="showInline && contentText">
<core-format-text [text]="contentText" [filter]="false"></core-format-text>
</ng-container>
<ng-container *ngFor="let file of files">
<!-- Files already attached to the filearea. -->
<core-file *ngIf="!file.name && !file.embedType" [file]="file" [component]="component" [componentId]="componentId"
[alwaysDownload]="alwaysDownload" [canDownload]="canDownload" [showSize]="showSize" [showTime]="showTime">
</core-file>
<!-- Files stored in offline to be sent later. -->
<core-local-file *ngIf="file.name && !file.embedType" [file]="file"></core-local-file>
</ng-container>

View File

@ -0,0 +1,85 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, Input, OnInit, DoCheck, KeyValueDiffers } from '@angular/core';
import { FileEntry } from '@ionic-native/file/ngx';
import { CoreMimetypeUtils } from '@services/utils/mimetype';
import { CoreUtils } from '@services/utils/utils';
import { CoreWSExternalFile } from '@services/ws';
/**
* Component to render a file list.
*
* <core-files [files]="files" [component]="component" [componentId]="assign.cmid">
* </core-files>
*/
@Component({
selector: 'core-files',
templateUrl: 'core-files.html',
})
export class CoreFilesComponent implements OnInit, DoCheck {
@Input() files?: (CoreWSExternalFile | FileEntry)[]; // List of files.
@Input() component?: string; // Component the downloaded files will be linked to.
@Input() componentId?: string | number; // Component ID.
@Input() alwaysDownload?: boolean | string; // Whether it should always display the refresh button when the file is downloaded.
@Input() canDownload?: boolean | string = true; // Whether file can be downloaded.
@Input() showSize?: boolean | string = true; // Whether show filesize.
@Input() showTime?: boolean | string = true; // Whether show file time modified.
@Input() showInline = false; // If true, it will reorder and try to show inline files first.
contentText?: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
protected differ: any; // To detect changes in the data input.
constructor(differs: KeyValueDiffers) {
this.differ = differs.find([]).create();
}
/**
* Component being initialized.
*/
ngOnInit(): void {
if (CoreUtils.instance.isTrueOrOne(this.showInline) && this.files) {
this.renderInlineFiles();
}
}
/**
* Detect and act upon changes that Angular cant or wont detect on its own (objects and arrays).
*/
ngDoCheck(): void {
if (CoreUtils.instance.isTrueOrOne(this.showInline) && this.files) {
// Check if there's any change in the files array.
const changes = this.differ.diff(this.files);
if (changes) {
this.renderInlineFiles();
}
}
}
/**
* Calculate contentText based on fils that can be rendered inline.
*/
protected renderInlineFiles(): void {
this.contentText = this.files!.reduce((previous, file) => {
const text = CoreMimetypeUtils.instance.getEmbeddedHtml(file);
return text ? previous + '<br>' + text : previous;
}, '');
}
}

View File

@ -0,0 +1,33 @@
<form (ngSubmit)="changeName(newFileName, $event)" #nameForm>
<ion-item class="ion-text-wrap item-media" (click)="fileClicked($event)"> <!-- [class.item-input]="editMode" -->
<img [src]="fileIcon" alt="{{fileExtension}}" role="presentation" slot="start" />
<ion-label>
<!-- File name and edit button (if editable). -->
<h3 *ngIf="!editMode">{{fileName}}</h3>
<!-- More data about the file. -->
<p *ngIf="size && !editMode">{{ size }}</p>
<p *ngIf="timemodified && !editMode">{{ timemodified }}</p>
</ion-label>
<!-- Form to edit the file's name. -->
<ion-input type="text" name="filename" [placeholder]="'core.filename' | translate" autocapitalize="none" autocorrect="off"
(click)="$event.stopPropagation()" [core-auto-focus] [(ngModel)]="newFileName" *ngIf="editMode">
</ion-input>
<div class="buttons" slot="end" *ngIf="manage">
<ion-button *ngIf="!editMode" fill="clear" [core-suppress-events] (onClick)="activateEdit($event)"
[attr.aria-label]="'core.edit' | translate" color="dark">
<ion-icon name="fas-pen" slot="icon-only"></ion-icon>
</ion-button>
<ion-button *ngIf="editMode" fill="clear" [attr.aria-label]="'core.save' | translate" color="success" type="submit">
<ion-icon name="fas-check" slot="icon-only"></ion-icon>
</ion-button>
<ion-button fill="clear" (click)="deleteFile($event)" [attr.aria-label]="'core.delete' | translate" color="danger">
<ion-icon name="fas-trash" slot="icon-only"></ion-icon>
</ion-button>
</div>
</ion-item>
</form>

View File

@ -0,0 +1,213 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, Input, Output, OnInit, EventEmitter, ViewChild, ElementRef } from '@angular/core';
import { FileEntry } from '@ionic-native/file/ngx';
import { CoreIonLoadingElement } from '@classes/ion-loading';
import { CoreFile } from '@services/file';
import { CoreFileHelper } from '@services/file-helper';
import { CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreMimetypeUtils } from '@services/utils/mimetype';
import { CoreTextUtils } from '@services/utils/text';
import { CoreTimeUtils } from '@services/utils/time';
import { CoreUtils } from '@services/utils/utils';
/**
* Component to handle a local file. Only files inside the app folder can be managed.
*
* Shows the file name, icon (depending on extension), size and time modified.
* Also, if managing is enabled it will also show buttons to rename and delete the file.
*/
@Component({
selector: 'core-local-file',
templateUrl: 'core-local-file.html',
})
export class CoreLocalFileComponent implements OnInit {
@Input() file?: FileEntry; // A fileEntry retrieved using CoreFileProvider.getFile or similar.
@Input() manage?: boolean | string; // Whether the user can manage the file (edit and delete).
@Input() overrideClick?: boolean | string; // Whether the default item click should be overridden.
@Output() onDelete = new EventEmitter<void>(); // Will notify when the file is deleted.
@Output() onRename = new EventEmitter<{ file: FileEntry }>(); // Will notify when the file is renamed.
@Output() onClick = new EventEmitter<void>(); // Will notify when the file is clicked. Only if overrideClick is true.
@ViewChild('nameForm') formElement?: ElementRef;
fileName?: string;
fileIcon?: string;
fileExtension?: string;
size?: string;
timemodified?: string;
newFileName = '';
editMode = false;
relativePath?: string;
/**
* Component being initialized.
*/
async ngOnInit(): Promise<void> {
this.manage = CoreUtils.instance.isTrueOrOne(this.manage);
if (!this.file) {
return;
}
this.loadFileBasicData(this.file);
// Get the size and timemodified.
const metadata = await CoreFile.instance.getMetadata(this.file);
if (metadata.size >= 0) {
this.size = CoreTextUtils.instance.bytesToSize(metadata.size, 2);
}
this.timemodified = CoreTimeUtils.instance.userDate(metadata.modificationTime.getTime(), 'core.strftimedatetimeshort');
}
/**
* Load the basic data for the file.
*/
protected loadFileBasicData(file: FileEntry): void {
this.fileName = file.name;
this.fileIcon = CoreMimetypeUtils.instance.getFileIcon(file.name);
this.fileExtension = CoreMimetypeUtils.instance.getFileExtension(file.name);
// Let's calculate the relative path for the file.
this.relativePath = CoreFile.instance.removeBasePath(file.toURL());
if (!this.relativePath) {
// Didn't find basePath, use fullPath but if the user tries to manage the file it'll probably fail.
this.relativePath = file.fullPath;
}
}
/**
* File clicked.
*
* @param e Click event.
*/
async fileClicked(e: Event): Promise<void> {
if (this.editMode) {
return;
}
e.preventDefault();
e.stopPropagation();
if (CoreUtils.instance.isTrueOrOne(this.overrideClick) && this.onClick.observers.length) {
this.onClick.emit();
return;
}
if (!CoreFileHelper.instance.isOpenableInApp(this.file!)) {
try {
await CoreFileHelper.instance.showConfirmOpenUnsupportedFile();
} catch (error) {
return; // Cancelled, stop.
}
}
CoreUtils.instance.openFile(this.file!.toURL());
}
/**
* Activate the edit mode.
*
* @param e Click event.
*/
activateEdit(e: Event): void {
e.preventDefault();
e.stopPropagation();
this.editMode = true;
this.newFileName = this.file!.name;
}
/**
* Rename the file.
*
* @param newName New name.
* @param e Click event.
*/
async changeName(newName: string, e: Event): Promise<void> {
e.preventDefault();
e.stopPropagation();
if (newName == this.file!.name) {
// Name hasn't changed, stop.
this.editMode = false;
CoreDomUtils.instance.triggerFormCancelledEvent(this.formElement, CoreSites.instance.getCurrentSiteId());
return;
}
const modal = await CoreDomUtils.instance.showModalLoading();
const fileAndDir = CoreFile.instance.getFileAndDirectoryFromPath(this.relativePath!);
const newPath = CoreTextUtils.instance.concatenatePaths(fileAndDir.directory, newName);
try {
// Check if there's a file with this name.
await CoreFile.instance.getFile(newPath);
// There's a file with this name, show error and stop.
CoreDomUtils.instance.showErrorModal('core.errorfileexistssamename', true);
} catch {
try {
// File doesn't exist, move it.
const fileEntry = await CoreFile.instance.moveFile(this.relativePath!, newPath);
CoreDomUtils.instance.triggerFormSubmittedEvent(this.formElement, false, CoreSites.instance.getCurrentSiteId());
this.editMode = false;
this.file = fileEntry;
this.loadFileBasicData(this.file);
this.onRename.emit({ file: this.file });
} catch (error) {
CoreDomUtils.instance.showErrorModalDefault(error, 'core.errorrenamefile', true);
}
} finally {
modal.dismiss();
}
}
/**
* Delete the file.
*
* @param e Click event.
*/
async deleteFile(e: Event): Promise<void> {
e.preventDefault();
e.stopPropagation();
let modal: CoreIonLoadingElement | undefined;
try {
// Ask confirmation.
await CoreDomUtils.instance.showDeleteConfirm('core.confirmdeletefile');
modal = await CoreDomUtils.instance.showModalLoading('core.deleting', true);
await CoreFile.instance.removeFile(this.relativePath!);
this.onDelete.emit();
} catch (error) {
CoreDomUtils.instance.showErrorModalDefault(error, 'core.errordeletefile', true);
} finally {
modal?.dismiss();
}
}
}