diff --git a/CHANGELOG.md b/CHANGELOG.md index 935ba8c3b900caf74fd0fe29acd75d08e6e10ac6..793f0fc744fa3971ed45868a26c4987e03c88add 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added ### +* File Upload component from @indiscale/caosdb-webui-core-components * Map component @indiscale/caosdb-webui-ext-map ### Changed ### diff --git a/package.json b/package.json index a13ecceda6a59729629f9750b624cb7f79804d09..070bccec473e6c1bf3f0a68647bbf08a1609c6d4 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,9 @@ { "dependencies": { - "@indiscale/caosdb-webui-core-components": "0.0.8", + "@indiscale/caosdb-webui-core-components": "0.0.9", "@indiscale/caosdb-webui-ext-map": "file:../caosdb-webui-ext-map/indiscale-caosdb-webui-ext-map-0.1.0.tgz", "react": "^18.2.0", - "react-bootstrap": "^2.7.2", + "react-bootstrap": "^2.8.0", "react-dom": "^18.2.0" }, "name": "@indiscale/caosdb-webui-legacy-adapter", diff --git a/src/file-upload.js b/src/file-upload.js new file mode 100644 index 0000000000000000000000000000000000000000..d2b45b6088cd45c8b7f9a4207a87be00de26f7c7 --- /dev/null +++ b/src/file-upload.js @@ -0,0 +1,340 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import "regenerator-runtime/runtime"; +import { FileUpload } from "@indiscale/caosdb-webui-core-components"; + +const get_filesystem_body = () => { + return document.querySelector("#caosdb-f-filesystem .card-body"); +}; + +const get_filesystem_header = () => { + return document.querySelector("#caosdb-f-filesystem .card-header"); +}; + +/** + * Return an array of the currently shown directory (file system view). + */ +const get_current_directory = () => { + const header = get_filesystem_header(); + if (!header) { + return []; + } + return Array.from(header.getElementsByClassName("caosdb-fs-cwd")).map( + (el) => el.textContent, + ); +}; + +/** + * Construct the XML payload for file insertion. + */ +const create_file_upload_request_xml = (files, targetDirectory, recordType) => { + const _parent = recordType ? `<Parent id="${recordType}"/>` : ""; + const request = + "<Request>" + + files.map((f, idx) => { + return `<File upload="${idx}-${f.name}" path="${targetDirectory}${f.name}">${_parent}</File>`; + }) + + "</Request>"; + + return request; +}; + +/** + * Put all the XML payload and the files into a form data object (for upload). + */ +const create_form_data = (xml, files) => { + const formData = new FormData(); + formData.append("FileRepresentation", xml); + files.forEach((f, idx) => { + const name = `${idx}-${f.name}`; + formData.append(name, f.file, name); + }); + return formData; +}; + +/** + * Split up the link to the file in the entity view. The two new links point to + * the file (for download) and to the parent directory (for browsing the file + * system). + */ +const split_parent_dir = (node) => { + repair_uri(node, "href"); + const path_old = node + .getAttribute("href") + .substring(connection.getFileSystemPath().length) + .split("/"); + if (path_old.length > 1) { + const parent_dir = path_old.slice(0, path_old.length - 1); + const parent_dir_node = node.cloneNode(); + parent_dir_node.setAttribute( + "href", + connection.getFileSystemPath() + parent_dir.join("/") + "/", + ); + parent_dir_node.textContent = parent_dir.join("/") + "/"; + parent_dir_node.title = "Go to parent directory."; + node.before(parent_dir_node); + node.dataset.entityPath = node.textContent; + node.textContent = node.textContent.split("/").slice(-1)[0]; + node.title = "Download this file"; + } +}; + +/** + * Repairs broken links (e.g. when files have strange names) + */ +const repair_uri = (node, attr) => { + const path_old = node + .getAttribute(attr) + .substring(connection.getFileSystemPath().length) + .split("/"); + const path_new = path_old.map(encodeURIComponent); + node.setAttribute(attr, connection.getFileSystemPath() + path_new.join("/")); +}; + +/** + * Perform the actual AJAX request (returns Promise). + */ +const uploadRequest = (formData) => { + return $.ajax({ + url: connection.getBasePath() + "Entity/", + method: "POST", + dataType: "xml", + contentType: false, + processData: false, + data: formData, + }); +}; + +/** + * This onSubmit implementation works for the legacy REST API. + */ +const defaultOnSubmit = async (data) => { + document.querySelector("#collapseFileUpload~ul")?.remove(); + var { files, directory, recordType } = data; + directory = directory || []; + directory = directory.length > 0 ? "/" + directory.join("/") + "/" : "/"; + + const xml = create_file_upload_request_xml(files, directory, recordType); + const formData = create_form_data(xml, files); + const response = await uploadRequest(formData); + + const iterator = response.evaluate( + "/Response/File", + response, + null, + XPathResult.UNORDERED_NODE_ITERATOR_TYPE, + ); + const newFiles = []; + + var next = iterator.iterateNext(); + var hasErrors = false; + while (next) { + var error = response + .evaluate("Error", next, null, XPathResult.UNORDERED_NODE_ITERATOR_TYPE) + ?.iterateNext(); + const path = response.evaluate( + "@path", + next, + null, + XPathResult.STRING_TYPE, + )?.stringValue; + const id = response.evaluate( + "@id", + next, + null, + XPathResult.STRING_TYPE, + )?.stringValue; + if (error) { + hasErrors = true; + error = response.evaluate( + "@description", + error, + null, + XPathResult.STRING_TYPE, + )?.stringValue; + } + newFiles.push({ + error: error, + id: id, + path: path, + url: !id || connection.getBasePath() + "Entity/" + id, + }); + next = iterator.iterateNext(); + } + return { + hasErrors: hasErrors, + url: connection.getBasePath() + "FileSystem/" + directory, + directory: directory, + newFiles: newFiles, + }; +}; + +/** + * Suitable for our purpose here means: Permission to USE:AS_PARENT is granted + * and the RecordType doesn't have any obligatory properties. + */ +const findSuitableRecordTypes = async () => { + const response = await connection.get("Entity/?query=FIND RECORDTYPE"); + const iterator = response.evaluate( + "/Response/RecordType", + response, + null, + XPathResult.UNORDERED_NODE_ITERATOR_TYPE, + ); + const results = []; + + var n; + while (true) { + n = iterator.iterateNext(); + if (!n) { + break; + } + const hasUsePermission = response + .evaluate( + "Permissions/Permission[@name='USE:AS_PARENT']", + n, + null, + XPathResult.UNORDERED_NODE_ITERATOR_TYPE, + ) + .iterateNext(); + if (!hasUsePermission) { + continue; + } + const hasObligatoryProperties = response + .evaluate( + "Property[@importance='OBLIGATORY']", + n, + null, + XPathResult.UNORDERED_NODE_ITERATOR_TYPE, + ) + .iterateNext(); + if (hasObligatoryProperties) { + continue; + } + results.push({ + name: response.evaluate("@name", n, null, XPathResult.STRING_TYPE) + ?.stringValue, + id: response.evaluate("@id", n, null, XPathResult.STRING_TYPE) + ?.stringValue, + description: response.evaluate( + "@description", + n, + null, + XPathResult.STRING_TYPE, + )?.stringValue, + }); + } + return results; +}; + +const file_upload = { + get_record_types: async function () { + // TODO add permissions to GRPC API, then use this again + //const service = new TransactionService(); + //const results = await service.executeQuery("FIND RECORDTYPE"); + //console.log(results); + return await findSuitableRecordTypes(); + }, + create_file_upload_widget: function (container, config) { + const root = ReactDOM.createRoot(container); + const _config = config || {}; + if (!_config.hasOwnProperty("onSubmit")) { + _config.onSubmit = defaultOnSubmit; + } + + if (!_config.hasOwnProperty("recordTypes")) { + _config.recordTypes = this.get_record_types().then((results) => + results.map((rt) => { + return { label: rt.name, value: rt.id, title: rt.description }; + }), + ); + } + + root.render( + <React.StrictMode> + <form> + <FileUpload {..._config} /> + </form> + </React.StrictMode>, + ); + }, + + /** + * This is the "+" button shown in the file system view. + */ + create_upload_button: () => { + const button = $(` + <button class="py-0 px-1 ms-2 btn btn-primary" type="button" data-bs-toggle="collapse" data-bs-target="#collapseFileUpload" aria-expanded="false" aria-controls="collapseFileUpload"> + <span class="bi bi-plus-lg"/> + </button>`); + + const _onClick = () => { + button[0].classList.add("d-none"); + }; + button.on("click", _onClick); + return button[0]; + }, + + init_file_upload: function () { + const container = $( + `<div class="row collapse mb-5" id="collapseFileUpload"/>`, + ); + const filesystem_header = + get_filesystem_header()?.querySelector("div.text-end"); + const filesystem_body = get_filesystem_body(); + + if (filesystem_body && filesystem_header) { + const button = this.create_upload_button(); + filesystem_header.appendChild(button); + + $(filesystem_body).prepend(container); + + const config = { + directoryReadOnly: false, + directoryBase: get_current_directory(), + onCancel: () => { + button.click(); + button.classList.remove("d-none"); + }, + onFinish: () => { + window.location.reload(); + }, + }; + this.create_file_upload_widget(container[0], config); + } + }, + + repair_file_system_links: function () { + // entity view + document + .querySelectorAll(".caosdb-entity-heading-attr a") + .forEach(split_parent_dir); + + // filesystem view + document + .querySelectorAll("img.entity-image-preview") + .forEach((node) => repair_uri(node, "src")); + document + .querySelectorAll("a.caosdb-fs-file") + .forEach((node) => repair_uri(node, "href")); + document + .querySelectorAll("a.caosdb-fs-dir") + .forEach((node) => repair_uri(node, "href")); + }, + + init: async function () { + this.init_file_upload(); + this.repair_file_system_links(); + + // this object can be used to create a widget, e.g. for creating custom + // upload forms. + window.caosdb_file_upload_widget = this; + }, +}; + +$(document).ready(function () { + const build = window.BUILD_MODULE_EXT_FILE_UPLOAD || "${BUILD_MODULE_EXT_FILE_UPLOAD}" + if (build === "ENABLED") { + caosdb_modules.register(file_upload); + } +}); diff --git a/src/map.js b/src/map.js index 4434b480b6fbbd055b349e9bef47a2059e65782c..f4e93ee11ee41cfb2610bfeef5dbeb8009363211 100644 --- a/src/map.js +++ b/src/map.js @@ -1,7 +1,5 @@ import React from "react"; import ReactDOM from "react-dom/client"; -import "regenerator-runtime/runtime"; -import { TransactionService } from "@indiscale/caosdb-webui-entity-service"; import { Map, ToggleMapButton } from "@indiscale/caosdb-webui-ext-map"; import { queryCallback } from "./queryCallback"; diff --git a/webpack.config.js b/webpack.config.js index d1b7db4d607faedf8e843f97723037302323b1ec..a922a91450151ed0cdbf81e3a065d7a67c1543e5 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -6,6 +6,7 @@ module.exports = { entry: { 'query-form' : './src/query-form.js', 'map' : './src/map.js', + 'file-upload': './src/file-upload.js', }, output: { path: path.resolve(__dirname, 'build'),