Skip to content

Commit

Permalink
Demo multiple upload
Browse files Browse the repository at this point in the history
  • Loading branch information
jelly authored and allisonkarlitskaya committed Apr 5, 2024
1 parent ae563bf commit 9929778
Show file tree
Hide file tree
Showing 7 changed files with 378 additions and 3 deletions.
157 changes: 157 additions & 0 deletions pkg/lib/cockpit-components-upload.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
/*
* This file is part of Cockpit.
*
* Copyright (C) 2024 Red Hat, Inc.
*
* Cockpit is free software; you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation; either version 2.1 of the License, or
* (at your option) any later version.
*
* Cockpit is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Cockpit; If not, see <http://www.gnu.org/licenses/>.
*/
import cockpit from "cockpit";

const BLOCK_SIZE = 16 * 1024;
const TOTAL_FRAME_WINDOW = 128;
/*
* Something smart about upload
*/
export class UploadHelper {
constructor(destination, onProgress) {
this.destination = destination;
this.progressCallback = onProgress;
this.count = TOTAL_FRAME_WINDOW;

// Promise resolvers
this.resolveClose = null;
this.resolveFlush = null;
this.resolveHandler = null;

this.channel = cockpit.channel({
binary: true,
payload: "fsreplace1",
path: this.destination,
superuser: "try",
"send-acks": "frames"
});
this.channel.addEventListener("control", this.on_control.bind(this));
}

// Private methods
on_control(event, message) {
if (message?.command === "send-acks") {
this.count += message.frames;

console.assert(this.count <= TOTAL_FRAME_WINDOW, "queue size too big", this.count);

if (this.resolveHandler) {
this.resolveHandler();
this.resolveHandler = null;
}

if (this.count === TOTAL_FRAME_WINDOW) {
if (this.resolveFlush) {
this.resolveFlush();
this.resolveFlush = null;
}
}
}
}

read_chunk(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (event) => resolve(event.target.result);
reader.onerror = reject;
reader.readAsArrayBuffer(blob);
});
}

async write(chunk) {
console.assert(this.count >= 0, "ack count went negative", this.count);
this.count -= 1;
this.channel.send(chunk);

if (this.count > 0) {
return Promise.resolve(true);
} else {
return new Promise((resolve, reject) => {
this.resolveHandler = resolve;
});
}
}

async close() {
const channel = this.channel;
return new Promise((resolve, reject) => {
channel.addEventListener("close", function(event, message) {
if (message.problem) {
reject(message);
} else {
resolve(message);
}
});
channel.control({ command: "done" });
});
}

// Public methods

// Flush is waiting on acks in flight
flush() {
if (this.count === TOTAL_FRAME_WINDOW) {
return Promise.resolve(true);
} else {
return new Promise((resolve, reject) => {
this.resolveFlush = resolve;
});
}
}

async upload(file) {
const promise = new Promise((resolve, reject) => {
this.channel.addEventListener("close", (event, message) => {
if (message.tag) {
resolve(message);
} else {
reject(message);
}
});
});

let chunk_start = 0;
let send_chunks = 0;

while (this.channel.valid && chunk_start <= file.size) {
const chunk_next = chunk_start + BLOCK_SIZE;
const blob = file.slice(chunk_start, chunk_next);

const chunk = await this.read_chunk(blob);
await this.write(chunk);

send_chunks += blob.size;
this.progressCallback(send_chunks);

chunk_start = chunk_next;
}

if (this.channel.valid) {
this.flush();
this.channel.control({ command: "done" });
}

return promise;
}

cancel() {
// Automatically handles reject for us
this.channel.close();
}
}
117 changes: 117 additions & 0 deletions pkg/playground/react-demo-file-upload.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/*
* This file is part of Cockpit.
*
* Copyright (C) 2024 Red Hat, Inc.
*
* Cockpit is free software; you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation; either version 2.1 of the License, or
* (at your option) any later version.
*
* Cockpit is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Cockpit; If not, see <http://www.gnu.org/licenses/>.
*/

import cockpit from "cockpit";
import React, { useRef, useState } from "react";
import { createRoot } from 'react-dom/client';

import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js";
import { Flex } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
import { Progress } from "@patternfly/react-core/dist/esm/components/Progress/index.js";
import { TimesIcon, UploadIcon } from "@patternfly/react-icons";

