Commit 6a28eda6 authored by Flavio TARSETTI's avatar Flavio TARSETTI

Merge branch 'misc' into 'master'

Miscellaneous editor-specific improvements

Closes #73, #118, #135, and #122

See merge request !8
parents 420ce36d 79cd6950
Pipeline #22675 failed with stages
in 90 minutes and 5 seconds
......@@ -48,6 +48,22 @@ from . import utils
from beat.core.dock import Host
from beat.core.environments import enumerate_packages
def make_error(status_code, message):
"""Overrides flask-restful's response handling to return a custom error message
Adapted from https://stackoverflow.com/a/21639552
Parameters:
status_code (int): The HTTP status code to return
message (str): The error message text to return
"""
response = simplejson.dumps({
'status': status_code,
'message': message
})
response.status_code = status_code
return response
class Layout(Resource):
"""Exposes toolchain layout functionality"""
......@@ -94,7 +110,8 @@ class Templates(Resource):
data = request.get_json()
entity = data.pop('entity')
name = data.pop('name')
utils.generate_python_template(entity, name, self.config, **data)
confirm = data.pop('confirm')
utils.generate_python_template(entity, name, confirm, self.config, **data)
class Settings(Resource):
......
......@@ -72,6 +72,7 @@ import docopt
from beat.cmdline.config import Configuration
def main(user_input=None):
if user_input is not None:
......@@ -111,7 +112,13 @@ def main(user_input=None):
static_folder = os.path.join(os.path.dirname(os.path.realpath(__file__)), '../js')
app = Flask(__name__, static_folder=static_folder, static_url_path='')
api = Api(app)
errors = errors = {
'PythonFileAlreadyExistsError': {
'message': "The python template file trying to be created already exists.",
'status': 409,
}
}
api = Api(app, errors=errors)
CORS(app)
@app.route('/')
......
# You may import any python packages that will be available in the environment you will run this algorithm in
# Environments can change based on the experiment's settings
{% for ref, lib in uses.items() %}# Library "{{ lib }}" is available under "{{ ref }}"
{% for ref, lib in contents.uses.items() %}# Library "{{ lib }}" is available under "{{ ref }}"
{% endfor %}
class Algorithm:
# initialise fields to store cross-input data (e.g. machines, aggregations, etc.)
def __init__(self):
pass
{% if has_parameters %}
{% if contents.parameters %}
# do initial setup work with the given parameters for the algorithm
def setup(self, parameters):
# Parameters available:
{% for p_name, param in contents.parameters.items() %}# Parameter "{{ p_name }}" with type "{{ param.type }}"
{% endfor %}
# get a parameter like:
# self.param1 = parameters.get('param_1', self.param1)
# param1_value = parameters.get('param_1', self.param1_default)
return True
{% endif %}
# this will be called each time the sync'd input has more data available to be processed
def process(self, inputs, outputs):
# Groups available:
{% for group in contents.groups %}# Group {{ loop.index - 1 }}:
{% for iName, input in group.inputs.items() %}# Input "{{ iName }}" with type "{{ input.type }}"
{% endfor %}{% if 'outputs' in group %}{% for oName, output in group.outputs.items() %}# Output "{{ oName }}" with type "{{ output.type }}"
{% endfor %}{% endif %}{% endfor %}{% if 'splittable' not in contents %}
# Results available:
{% for rName, result in contents.results.items() %}# Result "{{ rName }}" with type "{{ result.type }}"
{% endfor %}{% endif %}
# to check if there is more data waiting in the inputs
# (if it is False, you have processed all the inputs and this "process" function won't be called again):
# if inputs.hasMoreData():
......
......@@ -82,18 +82,13 @@ def generate_library(uses=None):
return template.render(uses=uses)
def generate_algorithm(has_parameters=False, uses=None):
def generate_algorithm(contents):
"""Generates a valid BEAT algorithm from our stored template
Parameters:
has_parameters (:py:class:`bool`, Optional): Whether the algorithm has
parameters or not (default: False)
uses (:py:class:`dict`, Optional): A dict of libraries that the algorithm
uses. Keys are the value to reference the library, values are the
library being referenced.
contents (:py:class:`dict`): The algorithm's JSON metadata
Returns:
......@@ -102,9 +97,8 @@ def generate_algorithm(has_parameters=False, uses=None):
"""
uses = uses or {}
template = ENV.get_template('algorithm.jinja2')
return template.render(uses=uses, has_parameters=has_parameters)
return template.render(contents=contents)
TEMPLATE_FUNCTION = dict(
......@@ -114,20 +108,31 @@ TEMPLATE_FUNCTION = dict(
)
"""Functions for template instantiation within beat.editor"""
class PythonFileAlreadyExistsError(Exception):
pass
def generate_python_template(entity, name, config, **kwargs):
def generate_python_template(entity, name, confirm, config, **kwargs):
"""Generates a template for a BEAT entity with the given named arguments
Parameters:
entity (str): A valid BEAT entity (valid values are
"""
entity (str): A valid BEAT entity
s = TEMPLATE_FUNCTION[entity](**kwargs)
name (str): The name of the object to have a python file generated for
confirm (:py:class:`boolean`): Whether to override the Python file if
one is found at the desired location
"""
resource_path = os.path.join(config.path, entity)
file_path = os.path.join(resource_path, name) + '.py'
if not confirm and os.path.isfile(file_path):
# python file already exists
raise PythonFileAlreadyExistsError
s = TEMPLATE_FUNCTION[entity](**kwargs)
with open(file_path, 'w') as f: f.write(s)
return s
......
......@@ -2524,12 +2524,14 @@
"balanced-match": {
"version": "1.0.0",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"brace-expansion": {
"version": "1.1.11",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
......@@ -2544,17 +2546,20 @@
"code-point-at": {
"version": "1.1.0",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"concat-map": {
"version": "0.0.1",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"console-control-strings": {
"version": "1.1.0",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"core-util-is": {
"version": "1.0.2",
......@@ -2671,7 +2676,8 @@
"inherits": {
"version": "2.0.3",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"ini": {
"version": "1.3.5",
......@@ -2683,6 +2689,7 @@
"version": "1.0.0",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"number-is-nan": "^1.0.0"
}
......@@ -2697,6 +2704,7 @@
"version": "3.0.4",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"brace-expansion": "^1.1.7"
}
......@@ -2808,7 +2816,8 @@
"number-is-nan": {
"version": "1.0.1",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"object-assign": {
"version": "4.1.1",
......@@ -2941,6 +2950,7 @@
"version": "1.0.2",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"code-point-at": "^1.0.0",
"is-fullwidth-code-point": "^1.0.0",
......
......@@ -94,6 +94,14 @@ export class EntityDetail extends React.Component<Props, State> {
const name = this.props.match.params.name;
const obj = this.props.getEntityObject();
const index = this.props.getEntityIndex();
let expName;
if(this.props.entity === 'experiment'){
const segs = name.split('/');
const usern = segs.shift();
const expn = segs.pop();
const tcn = segs.join('/');
expName = <span>{ usern }/<Link to={`/toolchain/${ tcn }`}>{ tcn }</Link>/{ expn }</span>
}
return (
<Container>
<Row className='mb-1'>
......@@ -105,7 +113,7 @@ export class EntityDetail extends React.Component<Props, State> {
</span>
{' '}
<pre style={{display: 'inline'}}>
{ name }
{ expName || name }
</pre>
<ValidSchemaBadge entity={this.props.entity} obj={obj} />
</h4>
......
......@@ -5,6 +5,7 @@ import {
Modal,
ModalBody,
ModalHeader,
ModalFooter,
} from 'reactstrap';
import {
generateDatabaseTemplate,
......@@ -22,6 +23,7 @@ type Props = {
type State = {
infoOpen: boolean,
confirmOpen: boolean,
};
export default class EntityTemplateGenerationButton extends React.Component<Props, State> {
......@@ -31,13 +33,18 @@ export default class EntityTemplateGenerationButton extends React.Component<Prop
state = {
infoOpen: false,
confirmOpen: false,
}
toggleInfo = (val: boolean = !this.state.infoOpen) => {
this.setState({ infoOpen: val });
}
click = () => {
toggleConfirm = (val: boolean = !this.state.confirmOpen) => {
this.setState({ confirmOpen: val });
}
click = async (confirm: boolean) => {
let uses;
let res;
switch(this.props.entity){
......@@ -50,27 +57,32 @@ export default class EntityTemplateGenerationButton extends React.Component<Prop
.map(p => p.sets.map(s => s.view))
.reduce((a, vs) => [...a, ...vs], [])
));
res = generateDatabaseTemplate(this.props.data.name, views);
res = await generateDatabaseTemplate(this.props.data.name, confirm, views);
break;
case('algorithm'):
// find if the alg has parameters
// find the used libraries
if(!this.props.data.contents.parameters)
throw new Error(`Bad alg object, no params field: ${ this.props.data.contents }`);
const hasParameters = Object.keys(this.props.data.contents.parameters).length > 0;
uses = copyObj(this.props.data.contents.uses);
res = generateAlgorithmTemplate(this.props.data.name, hasParameters, uses);
res = await generateAlgorithmTemplate(this.props.data.name, confirm, this.props.data.contents);
break;
case('library'):
// find the used libraries
uses = copyObj(this.props.data.contents.uses);
res = generateLibraryTemplate(this.props.data.name, uses);
res = await generateLibraryTemplate(this.props.data.name, confirm, uses);
break;
default:
throw new Error(`Cannot generate template for entity "${ this.props.entity }"`);
}
this.toggleInfo(true);
return res;
// inspect the result
if(res === false){
// the python file already existed, ask for confirmation to overwrite
this.toggleConfirm(true);
} else {
// its fine
this.toggleInfo(true);
}
}
render = () => {
......@@ -79,7 +91,7 @@ export default class EntityTemplateGenerationButton extends React.Component<Prop
<Button outline
color='danger'
className='mx-auto'
onClick={this.click}
onClick={e => this.click(false)}
title={`Some types of objects (databases, algorithms, and libraries) need Python files to be used in experiments. You may generate a template Python file to provide a starting point for development.`}
>
Generate Python File
......@@ -99,6 +111,22 @@ export default class EntityTemplateGenerationButton extends React.Component<Prop
</pre>
</ModalBody>
</Modal>
<Modal color='info' isOpen={this.state.confirmOpen} toggle={e => this.toggleConfirm(false)}>
<ModalHeader toggle={e => this.toggleConfirm(false)}>
Confirm Overwrite
</ModalHeader>
<ModalBody>
The generated Python file will overwrite the Python file that already exists at:
<pre className='preInline'>
/your/beat/prefix/{ pluralize(this.props.entity) }/{ this.props.data.name }.py
</pre>
Are you sure you want to overwrite this file?
</ModalBody>
<ModalFooter>
<Button color="primary" onClick={e => { this.toggleConfirm(false); this.click(true); }}>Overwrite</Button>{' '}
<Button color="secondary" onClick={e => this.toggleConfirm(false)}>Cancel</Button>
</ModalFooter>
</Modal>
</React.Fragment>
);
}
......
......@@ -228,26 +228,11 @@ export class AlgorithmEditor extends React.Component<Props, State> {
<Row key={i} className='mb-2' id={`endpoint${ i }`}>
<Col sm='12'>
<Card>
<CardHeader>
<CacheInput
type='text'
placeholder='Group Name...'
value={group.name}
onChange={(e) => this.updateGroup(
group,
{...group, name: e.target.value}
)}
validateFunc={str => !groups.map(g => [0]).includes(str) || <span>Each group name must be unique</span>}
>
<DeleteInputBtn
deleteFunc={() => this.changeContentsVal(
'groups',
groups
.filter(g => g.name !== group.name)
)}
/>
</CacheInput>
</CardHeader>
{ groups.length > 1 &&
<CardHeader>
Group { i }
</CardHeader>
}
<CardBody>
{ Object.keys(group.inputs).length === 0 && i !== 0 &&
<Alert color='warning'>
......@@ -339,13 +324,24 @@ export class AlgorithmEditor extends React.Component<Props, State> {
)}
</Row>
</CardBody>
{ i !== 0 &&
<Button block color='danger' outline
onClick={() => this.changeContentsVal(
'groups',
groups
.filter(g => g.name !== group.name)
)}
>
Delete Group
</Button>
}
</Card>
</Col>
</Row>
);
})
}
<Button outline block
<Button block
id='newGroupBtn'
onClick={(e) => {
const newGroupName = generateNewKey('group', this.props.data.contents.groups.map(g => g.name));
......
......@@ -115,31 +115,28 @@ describe('<AlgorithmEditor />', () => {
expect(updateFunc.callCount).to.equal(1);
expect(wrapper.props().data.contents).to.have.property('splittable', true);
wrapper.find('button#newGroupBtn').simulate('click');
expect(updateFunc.callCount).to.equal(2);
expect(updateFunc.callCount).to.equal(1);
expect(wrapper.props().data.contents).to.have.property('groups').with.lengthOf(1);
expect(wrapper.props().data.contents.groups[0]).to.have.property('inputs');
expect(wrapper.props().data.contents.groups[0]).to.have.property('outputs');
wrapper.find('CacheInput[placeholder="Group Name..."]').prop('onChange')( { target: { value: 'main' }});
expect(updateFunc.callCount).to.equal(3);
expect(wrapper.props().data.contents.groups[0]).to.have.property('name', 'main');
expect(wrapper.props().data.contents.groups[0]).to.have.property('name', 'group');
wrapper.find('button#newInputBtn').simulate('click');
expect(updateFunc.callCount).to.equal(4);
expect(updateFunc.callCount).to.equal(2);
wrapper.find('button#newOutputBtn').simulate('click');
expect(updateFunc.callCount).to.equal(5);
expect(updateFunc.callCount).to.equal(3);
wrapper.find('.algInput').find('CacheInput').prop('onChange')( { target: { value: 'in_data' }});
expect(updateFunc.callCount).to.equal(6);
expect(updateFunc.callCount).to.equal(4);
wrapper.find('.algOutput').find('CacheInput').prop('onChange')( { target: { value: 'out_data' }});
expect(updateFunc.callCount).to.equal(7);
expect(updateFunc.callCount).to.equal(5);
const ioType = 'system/integer/1';
wrapper.find('.algInput select').prop('onChange')( { target: { value: ioType }});
expect(updateFunc.callCount).to.equal(8);
expect(updateFunc.callCount).to.equal(6);
wrapper.find('.algOutput select').prop('onChange')( { target: { value: ioType }});
expect(updateFunc.callCount).to.equal(9);
expect(updateFunc.callCount).to.equal(7);
expect(wrapper.props().data.contents.groups[0].inputs).to.have.deep.property('in_data', { type: ioType });
expect(wrapper.props().data.contents.groups[0].outputs).to.have.deep.property('out_data', { type: ioType });
......@@ -153,7 +150,7 @@ describe('<AlgorithmEditor />', () => {
'type': 'system/integer/1'
}
},
'name': 'main',
'name': 'group',
'outputs': {
'out_data': {
'type': 'system/integer/1'
......@@ -195,13 +192,11 @@ describe('<AlgorithmEditor />', () => {
wrapper.find('#algSplittable input').prop('onChange')( { target: { checked: true }});
expect(wrapper.props().data.contents).to.have.property('splittable', true);
wrapper.find('button#newGroupBtn').simulate('click');
expect(wrapper.props().data.contents).to.have.property('groups').with.lengthOf(1);
expect(wrapper.props().data.contents.groups[0]).to.have.property('inputs');
expect(wrapper.props().data.contents.groups[0]).to.have.property('outputs');
wrapper.find('CacheInput[placeholder="Group Name..."]').prop('onChange')( { target: { value: 'main' }});
expect(wrapper.props().data.contents.groups[0]).to.have.property('name', 'main');
expect(wrapper.props().data.contents.groups[0]).to.have.property('name', 'group');
wrapper.find('button#newInputBtn').simulate('click');
wrapper.find('button#newOutputBtn').simulate('click');
......@@ -260,7 +255,7 @@ describe('<AlgorithmEditor />', () => {
'type': 'system/integer/1'
}
},
'name': 'main',
'name': 'group',
'outputs': {
'out_data': {
'type': 'system/integer/1'
......@@ -310,13 +305,11 @@ describe('<AlgorithmEditor />', () => {
wrapper.find('#algAnalyzer input').prop('onChange')( { target: { checked: true }});
expect(wrapper.props().data.contents).to.have.property('results');
wrapper.find('button#newGroupBtn').simulate('click');
expect(wrapper.props().data.contents).to.have.property('groups').with.lengthOf(1);
expect(wrapper.props().data.contents.groups[0]).to.have.property('inputs');
expect(wrapper.props().data.contents.groups[0]).to.not.have.property('outputs');
wrapper.find('CacheInput[placeholder="Group Name..."]').prop('onChange')( { target: { value: 'main' }});
expect(wrapper.props().data.contents.groups[0]).to.have.property('name', 'main');
expect(wrapper.props().data.contents.groups[0]).to.have.property('name', 'group');
wrapper.find('button#newInputBtn').simulate('click');
......@@ -380,7 +373,7 @@ describe('<AlgorithmEditor />', () => {
'type': 'system/integer/1'
}
},
'name': 'main'
'name': 'group'
}
],
parameters: {},
......@@ -434,7 +427,6 @@ describe('<AlgorithmEditor />', () => {
wrapper.find('#algSplittable input').prop('onChange')( { target: { checked: true }});
expect(wrapper.props().data.contents).to.have.property('splittable', true);
wrapper.find('button#newGroupBtn').simulate('click');
wrapper.find('button#newGroupBtn').simulate('click');
expect(wrapper.props().data.contents).to.have.property('groups').with.lengthOf(2);
......@@ -444,8 +436,7 @@ describe('<AlgorithmEditor />', () => {
expect(wrapper.props().data.contents.groups[1]).to.not.have.property('outputs');
// Group 1
wrapper.find('#endpoint0 CacheInput[placeholder="Group Name..."]').prop('onChange')( { target: { value: 'main' }});
expect(wrapper.props().data.contents.groups[0]).to.have.property('name', 'main');
expect(wrapper.props().data.contents.groups[0]).to.have.property('name', 'group');
wrapper.find('#endpoint0 button#newInputBtn').simulate('click');
wrapper.find('#endpoint0 button#newOutputBtn').simulate('click');
......@@ -461,8 +452,7 @@ describe('<AlgorithmEditor />', () => {
expect(wrapper.props().data.contents.groups[0].outputs).to.have.deep.property('out_data', { type: ioType });
// Group 2
wrapper.find('#endpoint1 CacheInput[placeholder="Group Name..."]').prop('onChange')( { target: { value: 'sub' }});
expect(wrapper.props().data.contents.groups[1]).to.have.property('name', 'sub');
expect(wrapper.props().data.contents.groups[1]).to.have.property('name', 'group0');
wrapper.find('#endpoint1 button#newInputBtn').simulate('click');
......@@ -479,7 +469,7 @@ describe('<AlgorithmEditor />', () => {
'type': 'system/integer/1'
}
},
'name': 'main',
'name': 'group',
'outputs': {
'out_data': {
'type': 'system/integer/1'
......@@ -492,7 +482,7 @@ describe('<AlgorithmEditor />', () => {
'type': 'system/integer/1'
}
},
'name': 'sub'
'name': 'group0'
}
],
parameters: {},
......@@ -529,13 +519,11 @@ describe('<AlgorithmEditor />', () => {
wrapper.find('#algSplittable input').prop('onChange')( { target: { checked: true }});
expect(wrapper.props().data.contents).to.have.property('splittable', true);
wrapper.find('button#newGroupBtn').simulate('click');
expect(wrapper.props().data.contents).to.have.property('groups').with.lengthOf(1);
expect(wrapper.props().data.contents.groups[0]).to.have.property('inputs');
expect(wrapper.props().data.contents.groups[0]).to.have.property('outputs');
wrapper.find('CacheInput[placeholder="Group Name..."]').prop('onChange')( { target: { value: 'main' }});
expect(wrapper.props().data.contents.groups[0]).to.have.property('name', 'main');
expect(wrapper.props().data.contents.groups[0]).to.have.property('name', 'group');
wrapper.find('button#newInputBtn').simulate('click');
wrapper.find('button#newOutputBtn').simulate('click');
......@@ -578,7 +566,7 @@ describe('<AlgorithmEditor />', () => {
'splittable': true,
'groups': [
{
'name': 'main',
'name': 'group',
'inputs': {
'in_data': {
'type': 'system/integer/1'
......@@ -628,13 +616,11 @@ describe('<AlgorithmEditor />', () => {
wrapper.find('#algAnalyzer input').prop('onChange')( { target: { checked: true }});
expect(wrapper.props().data.contents).to.have.property('results');
wrapper.find('button#newGroupBtn').simulate('click');
expect(wrapper.props().data.contents).to.have.property('groups').with.lengthOf(1);
expect(wrapper.props().data.contents.groups[0]).to.have.property('inputs');
expect(wrapper.props().data.contents.groups[0]).to.not.have.property('outputs');
wrapper.find('CacheInput[placeholder="Group Name..."]').prop('onChange')( { target: { value: '' }});
expect(wrapper.props().data.contents.groups[0]).to.have.property('name', '');
expect(wrapper.props().data.contents.groups[0]).to.have.property('name', 'group');
wrapper.find('button#newInputBtn').simulate('click');
......@@ -680,7 +666,7 @@ describe('<AlgorithmEditor />', () => {
'language': 'python',
'groups': [
{
name: '',
name: 'group',
'inputs': {
'in_data': {
'type': 'system/integer/1'
......
......@@ -19,7 +19,7 @@ import {
FormFeedback,
Alert,
InputGroupAddon,
UncontrolledDropdown, DropdownToggle, DropdownMenu, DropdownItem,
Dropdown, UncontrolledDropdown, DropdownToggle, DropdownMenu, DropdownItem,
ButtonGroup,
ListGroup, ListGroupItem,
UncontrolledTooltip,
......@@ -32,6 +32,7 @@ import './DatabaseEditor.css';
import { changeObjFieldName, copyObj } from '@helpers';
import { getValidDatabaseObj as getValidObj } from '@helpers/beat';
import type { BeatObject } from '@helpers/beat';
import fuse from 'fuse.js';
import * as Selectors from '@store/selectors.js';
......@@ -50,10 +51,23 @@ type Props = {
databases: BeatObject[],
dataformats: BeatObject[],
updateFunc: (BeatObject) => any,
protocols: SelectorProtocol[]
};
export type Protocol = {
name: string,
template: string,
sets: Set[],
};
// the protocols from the databaseProtocols selector has an additional field
// that's not in the protocols in database objects
type SelectorProtocol = Protocol & { database: string };
type State = {
activeProtocol: number,
insertProtocolOpen: boolean,
searchResults: SelectorProtocol[],
};
export type Set = {
......@@ -68,12 +82,17 @@ export type Set = {
},
};
export type Protocol = {
name: string,
template: string,
sets: Set[],
const FuseOptions = {
shouldSort: true,
tokenize: true,
threshold: 0.6,