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>
......
......@@ -30,7 +30,10 @@ import { connect } from 'react-redux';
import { ContextMenu, MenuItem, ContextMenuTrigger, SubMenu } from 'react-contextmenu';
import { getRandomBrightColor, generateNewKey, copyObj } from '@helpers';
import { getValidToolchainObj as getValidObj } from '@helpers/beat';
import {
getValidToolchainObj as getValidObj,
nameValidator, nameSegmentsForEntity, getTcFromExpName, getValidAlgorithmObj
} from '@helpers/beat';
import type { BeatObject } from '@helpers/beat';
import { fetchLayout, genModuleApiFuncs } from '@helpers/api';
import Validate, { VALIDATORS } from '@helpers/schema';
......@@ -45,6 +48,7 @@ import { connectionToId } from './ToolchainConnection.jsx';
import ValidSchemaBadge from '../ValidSchemaBadge.jsx';
import CacheInput from '../CacheInput.jsx';
import EditModal from './ToolchainModal.jsx';
import type { ModalCache, ModalAction } from './ToolchainModal.jsx';
import GraphicalEditor, {
blockWidth,
gridDistance,
......@@ -77,6 +81,8 @@ type Props = {
// func to save changes on the current tc
saveFunc: (BeatObject) => any,
updateFunc: (BeatObject) => any,
// creates an algorithm from block data from the ToolchainModal
createAlgorithmFromBlock: (data: NormalBlock | AnalyzerBlock, conns: ConnectionType[]) => any,
};
// represents a timeline of state changes
......@@ -123,6 +129,59 @@ const generateNewHistory = (state: State, data: any): History => {
const generateConnectionRepresentations = (connections: ConnectionType[]) => Object.assign({}, ...connections.map(c => ({[`${ c.from }/${ c.to }`]: []})));
// helper func for addBlocks()
// generates all the new data necessary for a new block,
// including the representation (location & dimensions),
// the channel color,
// and an initial block object
const generateNewBlockData = (blockName: string, set: BlockSet, x: number, y: number, copyBlock?: ?BlockType) => {
let newBlock = copyBlock ? { ...copyBlock } : undefined;
if(newBlock === undefined){
newBlock = {};
if(set === 'blocks' || set === 'analyzers'){
newBlock.inputs = [
'input',
];
newBlock.synchronized_channel = '';
}
if(set === 'blocks' || set === 'datasets'){
newBlock.outputs = [
'output',
];
}
}
newBlock.name = blockName;
const repBlock = {
col: x,
row: y,
height: 3,
width: blockWidth / gridDistance,
};
let newChannelColor = set === 'datasets' ? getRandomBrightColor() : null;
return {
block: newBlock,
rep: repBlock,
channelColor: newChannelColor,
};
};
// finds a block by name from the three block sub-arrays:
// blocks (normal blocks), datasets (dataset blocks), analyzers (analyzer blocks)
const findBlock = (contents: any, name: string): BlockType => {
const b = contents.blocks.find(b => b.name === name) ||
contents.datasets.find(b => b.name === name) ||
contents.analyzers.find(b => b.name === name)
;
if(!b)
throw new Error(`invalid block name: ${ name }`);
return b;
};
export class ToolchainEditor extends React.PureComponent<Props, State> {
constructor(props: Props) {
super(props);
......@@ -143,6 +202,8 @@ export class ToolchainEditor extends React.PureComponent<Props, State> {
},
}
/* HELPERS */
// helper to set the contents (this.props.data.contents) object
setContents = (newContents: any) => {
this.setState((prevState, props) => ({
......@@ -173,6 +234,21 @@ export class ToolchainEditor extends React.PureComponent<Props, State> {
});
}
// finds a block by name from the three block sub-arrays:
// blocks (normal blocks), datasets (dataset blocks), analyzers (analyzer blocks)
findBlock = (name: string): BlockType => findBlock(this.props.data.contents, name);
// gets possible channels the block can sync to
getPossibleChannels = (blockName: string) => {
const relConns = this.props.data.contents.connections.filter(c => c.to.split('.')[0] === blockName) || [];
const possibleChannels = Array.from(new Set(relConns.map(c => c.channel)))
.reduce((o, channel) => ({...o, [channel]: this.props.data.contents.representation.channel_colors[channel]}), {});
return possibleChannels;
}
/* HISTORY */
// undoes history by saving the current state to the future
// and switching to the latest history state
undoHistory = () => {
......@@ -223,6 +299,8 @@ export class ToolchainEditor extends React.PureComponent<Props, State> {
this.props.updateFunc(newData);
};
/* MODALS */
// toggles whether the object insert modal is active or not
toggleInsertModal = () => {
this.setState((prevState) => ({ insertModalActive: !prevState.insertModalActive }));
......@@ -233,24 +311,23 @@ export class ToolchainEditor extends React.PureComponent<Props, State> {
this.setState((prevState) => ({ renameGroupModalInfo: val }));
}
closeEditModal = () => {
this.setState({
modalBlockInfo: {
...this.state.modalBlockInfo,
active: false,
}
});
}
/* GROUPS */
// renames a group from an old name to a new one
renameGroup = (oldName: string, newName: string) => {
this.setGroups(this.props.data.extraContents.groups.map(g => g.name === oldName ? {...g, name: newName} : g));
}
// finds a block by name from the three block sub-arrays:
// blocks (normal blocks), datasets (dataset blocks), analyzers (analyzer blocks)
findBlock = (name: string): BlockType => {
const b = this.props.data.contents.blocks.find(b => b.name === name) ||
this.props.data.contents.datasets.find(b => b.name === name) ||
this.props.data.contents.analyzers.find(b => b.name === name)
;
if(!b)
throw new Error(`invalid block name: ${ name }`);
return b;
}
/* CONNECTIONS */
// creates a connection between a block's output and a block's input with a specific channel
createConnections = (connectionData: ConnectionType[]) => {
......@@ -295,205 +372,48 @@ export class ToolchainEditor extends React.PureComponent<Props, State> {
});
}
// updates a blocks location in the graphical editor by providing an x & y offset relative to the current location.
updateBlockLocations = (bLocs: {blockName: string, x: number, y: number}[]) => {
const rep = this.props.data.contents.representation;
const newReps = bLocs
.map(bLoc => ({ [bLoc.blockName]: { ...rep.blocks[bLoc.blockName], col: bLoc.x, row: bLoc.y }}))
.reduce((o, bLoc) => ({ ...o, ...bLoc }), {});;
this.setContents({
representation: {
...rep,
blocks: {
...rep.blocks,
...newReps,
}
}
});
}
// changes a block's name given the set it belongs to, the old name, and the new name
updateBlockName = (set: BlockSet, oldName: string, newName: string) => {
// func to update from/to strings with old names to new names
const updateConn = (from, to): string[] => {
const fromSplit = from.split('.');
fromSplit[0] = fromSplit[0] === oldName ? newName : fromSplit[0];
const toSplit = to.split('.');
toSplit[0] = toSplit[0] === oldName ? newName : toSplit[0];
return [fromSplit.join('.'), toSplit.join('.')];
};
// update the block & conn representation with the new names
// deletes a connection
// looks at the representation object and the connections array
deleteConnection = (cn: ConnectionType) => {
const rep = {...this.props.data.contents.representation};
rep.blocks = {...rep.blocks};
rep.blocks[newName] = rep.blocks[oldName];
delete rep.blocks[oldName];
rep.connections = Object.entries(rep.connections).map(([name, rep]) => {
if(!name.includes(oldName))
return [name, rep];
rep.connections = Object.entries(rep.connections).filter(([name, rep]) => {
const [from, to] = name.split('/');
const updated = updateConn(from, to);
return [updated.join('/'), rep];
if(from === cn.from && to === cn.to)
return false;
return true;
})
.reduce((cs, c) => ({...cs, [c[0]]: c[1]}), {})
.reduce((cs, [name, rep]) => ({...cs, [name]: rep}), {})
;
// update the channel colors
if(set === 'datasets'){
rep.channel_colors[newName] = rep.channel_colors[oldName];
delete rep.channel_colors[oldName];
}
// update blocks & connections
const newContents = {
...this.props.data.contents,
[set]: this.props.data.contents[set].map(s => s.name === oldName ? {...s, name: newName} : s),
// update the conns, both the names of the blocks and the channel
connections: this.props.data.contents.connections.map(c => {
if(!c.from.includes(oldName) && !c.to.includes(oldName) && c.channel !== oldName)
return c;
const newChannel = c.channel === oldName ? newName : c.channel;
const updated = updateConn(c.from, c.to);
return {
channel: newChannel,
from: updated[0],
to: updated[1],
};
}),
representation: rep,
};
// if the changed block is a dataset, update the synchronized_channel on all blocks using its old name
if(set === 'datasets'){
newContents['blocks'] = this.props.data.contents.blocks
.map(s => s.synchronized_channel === oldName ? {...s, synchronized_channel: newName} : s)
;
newContents['analyzers'] = this.props.data.contents.analyzers
.map(s => s.synchronized_channel === oldName ? {...s, synchronized_channel: newName} : s)
;
}
const newGroups = this.props.data.extraContents.groups
.map(({ name, blocks }) => ({ name, blocks: blocks.map(n => n === oldName ? newName : n) }));
this.setState((prevState, props) => ({
history: generateNewHistory(prevState, props.data),
modalBlockInfo: {
active: true,
name: newName,
set
}
}));
this.props.updateFunc({
...this.props.data,
contents: newContents,
extraContents: {
...this.props.data.extraContents,
groups: newGroups,
},
const newConns = this.props.data.contents.connections.filter(c => {
if(c.from === cn.from && c.to === cn.to)
return false;
return true;
});
}
// updates a block's input or output name
updateBlockIOName = (blockName: string, set: BlockSet, ioType: 'input' | 'output', oldName: string, newName: string) => {
const combinedName = `${ blockName }.${ oldName }`;
const updateConn = (from, to): string[] => {
const fromSplit = from.split('.');
if(ioType === 'output')
fromSplit[1] = from === combinedName ? newName : fromSplit[1];
const toSplit = to.split('.');
if(ioType === 'input')
toSplit[1] = to === combinedName ? newName : toSplit[1];
return [fromSplit.join('.'), toSplit.join('.')];
};
const rep = {...this.props.data.contents.representation};
const newRepConns = Object.entries(rep.connections).map(([name, rep]) => {
if(!name.includes(`.${ oldName }`))
return [name, rep];
// if the given block has no conns to it, the syncd channel should become ''
const removeInvalidChannels = (b: NormalBlock | AnalyzerBlock): NormalBlock | AnalyzerBlock => {
if(newConns.find(c => c.to.split('.')[0] == b.name))
return b;
const [from, to] = name.split('/');
const updated = updateConn(from, to);
return {
...b,
synchronized_channel: '',
};
};
return [updated.join('/'), rep];
})
.reduce((cs, [name, map]) => ({...cs, [name]: rep}), {})
;
const newContents = {
...this.props.data.contents,
[set]: this.props.data.contents[set].map(s => {
if(s.name !== blockName)
return s;
const newBlock = {
...s,
};
if(ioType === 'input')
newBlock.inputs = newBlock.inputs.map(str => str === oldName ? newName : str);
if(ioType === 'output')
newBlock.outputs = newBlock.outputs.map(str => str === oldName ? newName : str);
return newBlock;
}),
connections: this.props.data.contents.connections.map(c => {
if(!c.from.includes(`.${ oldName }`) && !c.to.includes(`.${ oldName }`))
return c;
const updated = updateConn(c.from, c.to);
return {
...c,
from: updated[0],
to: updated[1],
};
}),
representation: {
...rep,
connections: newRepConns,
},
connections: newConns,
blocks: this.props.data.contents.blocks.map(b => removeInvalidChannels(b)),
analyzers: this.props.data.contents.analyzers.map(b => removeInvalidChannels(b)),
representation: rep,
};
this.setContents(newContents);
}
// helper func for addBlocks()
// generates all the new data necessary for a new block,
// including the representation (location & dimensions),
// the channel color,
// and an initial block object
generateNewBlockData = (blockName: string, set: BlockSet, x: number, y: number, copyBlock?: ?BlockType) => {
let newBlock = copyBlock ? { ...copyBlock } : undefined;
if(newBlock === undefined){
newBlock = {};
if(set === 'blocks' || set === 'analyzers'){
newBlock.inputs = [
'input',
];
newBlock.synchronized_channel = '';
}
if(set === 'blocks' || set === 'datasets'){
newBlock.outputs = [
'output',
];
}
}
newBlock.name = blockName;
const repBlock = {
col: x,
row: y,
height: 3,
width: blockWidth / gridDistance,
};
let newChannelColor = set === 'datasets' ? getRandomBrightColor() : null;
return {
block: newBlock,
rep: repBlock,
channelColor: newChannelColor,
};
}
/* BLOCKS */
// adds new blocks at a certain location via an array of arrays of info about the new block(s)
// it will also copy connections via looking for connections between copied blocks and copying them to the related new blocks
......@@ -507,7 +427,7 @@ export class ToolchainEditor extends React.PureComponent<Props, State> {
// if you are inserting blocks from different toolchains, give the new connections as the second arg!
// these new connections should already be updated to have the new syncd channel names
addBlocks = (blockData: [string, BlockSet, number, number, ?BlockType][], connections: ConnectionType[]) => {
const newBlocks = blockData.map(d => this.generateNewBlockData(...d));
const newBlocks = blockData.map(d => generateNewBlockData(...d));
const sets = blockData.map(b => b[1]);
const rep = {
......@@ -567,24 +487,21 @@ export class ToolchainEditor extends React.PureComponent<Props, State> {
this.setContents(newContents);