GUACAMOLE-926: Create new file upload UI.

This commit is contained in:
James Muehlner
2023-02-16 01:30:55 +00:00
parent 51e0fb8c66
commit a1c1a58886
8 changed files with 641 additions and 56 deletions

View File

@@ -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;
/**
@@ -256,30 +335,26 @@ angular.module('import').controller('importConnectionsController', ['$scope', '$
*/
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) {
case "application/json":
case "text/json":
// Choose the appropriate parse function based on the mimetype
if (mimeType.endsWith("json"))
processDataCallback = connectionParseService.parseJSON;
break;
case "text/csv":
else if (mimeType.endsWith("csv"))
processDataCallback = connectionParseService.parseCSV;
break;
case "application/yaml":
case "application/x-yaml":
case "text/yaml":
case "text/x-yaml":
else if (mimeType.endsWith("yaml"))
processDataCallback = connectionParseService.parseYAML;
break;
default:
// 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',
@@ -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;
// Save the MIME type to the scope
$scope.mimeType = file.type;
// Save the file to the scope when ready
$scope.fileReader = new FileReader();
$scope.fileReader.onloadend = (e => {
// If the upload was explicitly aborted, clear any upload state and
// do not process the data
if ($scope.aborted)
resetUploadState();
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;
if (files.length <= 0) {
handleError(new ParseError({
message: 'No file supplied',
key: 'CONNECTION_IMPORT.ERROR_NO_FILE_SUPPLIED'
}));
return;
}
});
// The file that the user uploaded
const file = files[0];
// Call processData when the data is ready
const reader = new FileReader();
reader.onloadend = (e => processData(file.type, e.target.result));
// Read all the data into memory and call processData when done
reader.readAsBinaryString(file);
// Read all the data into memory
$scope.fileReader.readAsBinaryString(file);
}
}]);

View File

@@ -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.<String, String>} 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;
}]);

View File

@@ -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;
}

View File

@@ -20,3 +20,12 @@
.import .parseError {
color: red;
}
.import .import-buttons {
margin-top: 10px;
display: flex;
gap: 10px;
justify-content: center;
}

View File

@@ -5,8 +5,18 @@
<guac-user-menu></guac-user-menu>
</div>
<input type="file" id="file" name="file"/>
<button ng-click="upload()">Add</button>
<connection-import-file-upload on-file="handleFile(file)"></connection-import-file-upload>
<div class="import-buttons">
<button
ng-click="import()" ng-disabled="importDisabled()" class="import">
{{'CONNECTION_IMPORT.BUTTON_IMPORT' | translate}}
</button>
<button
ng-click="cancel()" ng-disabled="cancelDisabled()" class="cancel">
{{'CONNECTION_IMPORT.BUTTON_CANCEL' | translate}}
</button>
</div>
<!-- The translatable error message, if one is set -->
<p

View File

@@ -0,0 +1,30 @@
<div class="file-upload-container">
<div class="upload-header">
<span class="file-options">{{'CONNECTION_IMPORT.UPLOAD_FILE_TYPES' | translate}}</span>
<a
href="#/import/upload-help"
class="file-help-link">{{'CONNECTION_IMPORT.UPLOAD_HELP_LINK' | translate}}
</a>
</div>
<div class="drop-target" ng-class="{ 'drop-pending': dropPending, 'file-present': fileName}">
<div ng-show="!fileName" class="title">{{'CONNECTION_IMPORT.UPLOAD_DROP_TITLE' | translate}}</div>
<input type="file" class="file-upload-input"/>
<a ng-show="!fileName" ng-click="openFileBrowser()" class="browse-link">
{{'CONNECTION_IMPORT.UPLOAD_BROWSE_LINK' | translate}}
</a>
<div ng-show="fileName" class="file-name"> {{fileName}} </div>
</div>
<!-- The translatable error message regarding the provided file(s), if any -->
<p
class="file-error" ng-show="error.key"
translate="{{error.key}}" translate-values="{{error.variables}}"
></p>
</div>

View File

@@ -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"
},

View File

@@ -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: {