Commit 3acbb40e authored by Flavio TARSETTI's avatar Flavio TARSETTI

Merge branch 'tc-ux' into 'master'

Toolchain Editor UX Improvements

Closes #129, #117, #125, #44, #134, and #79

See merge request !27
parents c1dd28f6 d8faed66
Pipeline #25283 passed with stages
in 81 minutes and 6 seconds
......@@ -27,6 +27,8 @@ type Props = {
// update delay in ms, defaults to 500
// basically just simple throttling to keep from overwhelming complex updates in the parent
delay?: number,
// danger color highlight
invalid?: boolean,
};
type State = {
......@@ -163,6 +165,8 @@ class CacheInput extends React.Component<Props, State>{
target = null
render () {
// either if invalid or if the consuming component set it
const dangerColor = !this.state.valid || this.props.invalid;
return (
<InputGroup>
{ this.props.children }
......@@ -171,7 +175,8 @@ class CacheInput extends React.Component<Props, State>{
{...this.getInputProps()}
value={this.state.cache}
onChange={this.change}
valid={this.state.valid}
valid={!dangerColor}
invalid={dangerColor}
/>
{ !this.state.valid &&
<div ref={target => {this.target = target;}} className='input-group-append'>
......
......@@ -99,7 +99,7 @@ export const blockWidth = 200;
// spacing between grid rows/cols
export const gridDistance = 20;
// how much the scaling changes at once
export const zoomAdjustmentAmount = 1000;
export const zoomAdjustmentAmount = 100;
// convert coords in the SVG element to coords to save in data
export const convertWorldToDataCoords = (x: number, y: number) => {
return [x - svgWidthHalf, y - svgHeightHalf];
......@@ -510,7 +510,9 @@ export default class GraphicalEditor extends React.PureComponent<Props, State> {
}, 50);
};
// d3 behaviour for dragging lines from an output to an input and creating a connection
// d3 behaviour for connecting an output and input and creating a connection
// allows users to drag from output to input,
// or click an output and click an input
initD3Connections = () => {
// creating connections
setTimeout(() => {
......@@ -519,8 +521,17 @@ export default class GraphicalEditor extends React.PureComponent<Props, State> {
const channelColors = this.props.repData.channel_colors;
const {datasets, blocks} = this.props;
let startBlock;
// the user has started dragging from an output block
let startX = 0;
let startY = 0;
// the user has started dragging from an output block,
// or it might be starting a click event!
// to see if the user is clicking or dragging,
// check in endDrag() if the distance travelled is small
// enough to be considered a click or not
function startDrag(d) {
//console.log('startDrag');
startX = d3.event.x;
startY = d3.event.y;
// find the block that the user started from
const rawId = d3.select(this).attr('id');
const bName = rawId.split('-output-')[0];
......@@ -533,37 +544,57 @@ export default class GraphicalEditor extends React.PureComponent<Props, State> {
.append('line')
.classed('tmp', true)
.attr('stroke', 'black')
.attr('x1', d3.event.x)
.attr('y1', d3.event.y)
.attr('x2', d3.event.x)
.attr('y2', d3.event.y);
.attr('x1', startX)
.attr('y1', startY)
.attr('x2', startX)
.attr('y2', startY);
}
// create a connection if the user ended the drag on an input block,
// else just remove the temp svg line
function endDrag() {
const {x, y} = d3.event;
d3.select('.tmp')
.remove();
const processEndBlock = (endInput: HTMLElement) => {
if(!startBlock)
return;
// with the location of the end event,
// compare the location to the location & dims of all input blocks
// in the svg.
const endInput = Array.from(document.querySelectorAll('.iBlock'))
.find(n => {
const nx = n.x.baseVal.value;
const ny = n.y.baseVal.value;
const width = n.width.baseVal.value;
const height = n.height.baseVal.value;
return nx <= x && x <= nx + width && ny <= y && y <= ny + height;
});
if(endInput === undefined)
return;
//console.log(endInput);
const endId = endInput.id.replace('-input-', '.');
// create a new connection
// create a new connection assuming theres a given func that does it
if(createConnection)
createConnection(startId, endId, startBlock.synchronized_channel || startBlock.name);
startBlock = undefined;
};
// create a connection if the user ended the drag on an input block,
// else just remove the temp svg line
function endDrag(d) {
//console.log('endDrag');
const {x, y} = d3.event;
if(Math.abs(startX - x) < 10 && Math.abs(startY - y) < 10) {
// click event not drag
} else {
// probably not a click event
d3.select('.tmp').remove();
// with the location of the end event,
// compare the location to the location & dims of all input blocks
// in the svg.
const endInput = Array.from(document.querySelectorAll('.iBlock'))
.find(n => {
const nx = n.x.baseVal.value;
const ny = n.y.baseVal.value;
const width = n.width.baseVal.value;
const height = n.height.baseVal.value;
return nx <= x && x <= nx + width && ny <= y && y <= ny + height;
});
if(endInput === undefined){
startBlock = undefined;
} else {
processEndBlock(endInput);
}
}
}
// create a connection if the user clicked an input block,
// else just reset the startBlock
function endClick() {
processEndBlock(this);
}
// update the temp line
......@@ -573,12 +604,16 @@ export default class GraphicalEditor extends React.PureComponent<Props, State> {
.attr('y2', d3.event.y);
}
d3.selectAll('.oBlock').call(
d3.selectAll('.oBlock')
.call(
d3.drag()
.on('start', startDrag)
.on('end', endDrag)
.on('drag', dragged)
);
d3.selectAll('.iBlock')
.on('click', endClick);
}, 50);
}
......
......@@ -49,7 +49,7 @@ class GraphicalEditorHelpModal extends React.PureComponent<Props> {
</p>
<h5>Change the information of a block?</h5>
<p>
Left-clicking on a block will bring up the Block Editor modal, which lets you change the block name, add &amp; remove &amp; rename inputs and outputs, change the synchronized channel, and delete the block.
Left-clicking on a block will bring up the Block Editor modal, which lets you change the block name, add &amp; remove &amp; rename inputs and outputs, change the synchronized channel, and delete the block. Remember to click <i>Save Changes</i>! If you want to discard your changes, you can either click outside of the modal or click the <b>x</b> button on the top-right corner.
</p>
<h5>Create a connection?</h5>
<p>
......@@ -63,6 +63,10 @@ class GraphicalEditorHelpModal extends React.PureComponent<Props> {
<p>
The editor only displays connections connected to selected blocks. For example, to only see connections to/from Block X, select Block X (via shift+clicking on the block).
</p>
<h5>Insert blocks from another toolchain, an algorithm, or a database?</h5>
<p>
Right-click on the background grid and select <i>Insert Object Here</i> to insert blocks for an object relative to the position of your mouse. A modal will pop up, and lets you insert a toolchain, algorithm, or part of a database, to easily create toolchains using BEAT objects that you already have created.
</p>
<h3>Keyboard Shortcuts</h3>
<Table striped>
<thead>
......@@ -73,6 +77,17 @@ class GraphicalEditorHelpModal extends React.PureComponent<Props> {
</tr>
</thead>
<tbody>
<tr>
<td>
Pan
</td>
<td><pre className='preInline'>
Middle-click and move
</pre></td>
<td>
The preferred way to pan around the editor is to use a mouse with middle-click functionality. You can middle-click anywhere in the canvas and then move your mouse to pan around on both axis. If this does not work for you, you may need to turn on <i>autoscrolling</i> in your browser settings.
</td>
</tr>
<tr>
<td>
Pan vertically
......@@ -136,7 +151,7 @@ class GraphicalEditorHelpModal extends React.PureComponent<Props> {
</tr>
<tr>
<td>
Open a context menu
Open a context (right-click) menu
</td>
<td><pre className='preInline'>
Right click
......@@ -180,6 +195,24 @@ class GraphicalEditorHelpModal extends React.PureComponent<Props> {
</span>
</td>
<td>
You can also create a connection by clicking on the black rectangle of an output and then clicking on the black rectangle of an input. This lets you create connections between distant blocks and move around the canvas.
</td>
</tr>
<tr>
<td>
Create an Algorithm based on a block
</td>
<td>
<pre className='preInline'>
Left click
</pre>
{' '}
<span>
on the block to bring up the block editor. At the top of the modal, enter the username and name for the algorithm to be created, and click the <i>Create</i> button to generate an algorithm based off of the connections, inputs, and outputs of the block. You will have to edit the algorithm in the editor to choose the types, and to add any any parameters or results.
</span>
</td>
<td>
This only works for normal and analyzer blocks. If you do not see your new algorithm, refresh the browser tab/window.
</td>
</tr>
<tr>
......@@ -192,14 +225,12 @@ class GraphicalEditorHelpModal extends React.PureComponent<Props> {
</pre>
</td>
<td>
Some elements in the editor have extra information found by hovering over the element for a couple seconds.
Blocks and connections in the editor have extra information found by hovering over the element for a couple seconds.
</td>
</tr>
</tbody>
</Table>
</ModalBody>
<ModalFooter>
</ModalFooter>
</Modal>
);
}
......
......@@ -311,6 +311,13 @@ class InsertObjectModal extends React.PureComponent<Props, State> {
Insert blocks from an existing object
</ModalHeader>
<ModalBody>
<FormGroup row>
<Col>
<span className='text-muted'>
Insert the blocks from a compatible BEAT object into this toolchain. You may insert an existing toolchain or blocks generated from a database protocol, database protocol set, or algorithm.
</span>
</Col>
</FormGroup>
<FormGroup row>
<Col>
</Col>
......
......@@ -315,7 +315,7 @@ describe('<ToolchainEditor />', function() {
wrapper.update();
wrapper.find('.modal CacheInput[value="output0"]').prop('onChange')( { target: { value: 'species' }});
wrapper.update();
wrapper.find('button.close').simulate('click');
wrapper.find('.modal button.btn-success').simulate('click');
wrapper.update();
expect(wrapper.find('ToolchainModal').props().active).to.equal(false);
......@@ -331,6 +331,7 @@ describe('<ToolchainEditor />', function() {
wrapper.find('rect#block_dataset0').simulate('click');
wrapper.update();
expect(wrapper.find('ToolchainModal').props().active).to.equal(true);
wrapper.update();
expect(wrapper.find('.modal').find('CacheInput#tcModalInitFocus').props().value).to.equal('dataset0');
wrapper.find('.modal').find('CacheInput#tcModalInitFocus').prop('onChange')( { target: { value: 'testing_data' }});
wrapper.update();
......@@ -344,7 +345,7 @@ describe('<ToolchainEditor />', function() {
wrapper.update();
wrapper.find('.modal CacheInput[value="output0"]').prop('onChange')( { target: { value: 'species' }});
wrapper.update();
wrapper.find('button.close').simulate('click');
wrapper.find('.modal button.btn-success').simulate('click');
wrapper.update();
expect(wrapper.props().data.contents.datasets[1]).to.deep.equal({
......@@ -362,7 +363,7 @@ describe('<ToolchainEditor />', function() {
expect(wrapper.find('.modal').find('CacheInput#tcModalInitFocus').props().value).to.equal('block');
wrapper.find('.modal').find('CacheInput#tcModalInitFocus').prop('onChange')( { target: { value: 'training_alg' }});
wrapper.update();
wrapper.find('.modal button.btn-secondary').at(0).simulate('click');
wrapper.find('.modal button.btn-secondary').at(1).simulate('click');
wrapper.update();
/*
wrapper.find('.modal button.btn-secondary').at(0).simulate('click');
......@@ -376,7 +377,7 @@ describe('<ToolchainEditor />', function() {
wrapper.update();
wrapper.find('.modal CacheInput[value="output"]').prop('onChange')( { target: { value: 'lda_machine' }});
wrapper.update();
wrapper.find('button.close').simulate('click');
wrapper.find('.modal button.btn-success').simulate('click');
wrapper.update();
expect(wrapper.props().data.contents.blocks[0]).to.deep.equal({
......@@ -398,7 +399,7 @@ describe('<ToolchainEditor />', function() {
expect(wrapper.find('.modal').find('CacheInput#tcModalInitFocus').props().value).to.equal('block0');
wrapper.find('.modal').find('CacheInput#tcModalInitFocus').prop('onChange')( { target: { value: 'testing_alg' }});
wrapper.update();
wrapper.find('.modal button.btn-secondary').at(0).simulate('click');
wrapper.find('.modal button.btn-secondary').at(1).simulate('click');
wrapper.update();
/*
wrapper.find('.modal button.btn-secondary').at(0).simulate('click');
......@@ -412,7 +413,7 @@ describe('<ToolchainEditor />', function() {
wrapper.update();
wrapper.find('.modal CacheInput[value="output"]').prop('onChange')( { target: { value: 'scores' }});
wrapper.update();
wrapper.find('button.close').simulate('click');
wrapper.find('.modal button.btn-success').simulate('click');
wrapper.update();
expect(wrapper.props().data.contents.blocks[1]).to.deep.equal({
......@@ -436,13 +437,13 @@ describe('<ToolchainEditor />', function() {
wrapper.find('.modal button.btn-secondary').simulate('click');
wrapper.update();
*/
wrapper.find('.modal button.btn-secondary').simulate('click');
wrapper.find('.modal button.btn-secondary').at(1).simulate('click');
wrapper.update();
wrapper.find('.modal CacheInput[value="input"]').prop('onChange')( { target: { value: 'scores' }});
wrapper.update();
wrapper.find('.modal CacheInput[value="input0"]').prop('onChange')( { target: { value: 'species' }});
wrapper.update();
wrapper.find('button.close').simulate('click');
wrapper.find('.modal button.btn-success').simulate('click');
wrapper.update();
expect(wrapper.props().data.contents.analyzers[0]).to.deep.equal({
......@@ -964,12 +965,13 @@ describe('<ToolchainEditor />', function() {
wrapper.update();
expect(wrapper.find('ToolchainModal').props().active).to.equal(true);
wrapper.find('.modal CacheInput[value="lda_machine"]').prop('onChange')( { target: { value: 'lda' }});
wrapper.find('.modal button.btn-success').simulate('click');
wrapper.update();
expect(wrapper.find('.modal CacheInput[value="lda"]').props().value).to.equal('lda');
const data = wrapper.props().data;
expect(data.contents.representation.connections).to.have.property('training_alg.lda_machine/testing_alg.lda');
expect(data.contents.representation.connections).to.not.have.property('training_alg.lda_machine/testing_alg.lda_machine');
expect(data.contents.representation.connections).to.have.property('training_alg.lda_machine/testing_alg.lda');
});
it('Properly changes connection names when renaming an output', () => {
......@@ -993,12 +995,13 @@ describe('<ToolchainEditor />', function() {
wrapper.update();
expect(wrapper.find('ToolchainModal').props().active).to.equal(true);
wrapper.find('.modal CacheInput[value="lda_machine"]').prop('onChange')( { target: { value: 'lda' }});
wrapper.find('.modal button.btn-success').simulate('click');
wrapper.update();
expect(wrapper.find('.modal CacheInput[value="lda"]').props().value).to.equal('lda');
const data = wrapper.props().data;
expect(data.contents.representation.connections).to.have.property('training_alg.lda/testing_alg.lda_machine');
expect(data.contents.representation.connections).to.not.have.property('training_alg.lda_machine/testing_alg.lda_machine');
expect(data.contents.representation.connections).to.have.property('training_alg.lda/testing_alg.lda_machine');
});
it('Properly deletes the training_data.species/training_alg.species connection', async () => {
......
......@@ -12,7 +12,9 @@ const sleep = (ms) => {
return new Promise(resolve => setTimeout(resolve, ms));
};
describe('<ToolchainModal />', () => {
describe('<ToolchainModal />', function () {
this.timeout(10000);
let wrapper;
afterEach(() => {
......@@ -31,6 +33,18 @@ describe('<ToolchainModal />', () => {
synchronized_channel: 'dataset1',
};
const testBlockData2 = {
name: 'block1',
inputs: [
'input0',
'input1',
],
outputs: [
'output1',
],
synchronized_channel: 'dataset1',
};
const testChannels = { 'dataset1': '#880000' };
const testBlockNames = ['block1'];
......@@ -43,12 +57,9 @@ describe('<ToolchainModal />', () => {
blockNames={testBlockNames}
possibleChannels={testChannels}
toggle={() => {}}
updateBlockName={() => {}}
updateBlockChannel={() => {}}
updateBlockIOName={() => {}}
addBlockIO={() => {}}
deleteBlockIO={() => {}}
deleteBlock={() => {}}
updateBlockData={() => {}}
createAlgorithmFromBlock={() => {}}
/>
);
expect(wrapper).to.have.props([
......@@ -57,20 +68,14 @@ describe('<ToolchainModal />', () => {
'blockNames',
'possibleChannels',
'toggle',
'updateBlockName',
'updateBlockChannel',
'updateBlockIOName',
'addBlockIO',
'deleteBlockIO',
'deleteBlock',
'updateBlockData',
'createAlgorithmFromBlock',
]);
});
it('lets you change the name to another valid name', async () => {
const newBlockName = 'block2';
const _updateBlockName = (str: string) => {
expect(str).to.equal(newBlockName);
};
wrapper = mount(
<C
active={true}
......@@ -78,12 +83,9 @@ describe('<ToolchainModal />', () => {
blockNames={testBlockNames}
possibleChannels={testChannels}
toggle={() => {}}
updateBlockName={_updateBlockName}
updateBlockChannel={() => {}}
updateBlockIOName={() => {}}
addBlockIO={() => {}}
deleteBlockIO={() => {}}
deleteBlock={() => {}}
updateBlockData={() => {}}
createAlgorithmFromBlock={() => {}}
/>
);
wrapper.find('input#tcModalInitFocus').prop('onChange')( { target: { value: newBlockName }});
......@@ -95,9 +97,6 @@ describe('<ToolchainModal />', () => {
it('doesnt let you change the name to a valid name with a dash ("-") in it', async () => {
const newBlockName = 'block-2';
const _updateBlockName = (str: string) => {
expect(str).to.equal(newBlockName);
};
wrapper = mount(
<C
active={true}
......@@ -105,12 +104,9 @@ describe('<ToolchainModal />', () => {
blockNames={testBlockNames}
possibleChannels={testChannels}
toggle={() => {}}
updateBlockName={_updateBlockName}
updateBlockChannel={() => {}}
updateBlockIOName={() => {}}
addBlockIO={() => {}}
deleteBlockIO={() => {}}
deleteBlock={() => {}}
updateBlockData={() => {}}
createAlgorithmFromBlock={() => {}}
/>
);
wrapper.find('input#tcModalInitFocus').prop('onChange')( { target: { value: newBlockName }});
......@@ -119,4 +115,58 @@ describe('<ToolchainModal />', () => {
wrapper.update();
expect(wrapper.exists('.input-group-append .text-danger')).to.equal(true);
});
it('lets you switch the names of inputs (test 1)', async () => {
const newBlockName = 'block';
wrapper = mount(
<C
active={true}
data={testBlockData2}
blockNames={testBlockNames}
possibleChannels={testChannels}
toggle={() => {}}
deleteBlock={() => {}}
updateBlockData={() => {}}
createAlgorithmFromBlock={() => {}}
/>
);
wrapper.find('input[value="input0"]').prop('onChange')( { target: { value: 'input1' }});
wrapper.update();
await sleep(1000);
wrapper.find('input[value="input1"]').at(1).prop('onChange')( { target: { value: 'input0' }});
wrapper.update();
await sleep(1000);
wrapper.update();
expect(wrapper.state('cache')).to.have.deep.property('inputs', [
{ action: 'change', name: 'input1', original: 'input0' },
{ action: 'change', name: 'input0', original: 'input1' },
]);
});
it('lets you switch the names of inputs (test 2)', async () => {
const newBlockName = 'block';
wrapper = mount(
<C
active={true}
data={testBlockData2}
blockNames={testBlockNames}
possibleChannels={testChannels}
toggle={() => {}}
deleteBlock={() => {}}
updateBlockData={() => {}}
createAlgorithmFromBlock={() => {}}
/>
);
wrapper.find('input[value="input1"]').prop('onChange')( { target: { value: 'input0' }});
wrapper.update();
await sleep(1000);
wrapper.find('input[value="input0"]').at(0).prop('onChange')( { target: { value: 'input1' }});
wrapper.update();
await sleep(1000);
wrapper.update();
expect(wrapper.state('cache')).to.have.deep.property('inputs', [
{ action: 'change', name: 'input1', original: 'input0' },
{ action: 'change', name: 'input0', original: 'input1' },
]);
});
});
......@@ -12,7 +12,7 @@ const fetchConfig = {
let port = '5000';
const toUrl = (str) => `http://127.0.0.1:${ port }/${ str }`;
export const toUrl = (str: string) => `http://127.0.0.1:${ port }/${ str }`;
// curried func for calling the REST API
// basically:
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment