Merge "File contents control for settings tab"
This commit is contained in:
commit
cc20945b91
@ -44,7 +44,17 @@ structure includes the following attributes::
|
||||
* *label* is a setting title that is displayed on UI
|
||||
* *weight* defines the order in which this setting is displayed in its group.
|
||||
This attribute is desirable
|
||||
* *type* defines the type of UI control to use for the setting
|
||||
* *type* defines the type of UI control to use for the setting. The following types are supported:
|
||||
|
||||
* *text* - single line input
|
||||
* *password* - password input
|
||||
* *textarea* - multiline input
|
||||
* *checkbox* - multiple-options selector
|
||||
* *radio* - single-option selector
|
||||
* *select* - drop-down list
|
||||
* *hidden* - invisible input
|
||||
* *file* - file contents input
|
||||
|
||||
* *regex* section is applicable for settings of "text" type. "regex.source"
|
||||
is used when validating with a regular expression. "regex.error" contains
|
||||
a warning displayed near invalid field
|
||||
|
@ -22,7 +22,8 @@
|
||||
"open-sans-fontface": "1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"es5-shim": "4.1.0"
|
||||
"es5-shim": "4.1.0",
|
||||
"sinon": "1.15.3"
|
||||
},
|
||||
"overrides": {
|
||||
"react": {
|
||||
@ -56,6 +57,13 @@
|
||||
"dist/css/bootstrap.css.map",
|
||||
"dist/fonts/*"
|
||||
]
|
||||
},
|
||||
"sinon": {
|
||||
"main": [
|
||||
"lib/sinon.js",
|
||||
"lib/sinon/*",
|
||||
"lib/sinon/util/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"ignore": [
|
||||
|
@ -77,6 +77,7 @@ attribute_schema = {
|
||||
'select',
|
||||
'text',
|
||||
'textarea',
|
||||
'file',
|
||||
]
|
||||
},
|
||||
#'value': None, # custom validation depending on type
|
||||
|
@ -41,7 +41,8 @@ define(function() {
|
||||
i18next: 'vendor/bower/i18next/release/i18next-1.7.1',
|
||||
deepModel: 'vendor/custom/deep-model',
|
||||
lessLibrary: 'vendor/bower/less/dist/less',
|
||||
'require-css': 'vendor/bower/require-css'
|
||||
'require-css': 'vendor/bower/require-css',
|
||||
sinon: 'vendor/bower/sinon/lib/sinon'
|
||||
},
|
||||
shim: {
|
||||
'expression/parser': {
|
||||
|
@ -1918,6 +1918,14 @@ button, .btn:not(.btn-link) {.font-semibold;}
|
||||
input, textarea {
|
||||
float: left;
|
||||
}
|
||||
input[type=file] {
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
}
|
||||
&:not(.disabled) input.file-name[readonly] {
|
||||
background-color: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
input, textarea, select {
|
||||
max-width: @default-input-width;
|
||||
width: @default-input-width;
|
||||
|
92
nailgun/static/tests/unit/file_control.js
Normal file
92
nailgun/static/tests/unit/file_control.js
Normal file
@ -0,0 +1,92 @@
|
||||
define([
|
||||
'intern!object',
|
||||
'intern/chai!assert',
|
||||
'underscore',
|
||||
'sinon'
|
||||
], function(registerSuite, assert, _, sinon) {
|
||||
'use strict';
|
||||
|
||||
var input;
|
||||
|
||||
registerSuite({
|
||||
name: 'File Control',
|
||||
|
||||
beforeEach: function() {
|
||||
var controls = require('views/controls');
|
||||
|
||||
input = new controls.Input({
|
||||
type: 'file',
|
||||
name: 'some_file',
|
||||
label: 'Please select some file',
|
||||
description: 'File should be selected from the local disk',
|
||||
disabled: false,
|
||||
onChange: sinon.spy(),
|
||||
defaultValue: {
|
||||
name: 'certificate.crt',
|
||||
content: 'CERTIFICATE'
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
Initialization: function() {
|
||||
var initialState = input.getInitialState();
|
||||
|
||||
assert.equal(input.props.type, 'file', 'Input type should be equal to file');
|
||||
assert.equal(initialState.fileName, 'certificate.crt', 'Default file name must correspond to provided one');
|
||||
assert.equal(initialState.content, 'CERTIFICATE', 'Content should be equal to the default');
|
||||
},
|
||||
|
||||
'File selection': function() {
|
||||
var clickSpy = sinon.spy();
|
||||
|
||||
sinon.stub(input, 'getInputDOMNode').returns({
|
||||
click: clickSpy
|
||||
});
|
||||
|
||||
input.pickFile();
|
||||
assert.ok(clickSpy.calledOnce, 'When icon clicked input control should be clicked too to open select file dialog');
|
||||
},
|
||||
|
||||
'File fetching': function() {
|
||||
var readMethod = sinon.mock(),
|
||||
readerObject = {
|
||||
readAsBinaryString: readMethod,
|
||||
result: 'File contents'
|
||||
},
|
||||
saveMethod = sinon.spy(input, 'saveFile');
|
||||
|
||||
window.FileReader = function() {return readerObject};
|
||||
|
||||
sinon.stub(input, 'getInputDOMNode').returns({
|
||||
value: '/dummy/path/to/somefile.ext',
|
||||
files: ['file1']
|
||||
});
|
||||
|
||||
input.readFile();
|
||||
|
||||
assert.ok(readMethod.calledOnce, 'File reading as binary expected to be executed once');
|
||||
sinon.assert.calledWith(readMethod, 'file1');
|
||||
|
||||
readerObject.onload();
|
||||
assert.ok(saveMethod.calledOnce, 'saveFile handler called once');
|
||||
sinon.assert.calledWith(saveMethod, 'somefile.ext', 'File contents');
|
||||
},
|
||||
|
||||
'File saving': function() {
|
||||
var setState = sinon.spy(input, 'setState'),
|
||||
dummyName = 'dummy.ext',
|
||||
dummyContent = 'Lorem ipsum dolores';
|
||||
input.saveFile(dummyName, dummyContent);
|
||||
|
||||
assert.deepEqual(setState.args[0][0], {
|
||||
fileName: dummyName,
|
||||
content: dummyContent
|
||||
}, 'Save file must update control state with data supplied');
|
||||
|
||||
assert.deepEqual(input.props.onChange.args[0][1], {
|
||||
name: dummyName,
|
||||
content: dummyContent
|
||||
}, 'Control sends updated data upon changes');
|
||||
}
|
||||
});
|
||||
});
|
@ -47,6 +47,11 @@
|
||||
"recommended": "It is recommended to have at least __limitValue__ of __roleName__ nodes (__count__ selected currently)."
|
||||
}
|
||||
},
|
||||
"controls": {
|
||||
"file": {
|
||||
"placeholder": "No file selected"
|
||||
}
|
||||
},
|
||||
"node_details": {
|
||||
"cpu": "CPU",
|
||||
"hdd": "HDD",
|
||||
|
@ -20,7 +20,8 @@
|
||||
* Based on https://github.com/react-bootstrap/react-bootstrap/blob/master/src/Input.jsx
|
||||
**/
|
||||
|
||||
define(['jquery', 'underscore', 'react', 'utils', 'jsx!component_mixins'], function($, _, React, utils, componentMixins) {
|
||||
define(['i18n', 'jquery', 'underscore', 'react', 'utils', 'jsx!component_mixins'],
|
||||
function(i18n, $, _, React, utils, componentMixins) {
|
||||
'use strict';
|
||||
|
||||
var controls = {};
|
||||
@ -52,7 +53,7 @@ define(['jquery', 'underscore', 'react', 'utils', 'jsx!component_mixins'], funct
|
||||
controls.Input = React.createClass({
|
||||
mixins: [tooltipMixin],
|
||||
propTypes: {
|
||||
type: React.PropTypes.oneOf(['text', 'password', 'textarea', 'checkbox', 'radio', 'select', 'hidden', 'number', 'range']).isRequired,
|
||||
type: React.PropTypes.oneOf(['text', 'password', 'textarea', 'checkbox', 'radio', 'select', 'hidden', 'number', 'range', 'file']).isRequired,
|
||||
name: React.PropTypes.node,
|
||||
label: React.PropTypes.node,
|
||||
description: React.PropTypes.node,
|
||||
@ -66,7 +67,11 @@ define(['jquery', 'underscore', 'react', 'utils', 'jsx!component_mixins'], funct
|
||||
extraContent: React.PropTypes.node
|
||||
},
|
||||
getInitialState: function() {
|
||||
return {visible: false};
|
||||
return {
|
||||
visible: false,
|
||||
fileName: this.props.defaultValue && this.props.defaultValue.name || null,
|
||||
content: this.props.defaultValue && this.props.defaultValue.content || null
|
||||
};
|
||||
},
|
||||
togglePassword: function() {
|
||||
this.setState({visible: !this.state.visible});
|
||||
@ -83,10 +88,41 @@ define(['jquery', 'underscore', 'react', 'utils', 'jsx!component_mixins'], funct
|
||||
debouncedInput: _.debounce(function() {
|
||||
return this.onInput();
|
||||
}, 10, {leading: true}),
|
||||
pickFile: function() {
|
||||
this.getInputDOMNode().click();
|
||||
},
|
||||
saveFile: function(fileName, content) {
|
||||
this.setState({
|
||||
fileName: fileName,
|
||||
content: content
|
||||
});
|
||||
return this.props.onChange(
|
||||
this.props.name,
|
||||
{name: fileName, content: content}
|
||||
);
|
||||
},
|
||||
removeFile: function() {
|
||||
this.refs.form.getDOMNode().reset();
|
||||
this.saveFile(null, null);
|
||||
},
|
||||
readFile: function() {
|
||||
var reader = new FileReader(),
|
||||
input = this.getInputDOMNode();
|
||||
|
||||
if (input.files.length) {
|
||||
reader.onload = (function() {
|
||||
return this.saveFile(input.value.replace(/^.*[\\\/]/g, ''), reader.result);
|
||||
}).bind(this);
|
||||
reader.readAsBinaryString(input.files[0]);
|
||||
}
|
||||
},
|
||||
onChange: function() {
|
||||
if (this.props.onChange) {
|
||||
var input = this.getInputDOMNode();
|
||||
return this.props.onChange(this.props.name, this.props.type == 'checkbox' ? input.checked : input.value);
|
||||
return this.props.onChange(
|
||||
this.props.name,
|
||||
this.props.type == 'checkbox' ? input.checked : input.value
|
||||
);
|
||||
}
|
||||
},
|
||||
onInput: function() {
|
||||
@ -106,6 +142,8 @@ define(['jquery', 'underscore', 'react', 'utils', 'jsx!component_mixins'], funct
|
||||
};
|
||||
if (this.props.type == 'range') {
|
||||
props.onInput = this.debouncedInput;
|
||||
} else if (this.props.type == 'file') {
|
||||
props.onChange = this.readFile;
|
||||
} else {
|
||||
// debounced onChange callback is supported for uncontrolled inputs
|
||||
props.onChange = (_.isUndefined(this.props.value) && _.isUndefined(this.props.checked)) ? this.debouncedChange : this.onChange;
|
||||
@ -118,9 +156,26 @@ define(['jquery', 'underscore', 'react', 'utils', 'jsx!component_mixins'], funct
|
||||
'custom-tumbler': isCheckboxOrRadio,
|
||||
textarea: this.props.type == 'textarea'
|
||||
};
|
||||
if (this.props.type == 'file') {
|
||||
input = <form ref='form'>{input}</form>;
|
||||
}
|
||||
return (
|
||||
<div key='input-group' className={utils.classNames(inputWrapperClasses)}>
|
||||
{input}
|
||||
{this.props.type == 'file' &&
|
||||
<div className='input-group'>
|
||||
<input
|
||||
className='form-control file-name'
|
||||
type='text'
|
||||
placeholder={i18n('controls.file.placeholder')}
|
||||
value={this.state.fileName && '[' + utils.showSize(this.state.content.length) + '] ' + this.state.fileName}
|
||||
onClick={this.pickFile}
|
||||
readOnly />
|
||||
<div className='input-group-addon' onClick={this.state.fileName ? this.removeFile : this.pickFile}>
|
||||
<i className={this.state.fileName ? 'glyphicon glyphicon-remove' : 'glyphicon glyphicon-file'} />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
{this.props.toggleable &&
|
||||
<div className='input-group-addon' onClick={this.togglePassword}>
|
||||
<i className={this.state.visible ? 'glyphicon glyphicon-eye-close' : 'glyphicon glyphicon-eye-open'} />
|
||||
|
Loading…
Reference in New Issue
Block a user