diff --git a/src/app.tsx b/src/app.tsx index b37c53fd..3879a39e 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -46,7 +46,8 @@ interface Alert { key: string, title: string, variant: AlertVariant, - detail?: string, + detail?: string | React.ReactNode, + actionLinks?: React.ReactNode } export interface FolderFileInfo extends FileInfo { @@ -56,12 +57,15 @@ export interface FolderFileInfo extends FileInfo { } interface FilesContextType { - addAlert: (title: string, variant: AlertVariant, key: string, detail?: string) => void, + addAlert: (title: string, variant: AlertVariant, key: string, detail?: string | React.ReactNode, + actionLinks?: React.ReactNode) => void, + removeAlert: (key: string) => void, cwdInfo: FileInfo | null, } export const FilesContext = React.createContext({ addAlert: () => console.warn("FilesContext not initialized"), + removeAlert: () => console.warn("FilesContext not initialized"), cwdInfo: null, } as FilesContextType); @@ -163,14 +167,23 @@ export const Application = () => { if (loading) return ; - const addAlert = (title: string, variant: AlertVariant, key: string, detail?: string) => { - setAlerts(prevAlerts => [...prevAlerts, { title, variant, key, ...detail && { detail }, }]); + const addAlert = (title: string, variant: AlertVariant, key: string, detail?: string | React.ReactNode, + actionLinks?: React.ReactNode) => { + setAlerts(prevAlerts => [ + ...prevAlerts, { + title, + variant, + key, + ...detail && { detail }, + ...actionLinks && { actionLinks }, + } + ]); }; const removeAlert = (key: string) => setAlerts(prevAlerts => prevAlerts.filter(alert => alert.key !== key)); return ( - + {alerts.map(alert => ( @@ -184,6 +197,7 @@ export const Application = () => { onClose={() => removeAlert(alert.key)} /> } + {...alert.actionLinks && { actionLinks: alert.actionLinks }} key={alert.key} > {alert.detail} diff --git a/src/upload-button.scss b/src/upload-button.scss index 8eb806be..c20950a4 100644 --- a/src/upload-button.scss +++ b/src/upload-button.scss @@ -79,3 +79,6 @@ button.cancel-button { } } +.ct-grey-text { + color: var(--pf-v5-global--Color--200); +} diff --git a/src/upload-button.tsx b/src/upload-button.tsx index a3e51e2c..724be4ba 100644 --- a/src/upload-button.tsx +++ b/src/upload-button.tsx @@ -19,7 +19,7 @@ import React, { useState, useRef } from "react"; -import { AlertVariant } from "@patternfly/react-core/dist/esm/components/Alert"; +import { AlertVariant, AlertActionLink } from "@patternfly/react-core/dist/esm/components/Alert"; import { Button } from "@patternfly/react-core/dist/esm/components/Button"; import { Checkbox } from "@patternfly/react-core/dist/esm/components/Checkbox"; import { Divider } from "@patternfly/react-core/dist/esm/components/Divider"; @@ -38,7 +38,8 @@ import { superuser } from "superuser"; import * as timeformat from "timeformat"; import { fmt_to_fragments } from "utils"; -import { useFilesContext } from "./app.tsx"; +import { FolderFileInfo, useFilesContext } from "./app.tsx"; +import { edit_permissions } from "./dialogs/permissions.tsx"; import { get_owner_candidates } from "./ownership.tsx"; import "./upload-button.scss"; @@ -51,6 +52,22 @@ interface ConflictResult { applyToAll: boolean; } +const UploadedFilesList = ({ + files, + owner, +}: { + files: File[], + owner: string, +}) => { + const title = files.length === 1 ? files[0].name : cockpit.format(_("$0 files"), files.length); + return ( + <> +

{title}

+ {owner &&

{cockpit.format(_("Uploaded as $0"), owner)}

} + + ); +}; + const FileConflictDialog = ({ path, file, @@ -134,7 +151,7 @@ export const UploadButton = ({ path: string, }) => { const ref = useRef(null); - const { addAlert, cwdInfo } = useFilesContext(); + const { addAlert, removeAlert, cwdInfo } = useFilesContext(); const dialogs = useDialogs(); const [showPopover, setPopover] = React.useState(false); const [user, setUser] = useState(); @@ -164,7 +181,7 @@ export const UploadButton = ({ cockpit.assert(cwdInfo?.entries, "cwdInfo.entries is undefined"); let next_progress = 0; let owner = null; - const toUploadFiles = []; + const toUploadFiles: File[] = []; // When we are superuser upload as the owner of the directory and allow // the user to later change ownership if required. @@ -226,6 +243,7 @@ export const UploadButton = ({ await Promise.allSettled(toUploadFiles.map(async (file: File) => { const destination = path + file.name; const abort = new AbortController(); + let options = { }; setUploadedFiles(oldFiles => { return { @@ -234,14 +252,13 @@ export const UploadButton = ({ }; }); - let dest_tag = null; if (owner !== null) { try { await cockpit.file(destination, { superuser: "try" }).replace(""); // TODO: cleanup might not work? await cockpit.spawn(["chown", owner, destination], { superuser: "try" }); await cockpit.file(destination, { superuser: "try" }).read() - .then(((async (_content: string, tag: string) => { - dest_tag = tag; + .then((((_content: string, tag: string) => { + options = { superuser: "try", tag }; }) as any /* eslint-disable-line @typescript-eslint/no-explicit-any */)); } catch (exc) { await cockpit.spawn(["rm", "-f", destination], { superuser: "require" }); @@ -253,6 +270,7 @@ export const UploadButton = ({ } } + console.log(destination, options); try { await upload(destination, file, (progress) => { const now = performance.now(); @@ -266,9 +284,14 @@ export const UploadButton = ({ [file.name]: { ...oldFile, progress }, }; }); - }, abort.signal, { superuser: "try", tag: dest_tag }); + }, abort.signal, options); } catch (exc) { cockpit.assert(exc instanceof Error, "Unknown exception type"); + + // Clean up touched file + if (owner) { + await cockpit.spawn(["rm", "-f", destination], { superuser: "require" }); + } if (exc instanceof DOMException && exc.name === 'AbortError') { addAlert(_("Cancelled"), AlertVariant.warning, "upload", cockpit.format(_("Cancelled upload of $0"), file.name)); @@ -290,7 +313,28 @@ export const UploadButton = ({ // If all uploads are cancelled, don't show an alert if (cancelledUploads.length !== toUploadFiles.length) { - addAlert(_("Upload complete"), AlertVariant.success, "upload-success", _("Successfully uploaded file(s)")); + const title = cockpit.ngettext(_("File uploaded"), _("Files uploaded"), toUploadFiles.length); + const key = window.btoa(toUploadFiles.join("")); + let description; + let action; + + if (owner !== null) { + description = ( + + ); + action = ( + { + removeAlert(key); + edit_permissions(dialogs, toUploadFiles, path); + }} + > + {_("Change permissions")} + + ); + } + + addAlert(title, AlertVariant.success, key, description, action); } }; diff --git a/test/check-application b/test/check-application index d9fb732f..f4e4c272 100755 --- a/test/check-application +++ b/test/check-application @@ -10,6 +10,7 @@ import shlex import subprocess import tempfile from pathlib import Path +from typing import List, Optional # import Cockpit's machinery for test VMs and its browser test API import testlib @@ -2051,6 +2052,21 @@ class TestFiles(testlib.MachineCase): def dir_file_count(directory: str) -> int: return int(m.execute(f"find {directory} -mindepth 1 -maxdepth 1 | wc -l").strip()) + def assert_upload_alert(files: List[str], owner_group: Optional[str] = None, *, close: bool = True) -> None: + if len(files) > 1: + b.wait_in_text(".pf-v5-c-alert__title", "Files uploaded") + b.wait_in_text(".pf-v5-c-alert__description", f"{len(files)} files") + else: + b.wait_in_text(".pf-v5-c-alert__title", "File uploaded") + b.wait_in_text(".pf-v5-c-alert__description", files[0]) + + if owner_group: + b.wait_in_text(".pf-v5-c-alert__description", f"Uploaded as {owner_group}") + + if close: + b.click(".pf-v5-c-alert__action button") + b.wait_not_present(".pf-v5-c-alert__action") + with tempfile.TemporaryDirectory() as tmpdir: # Test cancelling of upload big_file = str(Path(tmpdir) / "bigfile.img") @@ -2129,9 +2145,7 @@ class TestFiles(testlib.MachineCase): with b.wait_timeout(30): b.wait(lambda: int(m.execute(f"ls {dest_dir} | wc -l").strip()) == len(files)) - b.wait_in_text(".pf-v5-c-alert__description", "Successfully uploaded file") - b.click(".pf-v5-c-alert__action button") - b.wait_not_present(".pf-v5-c-alert__action") + assert_upload_alert(files, "admin:admin") # Verify ownership filename = os.path.basename(files[0]) @@ -2164,10 +2178,7 @@ class TestFiles(testlib.MachineCase): b.wait_in_text("h1.pf-v5-c-modal-box__title", f"Replace file {filename}?") b.click("button.pf-m-warning:contains('Replace')") b.wait_not_present(".pf-v5-c-modal-box") - - b.wait_in_text(".pf-v5-c-alert__description", "Successfully uploaded file") - b.click(".pf-v5-c-alert__action button") - b.wait_not_present(".pf-v5-c-alert__action") + assert_upload_alert([os.path.basename(files[0])], "admin:admin") self.assertEqual(m.execute(f"cat {dest_dir}/{filename}"), subprocess.check_output(["cat", files[0]]).decode()) @@ -2211,9 +2222,7 @@ class TestFiles(testlib.MachineCase): b.click("button.pf-m-warning:contains('Replace')") b.wait_not_present(".pf-v5-c-modal-box") - b.wait_in_text(".pf-v5-c-alert__description", "Successfully uploaded file") - b.click(".pf-v5-c-alert__action button") - b.wait_not_present(".pf-v5-c-alert__action") + assert_upload_alert(files, "admin:admin") verify_uploaded_files(changed=True) @@ -2235,7 +2244,9 @@ class TestFiles(testlib.MachineCase): b.click("button.pf-m-secondary:contains('Keep original')") b.wait_not_present(".pf-v5-c-modal-box") - b.wait_in_text(".pf-v5-c-alert__description", "Successfully uploaded file") + b.wait_in_text(".pf-v5-c-alert__title", "File uploaded") + b.wait_in_text(".pf-v5-c-alert__description", "newfile.txt") + b.wait_in_text(".pf-v5-c-alert__description", "Uploaded as admin:admin") b.click(".pf-v5-c-alert__action button") b.wait_not_present(".pf-v5-c-alert__action") @@ -2259,9 +2270,7 @@ class TestFiles(testlib.MachineCase): b.click("button.pf-m-secondary:contains('Keep original')") b.wait_not_present(".pf-v5-c-modal-box") - b.wait_in_text(".pf-v5-c-alert__description", "Successfully uploaded file") - b.click(".pf-v5-c-alert__action button") - b.wait_not_present(".pf-v5-c-alert__action") + assert_upload_alert([os.path.basename(files[-1])], "admin:admin") self.assertEqual(m.execute(f"cat {dest_dir}/{filename}"), "new content") @@ -2275,7 +2284,7 @@ class TestFiles(testlib.MachineCase): b.click("#upload-file-btn") b.upload_files("#upload-file-btn + input[type='file']", [files[-1]]) - b.wait_in_text(".pf-v5-c-alert__description", "Successfully uploaded file") + b.wait_in_text(".pf-v5-c-alert__title", "File uploaded") b.click(".pf-v5-c-alert__action button") b.wait_not_present(".pf-v5-c-alert__action")