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
|
* *label* is a setting title that is displayed on UI
|
||||||
* *weight* defines the order in which this setting is displayed in its group.
|
* *weight* defines the order in which this setting is displayed in its group.
|
||||||
This attribute is desirable
|
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"
|
* *regex* section is applicable for settings of "text" type. "regex.source"
|
||||||
is used when validating with a regular expression. "regex.error" contains
|
is used when validating with a regular expression. "regex.error" contains
|
||||||
a warning displayed near invalid field
|
a warning displayed near invalid field
|
||||||
|
@ -22,7 +22,8 @@
|
|||||||
"open-sans-fontface": "1.4.0"
|
"open-sans-fontface": "1.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"es5-shim": "4.1.0"
|
"es5-shim": "4.1.0",
|
||||||
|
"sinon": "1.15.3"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"react": {
|
"react": {
|
||||||
@ -56,6 +57,13 @@
|
|||||||
"dist/css/bootstrap.css.map",
|
"dist/css/bootstrap.css.map",
|
||||||
"dist/fonts/*"
|
"dist/fonts/*"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"sinon": {
|
||||||
|
"main": [
|
||||||
|
"lib/sinon.js",
|
||||||
|
"lib/sinon/*",
|
||||||
|
"lib/sinon/util/*"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ignore": [
|
"ignore": [
|
||||||
|
@ -77,6 +77,7 @@ attribute_schema = {
|
|||||||
'select',
|
'select',
|
||||||
'text',
|
'text',
|
||||||
'textarea',
|
'textarea',
|
||||||
|
'file',
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
#'value': None, # custom validation depending on type
|
#'value': None, # custom validation depending on type
|
||||||
|
@ -41,7 +41,8 @@ define(function() {
|
|||||||
i18next: 'vendor/bower/i18next/release/i18next-1.7.1',
|
i18next: 'vendor/bower/i18next/release/i18next-1.7.1',
|
||||||
deepModel: 'vendor/custom/deep-model',
|
deepModel: 'vendor/custom/deep-model',
|
||||||
lessLibrary: 'vendor/bower/less/dist/less',
|
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: {
|
shim: {
|
||||||
'expression/parser': {
|
'expression/parser': {
|
||||||
|
@ -1918,6 +1918,14 @@ button, .btn:not(.btn-link) {.font-semibold;}
|
|||||||
input, textarea {
|
input, textarea {
|
||||||
float: left;
|
float: left;
|
||||||
}
|
}
|
||||||
|
input[type=file] {
|
||||||
|
opacity: 0;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
&:not(.disabled) input.file-name[readonly] {
|
||||||
|
background-color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
input, textarea, select {
|
input, textarea, select {
|
||||||
max-width: @default-input-width;
|
max-width: @default-input-width;
|
||||||
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)."
|
"recommended": "It is recommended to have at least __limitValue__ of __roleName__ nodes (__count__ selected currently)."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"controls": {
|
||||||
|
"file": {
|
||||||
|
"placeholder": "No file selected"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_details": {
|
"node_details": {
|
||||||
"cpu": "CPU",
|
"cpu": "CPU",
|
||||||
"hdd": "HDD",
|
"hdd": "HDD",
|
||||||
|
@ -20,7 +20,8 @@
|
|||||||
* Based on https://github.com/react-bootstrap/react-bootstrap/blob/master/src/Input.jsx
|
* 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';
|
'use strict';
|
||||||
|
|
||||||
var controls = {};
|
var controls = {};
|
||||||
@ -52,7 +53,7 @@ define(['jquery', 'underscore', 'react', 'utils', 'jsx!component_mixins'], funct
|
|||||||
controls.Input = React.createClass({
|
controls.Input = React.createClass({
|
||||||
mixins: [tooltipMixin],
|
mixins: [tooltipMixin],
|
||||||
propTypes: {
|
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,
|
name: React.PropTypes.node,
|
||||||
label: React.PropTypes.node,
|
label: React.PropTypes.node,
|
||||||
description: React.PropTypes.node,
|
description: React.PropTypes.node,
|
||||||
@ -66,7 +67,11 @@ define(['jquery', 'underscore', 'react', 'utils', 'jsx!component_mixins'], funct
|
|||||||
extraContent: React.PropTypes.node
|
extraContent: React.PropTypes.node
|
||||||
},
|
},
|
||||||
getInitialState: function() {
|
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() {
|
togglePassword: function() {
|
||||||
this.setState({visible: !this.state.visible});
|
this.setState({visible: !this.state.visible});
|
||||||
@ -83,10 +88,41 @@ define(['jquery', 'underscore', 'react', 'utils', 'jsx!component_mixins'], funct
|
|||||||
debouncedInput: _.debounce(function() {
|
debouncedInput: _.debounce(function() {
|
||||||
return this.onInput();
|
return this.onInput();
|
||||||
}, 10, {leading: true}),
|
}, 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() {
|
onChange: function() {
|
||||||
if (this.props.onChange) {
|
if (this.props.onChange) {
|
||||||
var input = this.getInputDOMNode();
|
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() {
|
onInput: function() {
|
||||||
@ -106,6 +142,8 @@ define(['jquery', 'underscore', 'react', 'utils', 'jsx!component_mixins'], funct
|
|||||||
};
|
};
|
||||||
if (this.props.type == 'range') {
|
if (this.props.type == 'range') {
|
||||||
props.onInput = this.debouncedInput;
|
props.onInput = this.debouncedInput;
|
||||||
|
} else if (this.props.type == 'file') {
|
||||||
|
props.onChange = this.readFile;
|
||||||
} else {
|
} else {
|
||||||
// debounced onChange callback is supported for uncontrolled inputs
|
// debounced onChange callback is supported for uncontrolled inputs
|
||||||
props.onChange = (_.isUndefined(this.props.value) && _.isUndefined(this.props.checked)) ? this.debouncedChange : this.onChange;
|
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,
|
'custom-tumbler': isCheckboxOrRadio,
|
||||||
textarea: this.props.type == 'textarea'
|
textarea: this.props.type == 'textarea'
|
||||||
};
|
};
|
||||||
|
if (this.props.type == 'file') {
|
||||||
|
input = <form ref='form'>{input}</form>;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div key='input-group' className={utils.classNames(inputWrapperClasses)}>
|
<div key='input-group' className={utils.classNames(inputWrapperClasses)}>
|
||||||
{input}
|
{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 &&
|
{this.props.toggleable &&
|
||||||
<div className='input-group-addon' onClick={this.togglePassword}>
|
<div className='input-group-addon' onClick={this.togglePassword}>
|
||||||
<i className={this.state.visible ? 'glyphicon glyphicon-eye-close' : 'glyphicon glyphicon-eye-open'} />
|
<i className={this.state.visible ? 'glyphicon glyphicon-eye-close' : 'glyphicon glyphicon-eye-open'} />
|
||||||
|
Loading…
Reference in New Issue
Block a user