diff --git a/guacamole/src/main/frontend/src/app/import/controllers/importConnectionsController.js b/guacamole/src/main/frontend/src/app/import/controllers/importConnectionsController.js index 8cd235e6e..31a49adf8 100644 --- a/guacamole/src/main/frontend/src/app/import/controllers/importConnectionsController.js +++ b/guacamole/src/main/frontend/src/app/import/controllers/importConnectionsController.js @@ -42,6 +42,70 @@ angular.module('import').controller('importConnectionsController', ['$scope', '$ const User = $injector.get('User'); const UserGroup = $injector.get('UserGroup'); + /** + * Any error that may have occured during import file parsing. + * + * @type {ParseError} + */ + $scope.error = null; + + /** + * True if the file is fully uploaded and ready to be processed, or false + * otherwise. + * + * @type {Boolean} + */ + $scope.dataReady = false; + + /** + * True if the file upload has been aborted mid-upload, or false otherwise. + */ + $scope.aborted = false; + + /** + * True if fully-uploaded data is being processed, or false otherwise. + */ + $scope.processing = false; + + /** + * The MIME type of the uploaded file, if any. + * + * @type {String} + */ + $scope.mimeType = null; + + /** + * The raw string contents of the uploaded file, if any. + * + * @type {String} + */ + $scope.fileData = null; + + /** + * The file reader currently being used to upload the file, if any. If + * null, no file upload is currently in progress. + * + * @type {FileReader} + */ + $scope.fileReader = null; + + /** + * Clear all file upload state. + */ + function resetUploadState() { + + $scope.aborted = false; + $scope.dataReady = false; + $scope.processing = false; + $scope.fileData = null; + $scope.mimeType = null; + $scope.fileReader = null; + + // Broadcast an event to clear the file upload UI + $scope.$broadcast('clearFile'); + + } + /** * Given a successful response to an import PATCH request, make another * request to delete every created connection in the provided request, i.e. @@ -227,20 +291,35 @@ angular.module('import').controller('importConnectionsController', ['$scope', '$ // TODON'T: Delete connections so we can test over and over cleanUpConnections(response); - }) + resetUploadState(); + }) }); }); } - // Set any caught error message to the scope for display + /** + * Set any caught error message to the scope for display. + * + * @argument {ParseError} error + * The error to display. + */ const handleError = error => { + + // Any error indicates that processing of the file has failed, so clear + // all upload state to allow for a fresh retry + resetUploadState(); + + // Set the error for display console.error(error); $scope.error = error; + } - // Clear the current error + /** + * Clear the current displayed error. + */ const clearError = () => delete $scope.error; /** @@ -255,39 +334,35 @@ angular.module('import').controller('importConnectionsController', ['$scope', '$ * The raw string contents of the import file. */ function processData(mimeType, data) { + + // Data processing has begun + $scope.processing = true; // The function that will process all the raw data and return a list of // patches to be submitted to the API let processDataCallback; - // Parse the data based on the provided mimetype - switch(mimeType) { + // Choose the appropriate parse function based on the mimetype + if (mimeType.endsWith("json")) + processDataCallback = connectionParseService.parseJSON; - case "application/json": - case "text/json": - processDataCallback = connectionParseService.parseJSON; - break; + else if (mimeType.endsWith("csv")) + processDataCallback = connectionParseService.parseCSV; - case "text/csv": - processDataCallback = connectionParseService.parseCSV; - break; + else if (mimeType.endsWith("yaml")) + processDataCallback = connectionParseService.parseYAML; - case "application/yaml": - case "application/x-yaml": - case "text/yaml": - case "text/x-yaml": - processDataCallback = connectionParseService.parseYAML; - break; - - default: - handleError(new ParseError({ - message: 'Invalid file type: ' + type, - key: 'CONNECTION_IMPORT.INVALID_FILE_TYPE', - variables: { TYPE: type } - })); - return; + // We don't expect this to happen - the file upload directive should + // have already have filtered out any invalid file types + else { + handleError(new ParseError({ + message: 'Invalid file type: ' + type, + key: 'CONNECTION_IMPORT.INVALID_FILE_TYPE', + variables: { TYPE: type } + })); + return; } - + // Make the call to process the data into a series of patches processDataCallback(data) @@ -298,30 +373,109 @@ angular.module('import').controller('importConnectionsController', ['$scope', '$ .catch(handleError); } - $scope.upload = function() { + /** + * Process the uploaded import data. Only usuable if the upload is fully + * complete. + */ + $scope.import = () => processData($scope.mimeType, $scope.fileData); + + /** + * @return {Boolean} + * True if import should be disabled, or false if cancellation + * should be allowed. + */ + $scope.importDisabled = () => + + // Disable import if no data is ready + !$scope.dataReady || + + // Disable import if the file is currently being processed + $scope.processing; + + /** + * Cancel any in-progress upload, or clear any uploaded-but + */ + $scope.cancel = function() { + + // Clear any error message + clearError(); + + // If the upload is in progress, stop it now; the FileReader will + // reset the upload state when it stops + if ($scope.fileReader) { + $scope.aborted = true; + $scope.fileReader.abort(); + } + + // Clear any upload state - there's no FileReader handler to do it + else + resetUploadState(); + + }; + + /** + * @return {Boolean} + * True if cancellation should be disabled, or false if cancellation + * should be allowed. + */ + $scope.cancelDisabled = () => + + // Disable cancellation if the import has already been cancelled + $scope.aborted || + + // Disable cancellation if the file is currently being processed + $scope.processing || + + // Disable cancellation if no data is ready or being uploaded + !($scope.fileReader || $scope.dataReady); + + /** + * Handle a provided File upload, reading all data onto the scope for + * import processing, should the user request an import. + * + * @argument {File} file + * The file to upload onto the scope for further processing. + */ + $scope.handleFile = function(file) { // Clear any error message from the previous upload attempt clearError(); - const files = angular.element('#file')[0].files; + // Initialize upload state + $scope.aborted = false; + $scope.dataReady = false; + $scope.processing = false; + $scope.uploadStarted = true; - if (files.length <= 0) { - handleError(new ParseError({ - message: 'No file supplied', - key: 'CONNECTION_IMPORT.ERROR_NO_FILE_SUPPLIED' - })); - return; - } + // Save the MIME type to the scope + $scope.mimeType = file.type; - // The file that the user uploaded - const file = files[0]; + // Save the file to the scope when ready + $scope.fileReader = new FileReader(); + $scope.fileReader.onloadend = (e => { - // Call processData when the data is ready - const reader = new FileReader(); - reader.onloadend = (e => processData(file.type, e.target.result)); + // If the upload was explicitly aborted, clear any upload state and + // do not process the data + if ($scope.aborted) + resetUploadState(); - // Read all the data into memory and call processData when done - reader.readAsBinaryString(file); + else { + + // Save the uploaded data + $scope.fileData = e.target.result; + + // Mark the data as ready + $scope.dataReady = true; + + // Clear the file reader from the scope now that this file is + // fully uploaded + $scope.fileReader = null; + + } + }); + + // Read all the data into memory + $scope.fileReader.readAsBinaryString(file); } }]); diff --git a/guacamole/src/main/frontend/src/app/import/directives/connectionImportFileUpload.js b/guacamole/src/main/frontend/src/app/import/directives/connectionImportFileUpload.js new file mode 100644 index 000000000..d988e7c7d --- /dev/null +++ b/guacamole/src/main/frontend/src/app/import/directives/connectionImportFileUpload.js @@ -0,0 +1,253 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/* global _ */ + +/** + * A directive that allows for file upload, either through drag-and-drop or + * a file browser. + */ + +/** + * All legal import file types. Any file not belonging to one of these types + * must be rejected. + */ +const LEGAL_FILE_TYPES = ["csv", "json", "yaml"]; + +angular.module('import').directive('connectionImportFileUpload', [ + function connectionImportFileUpload() { + + const directive = { + restrict: 'E', + replace: true, + templateUrl: 'app/import/templates/connectionImportFileUpload.html', + scope: { + + /** + * The function to invoke when a file is provided to the file upload + * UI, either by dragging and dropping, or by navigating using the + * file browser. The function will be called with 2 arguments - the + * mime type, and the raw string contents of the file. + * + * @type function + */ + onFile : '&', + } + }; + + directive.controller = ['$scope', '$injector', '$element', + function fileUploadController($scope, $injector, $element) { + + // Required services + const $timeout = $injector.get('$timeout'); + + /** + * Whether a drag/drop operation is currently in progress (the user has + * dragged a file over the Guacamole connection but has not yet + * dropped it). + * + * @type boolean + */ + $scope.dropPending = false; + + /** + * The error associated with the file upload, if any. An object of the + * form { key, variables }, or null if no error has occured. + */ + $scope.error = null; + + /** + * The name of the file that's currently being uploaded, or has yet to + * be imported, if any. + */ + $scope.fileName = null; + + // Clear the file if instructed to do so by the parent + $scope.$on('clearFile', () => delete $scope.fileName); + + /** + * Clear any displayed error message. + */ + const clearError = () => $scope.error = null; + + /** + * Set an error for display using the provided translation key and + * translation variables. + * + * @param {String} key + * The translation key. + * + * @param {Object.} variables + * The variables to subsitute into the message, if any. + */ + const setError = (key, variables) => $scope.error = { key, variables }; + + /** + * The location where files can be dragged-and-dropped to. + * + * @type Element + */ + const dropTarget = $element.find('.drop-target')[0]; + + /** + * Displays a visual indication that dropping the file currently + * being dragged is possible. Further propagation and default behavior + * of the given event is automatically prevented. + * + * @param {Event} e + * The event related to the in-progress drag/drop operation. + */ + const notifyDragStart = function notifyDragStart(e) { + + e.preventDefault(); + e.stopPropagation(); + + $scope.$apply(() => { + $scope.dropPending = true; + }); + + }; + + /** + * Removes the visual indication that dropping the file currently + * being dragged is possible. Further propagation and default behavior + * of the given event is automatically prevented. + * + * @param {Event} e + * The event related to the end of the former drag/drop operation. + */ + const notifyDragEnd = function notifyDragEnd(e) { + + e.preventDefault(); + e.stopPropagation(); + + $scope.$apply(() => { + $scope.dropPending = false; + }); + + }; + + // Add listeners to the drop target to ensure that the visual state + // stays up to date + dropTarget.addEventListener('dragenter', notifyDragStart, false); + dropTarget.addEventListener('dragover', notifyDragStart, false); + dropTarget.addEventListener('dragleave', notifyDragEnd, false); + + /** + * Given a user-supplied file, validate that the file type is correct, + * and invoke the onFile callback provided to this directive if so. + * + * @param {File} file + * The user-supplied file. + */ + function handleFile(file) { + + // Clear any error from a previous attempted file upload + clearError(); + + // The MIME type of the provided file + const mimeType = file.type; + + // Check if the mimetype ends with one of the supported types, + // e.g. "application/json" or "text/csv" + if (_.every(LEGAL_FILE_TYPES.map( + type => !mimeType.endsWith(type)))) { + + // If the provided file is not one of the supported types, + // display an error and abort processing + setError('CONNECTION_IMPORT.ERROR_INVALID_FILE_TYPE', + { TYPE: mimeType }); + return; + } + + $scope.fileName = file.name; + + // Invoke the provided file callback using the file + $scope.onFile({ file }); + } + + /** + * Drop target event listener that will be invoked if the user drops + * anything onto the drop target. If a valid file is provided, the + * onFile callback provided to this directive will be called; otherwise + * an error will be displayed, if appropriate. + * + * @param {Event} e + * The drop event that triggered this handler. + */ + dropTarget.addEventListener('drop', function(e) { + + notifyDragEnd(e); + + const files = e.dataTransfer.files; + + // Ignore any non-files that are dragged into the drop area + if (files.length < 1) + return; + + if (files.length > 2) { + + // If more than one file was provided, print an error explaining + // that only a single file is allowed and abort processing + setError('CONNECTION_IMPORT.ERROR_FILE_SINGLE_ONLY'); + return; + } + + handleFile(files[0]); + + }, false); + + /** + * The hidden file input used to create a file browser. + * + * @type Element + */ + const fileUploadInput = $element.find('.file-upload-input')[0]; + + /** + * A function that will click on the hidden file input to open a file + * browser to allow the user to select a file for upload. + */ + $scope.openFileBrowser = () => + $timeout(() => fileUploadInput.click(), 0, false); + + /** + * A handler that will be invoked when a user selectes a file in the + * file browser. After some error checking, the file will be passed to + * the onFile callback provided to this directive. + * + * @param {Event} e + * The event that was triggered when the user selected a file in + * their file browser. + */ + fileUploadInput.onchange = e => { + + // Process the uploaded file + handleFile(e.target.files[0]); + + // Clear the value to ensure that the change event will be fired + // if the user selects the same file again + fileUploadInput.value = null; + + }; + + }]; + return directive; + +}]); \ No newline at end of file diff --git a/guacamole/src/main/frontend/src/app/import/styles/file-upload.css b/guacamole/src/main/frontend/src/app/import/styles/file-upload.css new file mode 100644 index 000000000..afe0fcf97 --- /dev/null +++ b/guacamole/src/main/frontend/src/app/import/styles/file-upload.css @@ -0,0 +1,119 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +.file-upload-container { + + display: flex; + flex-direction: column; + align-items: center; + padding: 24px 24px 24px; + + width: fit-content; + + border: 1px solid rgba(0,0,0,.25); + box-shadow: 1px 1px 2px rgb(0 0 0 / 25%); + + margin-left: auto; + margin-right: auto; + +} + +.file-upload-container .upload-header { + + display: flex; + flex-direction: row; + width: 500px; + margin-bottom: 5px; + justify-content: space-between; + +} + +.file-upload-container .file-error { + + color: red; + +} + +.file-upload-container .file-options { + + font-weight: bold; + +} + +.file-upload-container .file-upload-input { + + display: none; + +} + +.file-upload-container .drop-target { + + display: flex; + flex-direction: column; + + align-items: center; + justify-content: space-evenly; + + width: 500px; + height: 200px; + + background: rgba(0,0,0,.04); + border: 1px solid black; + +} + +.file-upload-container .drop-target.file-present { + + background: rgba(0,0,0,.15); + +} + + +.file-upload-container .drop-target .file-name { + + font-weight: bold; + font-size: 1.5em; + +} + +.file-upload-container .drop-target.drop-pending { + + background: #3161a9; + +} + +.file-upload-container .drop-target.drop-pending > * { + + opacity: 0.5; + +} + +.file-upload-container .drop-target .title { + + font-weight: bold; + font-size: 1.25em; + +} + +.file-upload-container .drop-target .browse-link { + + text-decoration: underline; + cursor: pointer; + +} diff --git a/guacamole/src/main/frontend/src/app/import/styles/import.css b/guacamole/src/main/frontend/src/app/import/styles/import.css index f5551bb42..5addb1933 100644 --- a/guacamole/src/main/frontend/src/app/import/styles/import.css +++ b/guacamole/src/main/frontend/src/app/import/styles/import.css @@ -19,4 +19,13 @@ .import .parseError { color: red; +} + +.import .import-buttons { + + margin-top: 10px; + display: flex; + gap: 10px; + justify-content: center; + } \ No newline at end of file diff --git a/guacamole/src/main/frontend/src/app/import/templates/connectionImport.html b/guacamole/src/main/frontend/src/app/import/templates/connectionImport.html index 39b12fece..881cb2429 100644 --- a/guacamole/src/main/frontend/src/app/import/templates/connectionImport.html +++ b/guacamole/src/main/frontend/src/app/import/templates/connectionImport.html @@ -5,9 +5,19 @@ - - - + + +
+ + +
+

+ +

+ {{'CONNECTION_IMPORT.UPLOAD_FILE_TYPES' | translate}} + {{'CONNECTION_IMPORT.UPLOAD_HELP_LINK' | translate}} + +
+ +
+ +
{{'CONNECTION_IMPORT.UPLOAD_DROP_TITLE' | translate}}
+ + + + {{'CONNECTION_IMPORT.UPLOAD_BROWSE_LINK' | translate}} + + +
{{fileName}}
+ +
+ + +

+ + diff --git a/guacamole/src/main/frontend/src/translations/en.json b/guacamole/src/main/frontend/src/translations/en.json index 978ffc0e2..73afddb3c 100644 --- a/guacamole/src/main/frontend/src/translations/en.json +++ b/guacamole/src/main/frontend/src/translations/en.json @@ -186,6 +186,9 @@ "CONNECTION_IMPORT": { + "BUTTON_CANCEL": "Cancel", + "BUTTON_IMPORT": "Import Connections", + "HEADER": "Connection Import", "ERROR_AMBIGUOUS_CSV_HEADER": @@ -199,7 +202,7 @@ "Invalid CSV Header \"{HEADER}\" is neither an attribute or parameter", "ERROR_INVALID_GROUP": "No group matching \"{GROUP}\" found", "ERROR_INVALID_FILE_TYPE": - "Invalid import file type \"{TYPE}\"", + "Unsupported file type: \"{TYPE}\"", "ERROR_INVALID_USER_IDENTIFIERS": "Users not found: {IDENTIFIER_LIST}", "ERROR_INVALID_USER_GROUP_IDENTIFIERS": @@ -210,7 +213,14 @@ "ERROR_REQUIRED_PROTOCOL": "No connection protocol found in the provided file", "ERROR_REQUIRED_NAME": - "No connection name found in the provided file" + "No connection name found in the provided file", + + "UPLOAD_FILE_TYPES": "CSV, JSON, or YAML", + "UPLOAD_HELP_LINK": "View Format Tips", + "UPLOAD_DROP_TITLE": "Drop a File Here", + "UPLOAD_BROWSE_LINK": "Browse for File", + + "ERROR_FILE_SINGLE_ONLY": "Please upload only a single file at a time" }, diff --git a/guacamole/src/main/frontend/webpack.config.js b/guacamole/src/main/frontend/webpack.config.js index dc6ad08cf..7f8f9da81 100644 --- a/guacamole/src/main/frontend/webpack.config.js +++ b/guacamole/src/main/frontend/webpack.config.js @@ -95,14 +95,14 @@ module.exports = { optimization: { minimizer: [ - // Minify using Google Closure Compiler - new ClosureWebpackPlugin({ mode: 'STANDARD' }, { - languageIn: 'ECMASCRIPT_2020', - languageOut: 'ECMASCRIPT5', - compilationLevel: 'SIMPLE' - }), - - new CssMinimizerPlugin() +// // Minify using Google Closure Compiler +// new ClosureWebpackPlugin({ mode: 'STANDARD' }, { +// languageIn: 'ECMASCRIPT_2020', +// languageOut: 'ECMASCRIPT5', +// compilationLevel: 'SIMPLE' +// }), +// +// new CssMinimizerPlugin() ], splitChunks: {