import { FileAutoComplete } from "cockpit-components-file-autocomplete.jsx";
import { UploadHelper } from "cockpit-components-upload";

const _ = cockpit.gettext;

export const UploadButton = () => {
const ref = useRef();
const [files, setFiles] = useState({});
const [dest, setDest] = useState("/home/admin/");

const handleClick = () => {
ref.current.click();
};

const handleProgressIndex = (file, progress) => {
setFiles(oldFiles => {
const oldFile = oldFiles[file.name];
return {
...oldFiles,
[file.name]: { ...oldFile, progress },
};
});
};

const onUpload = async event => {
await Promise.all(Array.from(event.target.files).map(async (file) => {
const helper = new UploadHelper(`${dest}${file.name}`, (progress) => handleProgressIndex(file, progress));

setFiles(oldFiles => {
return {
[file.name]: { file, progress: 0, cancel: helper.cancel.bind(helper) },
...oldFiles,
};
});

try {
const status = await helper.upload(file);
console.log("upload status", status);
} catch (exc) {
console.log("upload exception", exc);
}

setFiles(oldFiles => {
const copy = { ...oldFiles };
delete copy[file.name];
return copy;
});
}));

// In theory not needed
setFiles({});
};

return (
<>
<Flex direction={{ default: "column" }}>
<FileAutoComplete className="upload-file-dest" value={dest} onChange={setDest} />
<Button
id="upload-file-btn"
variant="secondary"
icon={<UploadIcon />}
isDisabled={Object.keys(files).length !== 0}
isLoading={Object.keys(files).length !== 0}
onClick={handleClick}
>
{_("Upload")}
</Button>
<input
ref={ref} type="file"
hidden multiple onChange={onUpload}
/>
</Flex>
{Object.keys(files).map((key, index) => {
const file = files[key];
return (
<React.Fragment key={index}>
<Progress className={`upload-progress-${index}`} key={file.file.name} value={file.progress} title={file.file.name} max={file.file.size} />
<Button className={`cancel-button-${index}`} icon={<TimesIcon />} onClick={file.cancel} />
</React.Fragment>
);
})}
</>
);
};

export const showUploadDemo = (rootElement) => {
const root = createRoot(rootElement);
root.render(<UploadButton />);
};
5 changes: 5 additions & 0 deletions pkg/playground/react-patterns.html
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ <h3>Dialogs</h3>
<h3>Cards</h3>
<div id="demo-cards"></div>
</section>

<section class="pf-v5-c-page__main-section pf-m-light">
<h3>Upload</h3>
<div id="demo-upload"></div>
</section>
</main>
</div>
</body>
Expand Down
4 changes: 4 additions & 0 deletions pkg/playground/react-patterns.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { show_modal_dialog } from "cockpit-components-dialog.jsx";

import { PatternDialogBody } from "./react-demo-dialog.jsx";
import { showCardsDemo } from "./react-demo-cards.jsx";
import { showUploadDemo } from "./react-demo-file-upload.jsx";
import { showFileAcDemo, showFileAcDemoPreselected } from "./react-demo-file-autocomplete.jsx";

/* -----------------------------------------------------------------------------
Expand Down Expand Up @@ -126,4 +127,7 @@ document.addEventListener("DOMContentLoaded", function() {

// Cards
showCardsDemo(document.getElementById('demo-cards'));

// Upload
showUploadDemo(document.getElementById('demo-upload'));
});
9 changes: 7 additions & 2 deletions test/common/testlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -301,10 +301,15 @@ def switch_to_top(self):
"""
self.cdp.set_frame(None)

def upload_file(self, selector: str, file: str):
def upload_files(self, selector: str, files: List[str]):
"""Upload a local file to the browser
The selector should select the <input type="file"/> element.
Files is a list of absolute paths to files which should be uploaded.
"""
r = self.cdp.invoke("Runtime.evaluate", expression='document.querySelector(%s)' % jsquote(selector))
objectId = r["result"]["objectId"]
self.cdp.invoke("DOM.setFileInputFiles", files=[file], objectId=objectId)
self.cdp.invoke("DOM.setFileInputFiles", files=files, objectId=objectId)

def raise_cdp_exception(self, func, arg, details, trailer=None):
# unwrap a typical error string
Expand Down
Loading

0 comments on commit 9929778

Please sign in to comment.