From 34b09d572a49f4431425d628ed1dc38bd848538f Mon Sep 17 00:00:00 2001 From: MoritzWeber Date: Wed, 2 Oct 2024 23:07:13 +0200 Subject: [PATCH] refactor: Rewrite major parts of the file browser The UI was completely messed up and is fixed now. Folders and files are aligned again. In addition, workspace is now expanded by default, new code style has been applied and the deprecated `NestedTreeControl` was replaced. --- backend/capellacollab/core/responses.py | 36 +++ .../capellacollab/sessions/files/routes.py | 24 +- .../capellacollab/sessions/operators/k8s.py | 4 +- .../src/app/openapi/api/sessions.service.ts | 34 +- .../services/load-files/load-files.service.ts | 46 --- .../app/sessions/service/session.service.ts | 12 +- .../file-browser-dialog.component.css | 66 ---- .../file-browser-dialog.component.html | 191 ++++++----- .../file-browser-dialog.component.ts | 304 ++++++++---------- .../file-browser-dialog.docs.mdx | 67 ---- .../file-browser-dialog.stories.ts | 60 +++- .../file-exists-dialog.component.css | 4 - .../file-exists-dialog.component.html | 18 -- .../file-exists-dialog.component.ts | 21 -- 14 files changed, 372 insertions(+), 515 deletions(-) delete mode 100644 frontend/src/app/services/load-files/load-files.service.ts delete mode 100644 frontend/src/app/sessions/user-sessions-wrapper/active-sessions/file-browser-dialog/file-browser-dialog.component.css delete mode 100644 frontend/src/app/sessions/user-sessions-wrapper/active-sessions/file-browser-dialog/file-browser-dialog.docs.mdx delete mode 100644 frontend/src/app/sessions/user-sessions-wrapper/active-sessions/file-browser-dialog/file-exists-dialog/file-exists-dialog.component.css delete mode 100644 frontend/src/app/sessions/user-sessions-wrapper/active-sessions/file-browser-dialog/file-exists-dialog/file-exists-dialog.component.html delete mode 100644 frontend/src/app/sessions/user-sessions-wrapper/active-sessions/file-browser-dialog/file-exists-dialog/file-exists-dialog.component.ts diff --git a/backend/capellacollab/core/responses.py b/backend/capellacollab/core/responses.py index aa1688c1c0..6485a94f0d 100644 --- a/backend/capellacollab/core/responses.py +++ b/backend/capellacollab/core/responses.py @@ -178,3 +178,39 @@ class SVGResponse(fastapi.responses.Response): } } } + + +class ZIPFileResponse(fastapi.responses.StreamingResponse): + """Custom error class for zip-file responses. + + To use the class as response class, pass the following parameters + to the fastapi route definition. + + ```python + response_class=fastapi.responses.Response + responses=responses.ZIPFileResponse.responses + ``` + + Don't use ZIPFileResponse as response_class as this will also change the + media type for all error responses, see: + https://github.com/tiangolo/fastapi/discussions/6799 + + To return an ZIP-file response in the route, use: + + ```python + return responses.ZIPFileResponse( + file_generator(), + ) + ``` + """ + + media_type = "application/zip" + responses: dict[int | str, dict[str, t.Any]] | None = { + 200: { + "content": { + "application/zip": { + "schema": {"type": "string", "format": "binary"} + } + } + } + } diff --git a/backend/capellacollab/sessions/files/routes.py b/backend/capellacollab/sessions/files/routes.py index 58b3dd6138..a5c113c9e5 100644 --- a/backend/capellacollab/sessions/files/routes.py +++ b/backend/capellacollab/sessions/files/routes.py @@ -9,6 +9,7 @@ import fastapi from fastapi import responses +from capellacollab.core import responses as core_responses from capellacollab.sessions import injectables as sessions_injectables from capellacollab.sessions import models as sessions_models from capellacollab.sessions import operators @@ -35,7 +36,7 @@ def list_files( raise exceptions.SessionFileLoadingFailedError() -@router.post("") +@router.post("", status_code=204) def upload_files( files: list[fastapi.UploadFile], session: sessions_models.DatabaseSession = fastapi.Depends( @@ -55,7 +56,6 @@ def upload_files( file.file.seek(0) assert file.filename - file.filename = file.filename.replace(" ", "_") tar.addfile( tar.gettarinfo(arcname=file.filename, fileobj=file.file), fileobj=file.file, @@ -66,20 +66,18 @@ def upload_files( operators.get_operator().upload_files(session.id, tar_bytes) - return {"message": "Upload successful"} - -@router.get("/download", response_class=responses.StreamingResponse) +@router.get( + "/download", + response_class=responses.StreamingResponse, + responses=core_responses.ZIPFileResponse.responses, +) def download_file( - filename: str, + path: str, session: sessions_models.DatabaseSession = fastapi.Depends( sessions_injectables.get_existing_session ), -) -> responses.StreamingResponse: - return responses.StreamingResponse( - operators.get_operator().download_file(session.id, filename), - headers={ - "content-disposition": 'attachment; filename=f"{filename}.zip"', - "content-type": "application/zip", - }, +) -> core_responses.ZIPFileResponse: + return core_responses.ZIPFileResponse( + operators.get_operator().download_file(session.id, path), ) diff --git a/backend/capellacollab/sessions/operators/k8s.py b/backend/capellacollab/sessions/operators/k8s.py index 61183e83b8..93678a3833 100644 --- a/backend/capellacollab/sessions/operators/k8s.py +++ b/backend/capellacollab/sessions/operators/k8s.py @@ -1085,13 +1085,13 @@ def upload_files( ) raise - def download_file(self, _id: str, filename: str) -> t.Iterable[bytes]: + def download_file(self, _id: str, path: str) -> t.Iterable[bytes]: pod_name = self._get_pod_name(_id) try: exec_command = [ "bash", "-c", - f"zip -qr /tmp/archive.zip '{shlex.quote(filename)}' && base64 /tmp/archive.zip && rm -f /tmp/archive.zip", + f"zip -qr /tmp/archive.zip '{shlex.quote(path)}' && base64 /tmp/archive.zip && rm -f /tmp/archive.zip", ] stream = kubernetes.stream.stream( self.v1_core.connect_get_namespaced_pod_exec, diff --git a/frontend/src/app/openapi/api/sessions.service.ts b/frontend/src/app/openapi/api/sessions.service.ts index 45757f09ad..c56a3c4809 100644 --- a/frontend/src/app/openapi/api/sessions.service.ts +++ b/frontend/src/app/openapi/api/sessions.service.ts @@ -120,25 +120,25 @@ export class SessionsService { /** * Download File * @param sessionId - * @param filename + * @param path * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. * @param reportProgress flag to report request and response progress. */ - public downloadFile(sessionId: string, filename: string, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; - public downloadFile(sessionId: string, filename: string, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; - public downloadFile(sessionId: string, filename: string, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; - public downloadFile(sessionId: string, filename: string, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { + public downloadFile(sessionId: string, path: string, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/zip' | 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; + public downloadFile(sessionId: string, path: string, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/zip' | 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public downloadFile(sessionId: string, path: string, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/zip' | 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public downloadFile(sessionId: string, path: string, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/zip' | 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { if (sessionId === null || sessionId === undefined) { throw new Error('Required parameter sessionId was null or undefined when calling downloadFile.'); } - if (filename === null || filename === undefined) { - throw new Error('Required parameter filename was null or undefined when calling downloadFile.'); + if (path === null || path === undefined) { + throw new Error('Required parameter path was null or undefined when calling downloadFile.'); } let localVarQueryParameters = new HttpParams({encoder: this.encoder}); - if (filename !== undefined && filename !== null) { + if (path !== undefined && path !== null) { localVarQueryParameters = this.addToHttpParams(localVarQueryParameters, - filename, 'filename'); + path, 'path'); } let localVarHeaders = this.defaultHeaders; @@ -154,6 +154,7 @@ export class SessionsService { if (localVarHttpHeaderAcceptSelected === undefined) { // to determine the Accept header const httpHeaderAccepts: string[] = [ + 'application/zip', 'application/json' ]; localVarHttpHeaderAcceptSelected = this.configuration.selectHeaderAccept(httpHeaderAccepts); @@ -173,23 +174,12 @@ export class SessionsService { } - let responseType_: 'text' | 'json' | 'blob' = 'json'; - if (localVarHttpHeaderAcceptSelected) { - if (localVarHttpHeaderAcceptSelected.startsWith('text')) { - responseType_ = 'text'; - } else if (this.configuration.isJsonMime(localVarHttpHeaderAcceptSelected)) { - responseType_ = 'json'; - } else { - responseType_ = 'blob'; - } - } - let localVarPath = `/api/v1/sessions/${this.configuration.encodeParam({name: "sessionId", value: sessionId, in: "path", style: "simple", explode: false, dataType: "string", dataFormat: undefined})}/files/download`; - return this.httpClient.request('get', `${this.configuration.basePath}${localVarPath}`, + return this.httpClient.request('get', `${this.configuration.basePath}${localVarPath}`, { context: localVarHttpContext, params: localVarQueryParameters, - responseType: responseType_, + responseType: "blob", withCredentials: this.configuration.withCredentials, headers: localVarHeaders, observe: observe, diff --git a/frontend/src/app/services/load-files/load-files.service.ts b/frontend/src/app/services/load-files/load-files.service.ts deleted file mode 100644 index 5a7dd06879..0000000000 --- a/frontend/src/app/services/load-files/load-files.service.ts +++ /dev/null @@ -1,46 +0,0 @@ -/* - * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors - * SPDX-License-Identifier: Apache-2.0 - */ -import { HttpClient, HttpEvent } from '@angular/common/http'; -import { Injectable } from '@angular/core'; -import { Observable } from 'rxjs'; -import { PathNode } from 'src/app/sessions/service/session.service'; -import { environment } from 'src/environments/environment'; - -@Injectable({ - providedIn: 'root', -}) -export class LoadFilesService { - BACKEND_URL_PREFIX = environment.backend_url + '/sessions'; - - constructor(private http: HttpClient) {} - - upload(id: string, files: FormData): Observable> { - return this.http.post( - `${this.BACKEND_URL_PREFIX}/${id}/files`, - files, - { - reportProgress: true, - observe: 'events', - }, - ); - } - - getCurrentFiles(id: string, showHiddenFiles: boolean): Observable { - return this.http.get( - `${this.BACKEND_URL_PREFIX}/${id}/files?show_hidden=${showHiddenFiles}`, - ); - } - - download(id: string, filename: string): Observable { - return this.http.get(`${this.BACKEND_URL_PREFIX}/${id}/files/download`, { - params: { filename: filename }, - responseType: 'blob', - }); - } -} - -export interface UploadResponse { - message: string; -} diff --git a/frontend/src/app/sessions/service/session.service.ts b/frontend/src/app/sessions/service/session.service.ts index 4964e2aeba..4051f07b40 100644 --- a/frontend/src/app/sessions/service/session.service.ts +++ b/frontend/src/app/sessions/service/session.service.ts @@ -9,6 +9,7 @@ import { SessionProvisioningRequest, SessionsService, SessionConnectionInformation, + FileTree, } from 'src/app/openapi'; import { SessionHistoryService } from 'src/app/sessions/user-sessions-wrapper/create-sessions/create-session-history/session-history.service'; @@ -223,10 +224,9 @@ export interface SessionState { success: boolean; } -export interface PathNode { - path: string; - name: string; - type: 'file' | 'directory'; - isNew: boolean; +export type PathNode = Omit & { + isNew?: boolean; + isModified?: boolean; + isExpanded?: boolean; children: PathNode[] | null; -} +}; diff --git a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/file-browser-dialog/file-browser-dialog.component.css b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/file-browser-dialog/file-browser-dialog.component.css deleted file mode 100644 index e48b9ff77a..0000000000 --- a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/file-browser-dialog/file-browser-dialog.component.css +++ /dev/null @@ -1,66 +0,0 @@ -/* - * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -.file-tree { - max-height: 50vh; - overflow-y: auto; -} - -.progress { - display: flex; -} - -.progress div { - flex: 1; - display: flex; - flex-direction: column; - justify-content: space-around; - padding-right: 1em; -} - -.progress button { - flex: 0 1; -} - -.file-tree-invisible { - display: none; -} - -.file-tree ul, -.file-tree li { - margin-top: 0; - margin-bottom: 0; - list-style-type: none; -} - -.file-tree .mat-nested-tree-node div[role="group"] { - padding-left: 20px; -} - -.file-tree div[role="group"] > .mat-tree-node { - padding-left: 8px; - min-height: 10px; -} - -.upload_button { - margin-left: 20px; -} - -.new-file { - color: darkgreen; -} - -.file-icon { - padding-right: 10px; - color: burlywood; -} - -.dir-icon { - color: darkblue; -} - -#hidden-files { - margin-left: 10px; -} diff --git a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/file-browser-dialog/file-browser-dialog.component.html b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/file-browser-dialog/file-browser-dialog.component.html index aa6d1d6efd..6e5ed83b18 100644 --- a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/file-browser-dialog/file-browser-dialog.component.html +++ b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/file-browser-dialog/file-browser-dialog.component.html @@ -3,104 +3,150 @@ ~ SPDX-License-Identifier: Apache-2.0 --> -
+

Session File Browser

- - -
-
- - -
- -
+ @if (loadingFiles) { + + } @else { + @if (uploadProgress) { +
+

Your upload is in progress…

+
+ + + +
+
+ } -
-
-

Your download is being prepared…

-
-
- -
-
+ @if (session.download_in_progress) { +
+

Your download is being prepared…

+ +
+ } - Show hidden files + Show hidden files +
+ } - insert_drive_file + + @if (node.isNew) { + note_add + } @else if (node.isModified) { + edit_document + } @else { + insert_drive_file + } + -

+

{{ node.name }}

-
- -
-
- - -
-
- - {{ node.name }} -
+ } @else { -
- + } + - -
+ + +
+ {{ node.name }}
-
- + download_file + +
+ + +
- +
@@ -108,12 +154,11 @@

Session File Browser

diff --git a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/file-browser-dialog/file-browser-dialog.component.ts b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/file-browser-dialog/file-browser-dialog.component.ts index 8a5a965e2f..687db4c6fa 100644 --- a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/file-browser-dialog/file-browser-dialog.component.ts +++ b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/file-browser-dialog/file-browser-dialog.component.ts @@ -2,74 +2,57 @@ * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors * SPDX-License-Identifier: Apache-2.0 */ -import { NestedTreeControl } from '@angular/cdk/tree'; -import { NgIf, NgClass } from '@angular/common'; -import { HttpEvent, HttpEventType } from '@angular/common/http'; +import { NgClass } from '@angular/common'; +import { HttpEventType } from '@angular/common/http'; import { Component, Inject, OnInit } from '@angular/core'; import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { MatButton, MatIconButton } from '@angular/material/button'; +import { MatButtonModule } from '@angular/material/button'; import { MatCheckbox } from '@angular/material/checkbox'; +import { MatRippleModule } from '@angular/material/core'; import { MAT_DIALOG_DATA, MatDialog, MatDialogRef, } from '@angular/material/dialog'; -import { MatIcon } from '@angular/material/icon'; +import { MatIconModule } from '@angular/material/icon'; import { MatProgressBar } from '@angular/material/progress-bar'; -import { - MatTree, - MatTreeNodeDef, - MatTreeNode, - MatTreeNodeToggle, - MatTreeNodePadding, - MatNestedTreeNode, - MatTreeNodeOutlet, -} from '@angular/material/tree'; +import { MatTableDataSource } from '@angular/material/table'; +import { MatTreeModule } from '@angular/material/tree'; import { saveAs } from 'file-saver'; -import { BehaviorSubject } from 'rxjs'; +import { ConfirmationDialogComponent } from 'src/app/helpers/confirmation-dialog/confirmation-dialog.component'; +import { MatIconComponent } from 'src/app/helpers/mat-icon/mat-icon.component'; import { ToastService } from 'src/app/helpers/toast/toast.service'; -import { Session } from 'src/app/openapi'; -import { - LoadFilesService, - UploadResponse, -} from 'src/app/services/load-files/load-files.service'; +import { Session, SessionsService } from 'src/app/openapi'; import { PathNode } from 'src/app/sessions/service/session.service'; -import { FileExistsDialogComponent } from './file-exists-dialog/file-exists-dialog.component'; @Component({ selector: 'app-file-browser-dialog', templateUrl: 'file-browser-dialog.component.html', - styleUrls: ['file-browser-dialog.component.css'], standalone: true, imports: [ - NgIf, - MatProgressBar, - MatButton, - MatIcon, - MatCheckbox, FormsModule, - ReactiveFormsModule, - MatTree, - MatTreeNodeDef, - MatTreeNode, - MatTreeNodeToggle, - MatTreeNodePadding, + MatButtonModule, + MatCheckbox, + MatIconModule, + MatProgressBar, + MatRippleModule, + MatTreeModule, NgClass, - MatIconButton, - MatNestedTreeNode, - MatTreeNodeOutlet, + ReactiveFormsModule, + MatIconComponent, ], }) export class FileBrowserDialogComponent implements OnInit { - files: [File, string][] = []; + filesToUpload: [File, string][] = []; uploadProgress: number | null = null; loadingFiles = false; - treeControl = new NestedTreeControl((node) => node.children); - dataSource = new BehaviorSubject([]); + dataSource = new MatTableDataSource([]); + + childrenAccessor = (node: PathNode) => node.children ?? []; constructor( - private loadService: LoadFilesService, + private sessionsService: SessionsService, private dialog: MatDialog, public dialogRef: MatDialogRef, private toastService: ToastService, @@ -85,11 +68,13 @@ export class FileBrowserDialogComponent implements OnInit { loadFiles(): void { this.loadingFiles = true; - this.loadService - .getCurrentFiles(this.session.id, this.showHiddenFiles.value as boolean) + this.sessionsService + .listFiles(this.session.id, this.showHiddenFiles.value as boolean) .subscribe({ - next: (file: PathNode) => { - this.dataSource.next([file]); + next: (file) => { + const treeNode = file as PathNode; + treeNode.isExpanded = true; + this.dataSource.data = [treeNode]; }, complete: () => { this.loadingFiles = false; @@ -99,59 +84,56 @@ export class FileBrowserDialogComponent implements OnInit { hasChild = (_: number, node: PathNode) => !!node.children; - addFiles(files: FileList | null, path: string, parentNode: PathNode): void { - if (files) { - for (const file of Array.from(files)) { - const name = file.name.replace(/\s/g, '_'); - if (this.checkIfFileExists(parentNode, name)) { - const fileExistsDialog = this.dialog.open(FileExistsDialogComponent, { - data: name, - }); - fileExistsDialog.afterClosed().subscribe((response) => { - if (!this.files.includes([file, path]) && response) { - this.files.push([file, path]); - if (parentNode.children) { - for (const child of parentNode.children) { - if (child.name === name) { - child.isNew = true; - break; - } - } - } - } - }); - } else if (!this.files.includes([file, path])) { - this.addFileToTree(this.dataSource.value[0], path, name); - this.files.push([file, path]); - this.treeControl.expand(this.dataSource.value[0]); - } + addFiles(files: FileList | null, parentNode: PathNode): void { + if (!files) return; + for (const file of Array.from(files)) { + if (this.checkIfFileExists(parentNode, file.name)) { + const fileExistsDialog = this.dialog.open(ConfirmationDialogComponent, { + data: { + title: 'Overwrite file in workspace', + text: `Do you want to overwrite the file '${file.name}' in your workspace?`, + }, + }); + fileExistsDialog.afterClosed().subscribe((response) => { + if ( + !this.filesToUpload.includes([file, parentNode.path]) && + response + ) { + this.stageFile(parentNode, file); + } + }); + } else if (!this.filesToUpload.includes([file, parentNode.path])) { + this.stageFile(parentNode, file); } } } - addFileToTree(parentNode: PathNode, path: string, name: string): boolean { - let result = false; - if (parentNode.path === path) { + stageFile(parentNode: PathNode, file: File) { + this.addFileToTree(parentNode, file.name); + this.filesToUpload.push([file, parentNode.path]); + } + + addFileToTree(parentNode: PathNode, name: string): void { + const existingFile = parentNode.children?.find( + (child) => child.name === name, + ); + + parentNode.isExpanded = true; + + if (existingFile) { + existingFile.isModified = true; + } else { parentNode.children?.push({ - path: path + `/${name}`, + path: parentNode.path + `/${name}`, name, type: 'file', children: null, isNew: true, }); - this.dataSource.next([{ ...this.dataSource.value[0] }]); - this.treeControl.expand(parentNode); - return true; - } else if (parentNode.children) { - for (const child of parentNode.children) { - result = this.addFileToTree(child, path, name); - if (result) { - this.treeControl.expand(parentNode); - break; - } - } + + // Trigger change detection + this.dataSource.data = this.dataSource.data; // eslint-disable-line no-self-assign } - return result; } checkIfFileExists(parentNode: PathNode, fileName: string): boolean { @@ -163,85 +145,52 @@ export class FileBrowserDialogComponent implements OnInit { return false; } - expandToNode(node: PathNode): void { - this._expandToNode(this.dataSource.value[0], node); - } - - _expandToNode(parentNode: PathNode, node: PathNode): boolean { - let result = false; - if (node.path === parentNode.path) { - this.treeControl.expand(parentNode); - result = true; - } else if (parentNode.children) { - for (const child of parentNode.children) { - result = this._expandToNode(child, node); - if (result) { - this.treeControl.expand(parentNode); - } - } - } - return result; + removeFile(node: PathNode): void { + this.removeFileFromSelection(node.path, node.name); + this.removeFileFromTree(node); } - removeFile(path: string, filename: string): void { - this.removeFileFromSelection(path, filename); - this.removeFileFromTree(path, filename); - } + private _removeElementByPath(path: string): void { + // Remove the element using path traversal - findNode(prefix: string, searchedName: string): [PathNode, number] | null { - return this._findNode(this.dataSource.value[0], searchedName, prefix); - } + const paths = path.split('/').slice(2); + const filename = paths.pop(); - _findNode( - parentNode: PathNode, - searchedName: string, - prefix: string, - ): [PathNode, number] | null { - if (parentNode.children!) { - for (let i = 0; i < parentNode.children.length; i++) { - const child = parentNode.children[i]; - if (child.name === searchedName && child.path === prefix) { - return [parentNode, i]; - } else { - const result = this._findNode(child, searchedName, prefix); - if (result) { - return result; - } - } - } + let currentNode = this.dataSource.data[0]; + for (const name of paths) { + const child = currentNode.children?.find((child) => child.name === name); + if (!child) return; + currentNode = child; } - return null; + if (!currentNode.children) return; + const childIndex = currentNode.children.findIndex( + (child) => child.name === filename, + ); + if (childIndex === -1) return; + currentNode.children?.splice(childIndex, 1); } - removeFileFromTree(path: string, filename: string): void { - const result = this.findNode(path, filename); - if (result) { - result[0].children?.splice(result[1], 1); - this.dataSource.next([{ ...this.dataSource.value[0] }]); - this.expandToNode(result[0]); + removeFileFromTree(node: PathNode): void { + if (node.isModified) { + node.isModified = false; + } else { + this._removeElementByPath(node.path); + // Trigger change detection + this.dataSource.data = this.dataSource.data; // eslint-disable-line no-self-assign } } removeFileFromSelection(path: string, filename: string): void { - let file; - let prefix = null; - for (const fileIter of this.files) { - file = fileIter[0]; - prefix = fileIter[1]; - if (file.name === filename && prefix === path) { - break; - } - } - if (!!file && !!prefix) { - const index: number = this.files.indexOf([file, prefix]); - this.files.splice(index, 1); - } + const index = this.filesToUpload.findIndex( + ([file, prefix]) => file.name === filename && prefix === path, + ); + if (!index) return; + this.filesToUpload.splice(index, 1); } submit() { - const formData = new FormData(); let size = 0; - this.files.forEach(([file, _]: [File, string]) => { + this.filesToUpload.forEach(([file, _]: [File, string]) => { size += file.size; }); @@ -253,32 +202,45 @@ export class FileBrowserDialogComponent implements OnInit { return; } - this.files.forEach(([file, prefix]: [File, string]) => { - formData.append('files', file, `${prefix}/${file.name}`); - }); - formData.append('id', this.session.id); - - this.loadService.upload(this.session.id, formData).subscribe({ - next: (event: HttpEvent) => { - if (event.type == HttpEventType.Response) { - this.dialogRef.close(); - } else if (event.type == HttpEventType.UploadProgress && event.total) { - this.uploadProgress = Math.round(100 * (event.loaded / event.total)); - } - }, - error: () => { - this.reset(); - }, - }); + const files = this.filesToUpload.map( + ([file, prefix]) => + new File([file], `${prefix}/${file.name}`, { + type: file.type, + lastModified: file.lastModified, + }), + ); + this.sessionsService + .uploadFiles(this.session.id, files, 'events', true) + .subscribe({ + next: (event) => { + if (event.type == HttpEventType.Response) { + this.dialogRef.close(); + this.toastService.showSuccess( + 'Upload successful', + `${files.length} file(s) uploaded successfully`, + ); + } else if ( + event.type == HttpEventType.UploadProgress && + event.total + ) { + this.uploadProgress = Math.round( + 100 * (event.loaded / event.total), + ); + } + }, + error: () => { + this.cancelUpload(); + }, + }); } - download(filename: string) { + download(path: string) { this.session.download_in_progress = true; - this.loadService.download(this.session.id, filename).subscribe({ + this.sessionsService.downloadFile(this.session.id, path).subscribe({ next: (response: Blob) => { saveAs( response, - `${filename.replace(/^[/\\: ]+/, '').replace(/[/\\: ]+/g, '_')}.zip`, + `${path.replace(/^[/\\: ]+/, '').replace(/[/\\: ]+/g, '_')}.zip`, ); this.session.download_in_progress = false; }, @@ -288,7 +250,7 @@ export class FileBrowserDialogComponent implements OnInit { }); } - reset() { + cancelUpload() { this.uploadProgress = null; } } diff --git a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/file-browser-dialog/file-browser-dialog.docs.mdx b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/file-browser-dialog/file-browser-dialog.docs.mdx deleted file mode 100644 index aa7bd4cd3b..0000000000 --- a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/file-browser-dialog/file-browser-dialog.docs.mdx +++ /dev/null @@ -1,67 +0,0 @@ -{/* - SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors - SPDX-License-Identifier: Apache-2.0 -*/} - -import * as FileBrowserDialog from './file-browser-dialog.stories.ts' -import { Meta, Title, Story, Canvas, Unstyled } from '@storybook/blocks' - - - - - -The file explorer allows users to browse, download and upload files from their workspace. - -While the files are loading, a progress bar is displayed: - -<Unstyled> - <div style={{ width: '400px' }}> - <Story of={FileBrowserDialog.LoadingFiles} /> - </div> -</Unstyled> - -When the files are loaded, a tree is displayed. -Users can expand directories by clicking on the folder icon. - -<Unstyled> - <div style={{ width: '400px' }}> - <Story of={FileBrowserDialog.Files} /> - </div> -</Unstyled> - -## Uploads - -When uploading a file, the upload button on the specific directory has to be selected. -The file is then staged for upload. In this story, expand the workspace directory to see the staged file1. - -<Unstyled> - <div style={{ width: '400px' }}> - <Story of={FileBrowserDialog.UploadNewFile} /> - </div> -</Unstyled> - -When the upload is confirmed with the Submit button, it's uploaded to the server: - -<Unstyled> - <div style={{ width: '400px' }}> - <Story of={FileBrowserDialog.UploadInProgress} /> - </div> -</Unstyled> - -When the upload is finished, it's processed by the backend: - -<Unstyled> - <div style={{ width: '400px' }}> - <Story of={FileBrowserDialog.UploadProcessedByBackend} /> - </div> -</Unstyled> - -## Downloads - -When downloading a directory, the download button on the specific directory has to be selected. -The file is then downloaded: -<Unstyled> - <div style={{ width: '400px' }}> - <Story of={FileBrowserDialog.DownloadPreparation} /> - </div> -</Unstyled> diff --git a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/file-browser-dialog/file-browser-dialog.stories.ts b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/file-browser-dialog/file-browser-dialog.stories.ts index 060a2d8fb3..434a0fdc5c 100644 --- a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/file-browser-dialog/file-browser-dialog.stories.ts +++ b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/file-browser-dialog/file-browser-dialog.stories.ts @@ -3,8 +3,9 @@ * SPDX-License-Identifier: Apache-2.0 */ import { MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { MatTableDataSource } from '@angular/material/table'; import { Meta, moduleMetadata, StoryObj } from '@storybook/angular'; -import { BehaviorSubject } from 'rxjs'; +import { userEvent, within } from '@storybook/test'; import { PathNode } from 'src/app/sessions/service/session.service'; import { FileBrowserDialogComponent } from 'src/app/sessions/user-sessions-wrapper/active-sessions/file-browser-dialog/file-browser-dialog.component'; import { dialogWrapper } from 'src/storybook/decorators'; @@ -38,7 +39,7 @@ export const LoadingFiles: Story = { export const Files: Story = { args: { loadingFiles: false, - dataSource: new BehaviorSubject<PathNode[]>([ + dataSource: new MatTableDataSource<PathNode>([ { path: '/workspace', name: 'workspace', @@ -49,26 +50,62 @@ export const Files: Story = { path: '/workspace/file1', name: 'file1', type: 'file', - isNew: false, children: null, }, { path: '/workspace/file2', name: 'file2', type: 'file', - isNew: false, children: null, }, + { + path: '/workspace/directory1', + name: 'directory1', + type: 'directory', + children: [ + { + path: '/workspace/directory1/file1', + name: 'file1', + type: 'file', + children: null, + }, + { + path: '/workspace/directory1/file2', + name: 'file2', + type: 'file', + children: null, + }, + ], + }, + { + path: '/workspace/directory2', + name: 'directory2', + type: 'directory', + children: [], + }, + { + path: '/workspace/directory3', + name: 'directory3', + type: 'directory', + children: [], + }, ], }, ]), }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + let folderButtons = canvas.getAllByTestId('folder-button'); + await userEvent.click(folderButtons[0]); + folderButtons = canvas.getAllByTestId('folder-button'); + await userEvent.click(folderButtons[1]); + }, }; export const UploadNewFile: Story = { args: { loadingFiles: false, - dataSource: new BehaviorSubject<PathNode[]>([ + dataSource: new MatTableDataSource<PathNode>([ { path: '/workspace', name: 'workspace', @@ -86,13 +123,24 @@ export const UploadNewFile: Story = { path: '/workspace/file2', name: 'file2', type: 'file', - isNew: false, + isModified: true, + children: null, + }, + { + path: '/workspace/file3', + name: 'file3', + type: 'file', children: null, }, ], }, ]), }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const folderButton = canvas.getByTestId('folder-button'); + await userEvent.click(folderButton); + }, }; export const UploadInProgress: Story = { diff --git a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/file-browser-dialog/file-exists-dialog/file-exists-dialog.component.css b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/file-browser-dialog/file-exists-dialog/file-exists-dialog.component.css deleted file mode 100644 index 8535c6938a..0000000000 --- a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/file-browser-dialog/file-exists-dialog/file-exists-dialog.component.css +++ /dev/null @@ -1,4 +0,0 @@ -/* - * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors - * SPDX-License-Identifier: Apache-2.0 - */ diff --git a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/file-browser-dialog/file-exists-dialog/file-exists-dialog.component.html b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/file-browser-dialog/file-exists-dialog/file-exists-dialog.component.html deleted file mode 100644 index bae544420e..0000000000 --- a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/file-browser-dialog/file-exists-dialog/file-exists-dialog.component.html +++ /dev/null @@ -1,18 +0,0 @@ -<!-- - ~ SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors - ~ SPDX-License-Identifier: Apache-2.0 - --> - -<p> - Are you sure to overwrite file <b>{{ filename }}</b - >? -</p> -<button mat-button (click)="this.dialogRef.close(false)">No</button> -<button - mat-raised-button - (click)="this.dialogRef.close(true)" - color="primary" - style="margin-left: 20px" -> - Yes -</button> diff --git a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/file-browser-dialog/file-exists-dialog/file-exists-dialog.component.ts b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/file-browser-dialog/file-exists-dialog/file-exists-dialog.component.ts deleted file mode 100644 index 52bba11ffb..0000000000 --- a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/file-browser-dialog/file-exists-dialog/file-exists-dialog.component.ts +++ /dev/null @@ -1,21 +0,0 @@ -/* - * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors - * SPDX-License-Identifier: Apache-2.0 - */ -import { Component, Inject } from '@angular/core'; -import { MatButton } from '@angular/material/button'; -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; - -@Component({ - selector: 'app-file-exists-dialog', - templateUrl: './file-exists-dialog.component.html', - styleUrls: ['./file-exists-dialog.component.css'], - standalone: true, - imports: [MatButton], -}) -export class FileExistsDialogComponent { - constructor( - public dialogRef: MatDialogRef<FileExistsDialogComponent>, - @Inject(MAT_DIALOG_DATA) public filename: string, - ) {} -}