From 35c1aa1cf9cc0c8caf339722235a020ce1b52144 Mon Sep 17 00:00:00 2001 From: MoritzWeber Date: Wed, 2 Oct 2024 23:07:13 +0200 Subject: [PATCH] feat!: Rewrite major parts of the file browser Breaking API change: `filename` was renamed to `path` in `/api/v1/sessions/{session_id}/files/download` to make it consistent with the the list files endpoint. - The UI got an overhaul - margins are fixed and more icons for different cases were added (added files, modified files, expanded folder) - The workspace is now expanded per default - Download of single files (not directories) was added - Replaced brute force to find a path with path traversal, which is a lot more efficient - Use the generated OpenAPI client, fix API docs to use ZIP as response - Migrate to TailwindCSS - Remove custom "Overwrite file" dialog, replace with ConfirmationDialog. - Remove `NestedTreeControl` - Separate handling for files which replace existing files - Stream downloading directory directory without loading to memory - Fix a bug that files with special characters couldn't be downloaded (archive had a size of 0KB) --- 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 | 193 +++++++---- .../file-browser-dialog.component.ts | 316 ++++++++---------- .../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, 383 insertions(+), 518 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..5b7349c7e6 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 - {shlex.quote(path)} | base64", ] 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..341a42b150 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,117 +3,162 @@ ~ 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 + +
+ + +
- +
- + 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..9df5335087 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,149 +84,121 @@ 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)) { + const existingFile = this.checkIfFileExists(parentNode, file.name); + if (existingFile) { + if (existingFile.type === 'directory') { + this.toastService.showError( + "Can't overwrite directories", + `A directory with the name '${file.name}' already exists in the workspace.`, + ); + continue; } + 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 { + checkIfFileExists(parentNode: PathNode, fileName: string): PathNode | null { if (parentNode.children) { for (const child of parentNode.children) { - if (fileName == child.name) return true; + if (fileName == child.name) return child; } } - return false; - } - - expandToNode(node: PathNode): void { - this._expandToNode(this.dataSource.value[0], node); + return null; } - _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 +210,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 +258,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, - ) {} -}