diff --git a/src/app.scss b/src/app.scss index ae02d28b..96015963 100644 --- a/src/app.scss +++ b/src/app.scss @@ -11,10 +11,32 @@ // Passthrough for layout and styling purposes, to enable main page parts to participate in the grid .pf-v5-c-page__main-section, -.files-view-stack { +.files-view-stack, +.upload-drop-zone { display: contents; } +.files-card { + position: relative; +} + +.drag-drop-upload, +.drag-drop-upload-blocked { + position: absolute; + z-index: 10; + border: 3px dashed var(--pf-v5-global--link--Color); + background: rgb(from var(--pf-v5-global--BackgroundColor--100) r g b / 80%); + display: flex; + align-items: center; + justify-content: center; + block-size: 100%; + inline-size: 100%; + + .pf-v5-c-empty-state__icon { + color: var(--pf-v5-global--Color--100); + } +} + .pf-v5-c-page__main { gap: var(--pf-v5-global--spacer--md); display: grid; @@ -30,12 +52,12 @@ } .files-empty-state, -.files-view-stack > .pf-v5-c-card, +.files-view-stack > .upload-drop-zone > .pf-v5-c-card, .files-view-stack > .files-footer-info { grid-column: content; } -.files-view-stack > .pf-v5-c-card { +.files-view-stack > .upload-drop-zone > .pf-v5-c-card { overflow: auto; } diff --git a/src/files-card-body.scss b/src/files-card-body.scss index c68af7c1..668bc43d 100644 --- a/src/files-card-body.scss +++ b/src/files-card-body.scss @@ -309,3 +309,9 @@ column-gap: var(--pf-v5-global--spacer--sm); text-align: start; } + +// use all available space when there is not enough files to fill the whole view +// so drag&drop works in empty space +.fileview-wrapper { + block-size: 100%; +} diff --git a/src/files-card-body.tsx b/src/files-card-body.tsx index 165f3b56..9def7b3d 100644 --- a/src/files-card-body.tsx +++ b/src/files-card-body.tsx @@ -170,7 +170,7 @@ export const FilesCardBody = ({ return files.filter(file => file.name.startsWith(".")).length; }, [files]); const isMounted = useRef(); - const folderViewRef = React.useRef(null); + const folderViewRef = useRef(null); function calculateBoxPerRow () { const boxes = document.querySelectorAll(".fileview tbody > tr") as NodeListOf; @@ -206,7 +206,7 @@ export const FilesCardBody = ({ }); useEffect(() => { - let folderViewElem = null; + let folderViewElem: HTMLDivElement | null = null; const resetSelected = (e: MouseEvent) => { if ((e.target instanceof HTMLElement)) { diff --git a/src/files-folder-view.tsx b/src/files-folder-view.tsx index 4756e886..66023ca5 100644 --- a/src/files-folder-view.tsx +++ b/src/files-folder-view.tsx @@ -17,15 +17,35 @@ * along with Cockpit; If not, see . */ -import React, { useEffect, useState } from "react"; +import React, { createContext, useContext, useEffect, useState, useRef } from "react"; import { Card } from '@patternfly/react-core/dist/esm/components/Card'; +import { BanIcon, UploadIcon } from '@patternfly/react-icons'; import { debounce } from "throttle-debounce"; +import cockpit from "cockpit"; +import { EmptyStatePanel } from "cockpit-components-empty-state"; + import type { FolderFileInfo } from "./app.tsx"; import { FilesCardBody } from "./files-card-body.tsx"; import { as_sort, FilesCardHeader } from "./header.tsx"; +const _ = cockpit.gettext; + +export interface UploadedFilesType {[name: string]:{file: File, progress: number, cancel:() => void}} + +interface UploadContextType { + uploadedFiles: UploadedFilesType, + setUploadedFiles: React.Dispatch>, +} + +export const UploadContext = createContext({ + uploadedFiles: {}, + setUploadedFiles: () => console.warn("UploadContext not initialized!"), +} as UploadContextType); + +export const useUploadContext = () => useContext(UploadContext); + export const FilesFolderView = ({ path, files, @@ -45,9 +65,13 @@ export const FilesFolderView = ({ selected: FolderFileInfo[], setSelected: React.Dispatch>, clipboard: string[], setClipboard: React.Dispatch>, }) => { + const dropzoneRef = useRef(null); const [currentFilter, setCurrentFilter] = useState(""); const [isGrid, setIsGrid] = useState(localStorage.getItem("files:isGrid") !== "false"); const [sortBy, setSortBy] = useState(as_sort(localStorage.getItem("files:sort"))); + const [dragDropActive, setDragDropActive] = useState(false); + const [uploadedFiles, setUploadedFiles] = useState<{[name: string]: + {file: File, progress: number, cancel:() => void}}>({}); const onFilterChange = debounce(300, (_event: React.FormEvent, value: string) => setCurrentFilter(value)); @@ -57,39 +81,130 @@ export const FilesFolderView = ({ setCurrentFilter(""); }, [path]); + // counter to manage current status of drag&drop + // dragging items over file entries causes a bunch of drag-enter and drag-leave + // events, this counter helps checking when final drag-leave event is fired + const dragDropCnt = useRef(0); + + useEffect(() => { + let dropzoneElem: HTMLDivElement | null = null; + const isUploading = Object.keys(uploadedFiles).length !== 0; + + const handleDragEnter = (event: DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + + if (dragDropCnt.current === 0) { + setDragDropActive(true); + } + dragDropCnt.current++; + }; + + const handleDragLeave = (event: DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + + dragDropCnt.current--; + if (dragDropCnt.current === 0) { + setDragDropActive(false); + } + }; + + const handleDrop = (event: DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + + setDragDropActive(false); + dragDropCnt.current = 0; + + // disable drag & drop when upload is in progress + if (isUploading) { + return; + } + + cockpit.assert(event.dataTransfer !== null, "dataTransfer cannot be null"); + dispatchEvent(new CustomEvent('files-drop', { detail: event.dataTransfer.files })); + }; + + const handleDragOver = (event: DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + }; + + if (dropzoneRef.current) { + dropzoneElem = dropzoneRef.current; + dropzoneElem.addEventListener("dragenter", handleDragEnter); + dropzoneElem.addEventListener("dragleave", handleDragLeave); + dropzoneElem.addEventListener("dragover", handleDragOver, false); + dropzoneElem.addEventListener("drop", handleDrop, false); + } + + return () => { + if (dropzoneElem) { + dropzoneElem.removeEventListener("dragenter", handleDragEnter); + dropzoneElem.removeEventListener("dragover", handleDragOver); + dropzoneElem.removeEventListener("dragleave", handleDragLeave); + dropzoneElem.removeEventListener("drop", handleDrop); + } + }; + }, [uploadedFiles, dragDropCnt]); + + const dropzoneComponent = (Object.keys(uploadedFiles).length === 0) + ? ( +
+ +
+ ) + : ( +
+ +
+ ); + return ( - - - - + +
+ + + + {dragDropActive && dropzoneComponent} + +
+
); }; diff --git a/src/upload-button.tsx b/src/upload-button.tsx index 38c151f3..5e28b4ce 100644 --- a/src/upload-button.tsx +++ b/src/upload-button.tsx @@ -17,7 +17,7 @@ * along with Cockpit; If not, see . */ -import React, { useState, useRef } from "react"; +import React, { useRef, useCallback, useContext, useEffect } from "react"; import { AlertVariant, AlertActionLink } from "@patternfly/react-core/dist/esm/components/Alert"; import { Button } from "@patternfly/react-core/dist/esm/components/Button"; @@ -40,6 +40,7 @@ import { fmt_to_fragments } from "utils"; import { FolderFileInfo, useFilesContext } from "./app.tsx"; import { permissionShortStr } from "./common.ts"; import { edit_permissions } from "./dialogs/permissions.tsx"; +import { UploadContext } from "./files-folder-view.tsx"; import { get_owner_candidates } from "./ownership.tsx"; import "./upload-button.scss"; @@ -158,8 +159,7 @@ export const UploadButton = ({ const { addAlert, removeAlert, cwdInfo } = useFilesContext(); const dialogs = useDialogs(); const [showPopover, setPopover] = React.useState(false); - const [uploadedFiles, setUploadedFiles] = useState<{[name: string]: - {file: File, progress: number, cancel:() => void}}>({}); + const { uploadedFiles, setUploadedFiles } = useContext(UploadContext); const handleClick = () => { if (ref.current) { @@ -175,8 +175,7 @@ export const UploadButton = ({ event.returnValue = true; }; - const onUpload = async (event: React.ChangeEvent) => { - cockpit.assert(event.target.files, "not an ?"); + const onUpload = useCallback(async (files: FileList, event?: React.ChangeEvent) => { cockpit.assert(cwdInfo?.entries, "cwdInfo.entries is undefined"); let next_progress = 0; let owner = null; @@ -193,14 +192,15 @@ export const UploadButton = ({ const resetInput = () => { // Reset input field in the case a download was cancelled and has to be re-uploaded // https://stackoverflow.com/questions/26634616/filereader-upload-same-file-again-not-working - event.target.value = ""; + if (event) + event.target.value = ""; }; let resolution; let replaceAll = false; let skipAll = false; - for (let i = 0; i < event.target.files.length; i++) { - const uploadFile = event.target.files[i]; + for (let i = 0; i < files.length; i++) { + const uploadFile = files[i]; const file = cwdInfo?.entries[uploadFile.name]; if (replaceAll) @@ -210,7 +210,7 @@ export const UploadButton = ({ } else if (file) { try { resolution = await dialogs.run(FileConflictDialog, { - path, file, uploadFile, isMultiUpload: event.target.files.length > 1 + path, file, uploadFile, isMultiUpload: files.length > 1 }); } catch (_exc) { // eslint-disable-line @typescript-eslint/no-unused-vars resetInput(); @@ -396,7 +396,26 @@ export const UploadButton = ({ addAlert(title, AlertVariant.success, key, description, action); } - }; + }, [ + addAlert, + removeAlert, + cwdInfo, + dialogs, + path, + setUploadedFiles, + ]); + + useEffect(() => { + const handleFilesDrop = ((event: CustomEvent) => { + onUpload(event.detail); + }) as EventListener; + + window.addEventListener("files-drop", handleFilesDrop); + + return () => { + window.removeEventListener("files-drop", handleFilesDrop); + }; + }, [path, cwdInfo, onUpload]); const isUploading = Object.keys(uploadedFiles).length !== 0; let popover; @@ -479,7 +498,10 @@ export const UploadButton = ({ type="file" hidden multiple - onChange={onUpload} + onChange={(event: React.ChangeEvent) => { + cockpit.assert(event?.target.files, "not an ?"); + onUpload(event.target.files, event); + }} /> ); diff --git a/test/check-application b/test/check-application index 37f330d1..ad4e4582 100755 --- a/test/check-application +++ b/test/check-application @@ -979,7 +979,7 @@ class TestFiles(testlib.MachineCase): # Opening context menu from empty space deselects item b.click("[data-item='newdir']") - b.mouse("#files-card-parent tbody", "contextmenu", body_size[1] / 2, body_size[1] / 2) + b.mouse("#files-card-parent tbody", "contextmenu", body_size[1] / 4, body_size[1] / 4) b.assert_pixels(".contextMenu", "overview-context-menu") b.click(".contextMenu button:contains('Create directory')") b.set_input_text("#create-directory-input", "newdir2") @@ -2358,6 +2358,114 @@ class TestFiles(testlib.MachineCase): b.click(".pf-v5-c-alert__action button") b.wait_not_present(".pf-v5-c-alert__action") + b.click("button[aria-label='Display as a list']") + b.go("/files#/?path=/home/admin") + + # Test drag & drop upload + b.eval_js(''' + function dragFileEvent(sel, eventType, filename) { + const el = window.ph_find(sel); + + let dataTransfer = null; + if (filename) { + // Synthetize a file DataTransfer action + const content = "Random file content: 4"; + const file = new File([content], filename, { type: "text/plain" }); + dataTransfer = new DataTransfer(); + dataTransfer.dropEffect = "move"; + + Object.defineProperty(dataTransfer, 'files', { + value: [file], + writable: false, + }); + } + + const ev = new DragEvent(eventType, { bubbles: true, dataTransfer: dataTransfer }); + el.dispatchEvent(ev); + }''') + + def drag_file_event(selector: str, dragType: str, filename: str | None = None) -> None: + b.wait_visible(selector) + b.call_js_func('dragFileEvent', selector, dragType, filename) + + b.wait_not_present(".drag-drop-upload") + drag_file_event(".fileview-wrapper", 'dragenter') + b.wait_visible(".drag-drop-upload") + b.assert_pixels(".files-card", "drag-drop-upload-dropzone", + mock={".item-date": "Jun 19, 2024, 11:30 AM"}) + drag_file_event(".fileview-wrapper", 'drop', 'drag-drop-testfile.txt') + b.wait_in_text(".pf-v5-c-alert__title", "File uploaded") + b.click(".pf-v5-c-alert__action button") + b.wait_visible("[data-item='drag-drop-testfile.txt']") + b.wait_not_present(".pf-v5-c-alert__action") + b.wait_not_present(".drag-drop-upload") + + # Upload same file again - warning should show up + drag_file_event(".fileview-wrapper", 'dragenter') + b.wait_visible(".drag-drop-upload") + drag_file_event(".fileview-wrapper", 'dragover') + drag_file_event(".fileview-wrapper", 'drop', 'drag-drop-testfile.txt') + b.wait_in_text("h1.pf-v5-c-modal-box__title", "Replace file drag-drop-testfile.txt?") + b.click(".pf-v5-c-modal-box button.pf-m-link") + b.wait_not_present(".pf-v5-c-modal-box") + + # Drag file around to test if upload icon shows up and hides accordingly + drag_file_event(".fileview-wrapper", 'dragenter') + b.wait_visible(".drag-drop-upload") + drag_file_event(".fileview-wrapper", 'dragleave') + b.wait_not_present(".drag-drop-upload") + + drag_file_event(".fileview-wrapper", 'dragenter') + b.wait_visible(".drag-drop-upload") + # Hover over element which is a child of .fileview-wrapper + drag_file_event(".fileview-wrapper [data-item='Downloads'] .item-name", 'dragenter') + drag_file_event(".fileview-wrapper", 'dragleave') + b.wait_visible(".drag-drop-upload") + drag_file_event(".fileview-wrapper [data-item='project'] .item-name", 'dragenter') + drag_file_event(".fileview-wrapper [data-item='Downloads'] .item-name", 'dragleave') + b.wait_visible(".drag-drop-upload") + # Drag away from the .fileview-wrapper + drag_file_event(".fileview-wrapper [data-item='project'] .item-name", 'dragleave') + b.wait_not_present(".drag-drop-upload") + drag_file_event(".fileview-wrapper", 'dragenter') + b.wait_visible(".drag-drop-upload") + drag_file_event(".fileview-wrapper", 'dragleave') + b.wait_not_present(".drag-drop-upload") + + # Drag & drop is disabled when upload is in progress + b.click("#upload-file-btn") + b.upload_files("#upload-file-btn + input[type='file']", [big_file]) + b.wait_visible("#upload-file-btn:disabled") + b.wait_not_present(".drag-drop-upload") + drag_file_event(".fileview-wrapper", 'dragenter') + b.wait_visible(".drag-drop-upload-blocked") + b.assert_pixels(".files-card", "drag-drop-upload-dropzone-blocked", + mock={".item-date": "Jun 19, 2024, 11:30 AM"}) + drag_file_event(".fileview-wrapper", 'dragleave') + b.wait_not_present(".drag-drop-upload-blocked") + # Dropping the file does nothing + drag_file_event(".fileview-wrapper", 'dragenter') + b.wait_visible(".drag-drop-upload-blocked") + drag_file_event(".fileview-wrapper", 'drop', 'drag-drop-file-2.txt') + b.wait_not_present(".drag-drop-upload") + b.click("#upload-progress-btn") + b.wait_in_text(".upload-progress-0", "bigfile.img") + b.wait_not_present("[data-item='drag-drop-file-2.txt']") + b.click("#upload-progress-btn") + b.click(".cancel-button-0") + b.click(".pf-v5-c-alert__action button") + + # Change directory and upload + b.mouse("[data-item='project']", "dblclick") + drag_file_event(".fileview-wrapper", 'dragenter') + b.wait_visible(".drag-drop-upload") + drag_file_event(".fileview-wrapper", 'drop', 'drag-drop-testfile.txt') + b.wait_in_text(".pf-v5-c-alert__title", "File uploaded") + b.click(".pf-v5-c-alert__action button") + b.wait_visible("[data-item='drag-drop-testfile.txt']") + b.wait_not_present(".pf-v5-c-alert__action") + b.wait_not_present(".drag-drop-upload") + def testFileTypes(self) -> None: m = self.machine b = self.browser diff --git a/test/reference b/test/reference index 448e946b..0887b882 160000 --- a/test/reference +++ b/test/reference @@ -1 +1 @@ -Subproject commit 448e946b659a233394b5825aefdcfaa08c916a0a +Subproject commit 0887b88263e054d3aef25f1fd78a4d0a4d0fb295