diff --git a/conda/js/src/components/toolchain/GraphicalEditor.jsx b/conda/js/src/components/toolchain/GraphicalEditor.jsx index cd6b596f83f3a536a2c251d2549223ffa548b55e..a78ced3420f98d62ec961e45ce89239a14fc86e9 100644 --- a/conda/js/src/components/toolchain/GraphicalEditor.jsx +++ b/conda/js/src/components/toolchain/GraphicalEditor.jsx @@ -529,7 +529,7 @@ export default class GraphicalEditor extends React.PureComponent<Props, State> { // check in endDrag() if the distance travelled is small // enough to be considered a click or not function startDrag(d) { - console.log('startDrag'); + //console.log('startDrag'); startX = d3.event.x; startY = d3.event.y; // find the block that the user started from @@ -553,7 +553,7 @@ export default class GraphicalEditor extends React.PureComponent<Props, State> { const processEndBlock = (endInput: HTMLElement) => { if(!startBlock) return; - console.log(endInput); + //console.log(endInput); const endId = endInput.id.replace('-input-', '.'); // create a new connection assuming theres a given func that does it if(createConnection) diff --git a/conda/js/src/components/toolchain/ToolchainEditor.jsx b/conda/js/src/components/toolchain/ToolchainEditor.jsx index 1ee33705f0c4dd7401dfc19eb28b8fe92f65da2d..afb671cc3d9d9f552e83effca4439ed51e1a17c4 100644 --- a/conda/js/src/components/toolchain/ToolchainEditor.jsx +++ b/conda/js/src/components/toolchain/ToolchainEditor.jsx @@ -45,6 +45,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, @@ -123,6 +124,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 +197,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 +229,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 +294,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 +306,14 @@ export class ToolchainEditor extends React.PureComponent<Props, State> { this.setState((prevState) => ({ renameGroupModalInfo: val })); } + /* 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,6 +358,121 @@ export class ToolchainEditor extends React.PureComponent<Props, State> { }); } + // deletes a connection + // looks at the representation object and the connections array + deleteConnection = (cn: ConnectionType) => { + const rep = {...this.props.data.contents.representation}; + + rep.connections = Object.entries(rep.connections).filter(([name, rep]) => { + const [from, to] = name.split('/'); + if(from === cn.from && to === cn.to) + return false; + return true; + }) + .reduce((cs, [name, rep]) => ({...cs, [name]: rep}), {}) + ; + + const newConns = this.props.data.contents.connections.filter(c => { + if(c.from === cn.from && c.to === cn.to) + return false; + return true; + }); + + // 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; + + return { + ...b, + synchronized_channel: '', + }; + }; + + const newContents = { + 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); + } + + /* 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 + // Parameters: + // blockData: each array is info about a new block: + // - the first string is the new name + // - the BlockSet is the set to put the block in + // - the numbers are the x & y coords of the new block + // - last entry is an optional block object to copy from + // connections: + // 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 => generateNewBlockData(...d)); + const sets = blockData.map(b => b[1]); + + const rep = { + ...this.props.data.contents.representation, + blocks: { + ...this.props.data.contents.representation.blocks, + ...newBlocks.reduce((newReps, b) => ({...newReps, [b.block.name]: b.rep}), {}), + }, + channel_colors: { + ...this.props.data.contents.representation.channel_colors, + ...newBlocks.filter(b => b.channelColor !== null) + .reduce((newColors, b) => ({...newColors, [b.block.name]: b.channelColor}), {}), + } + }; + + + const newContents = { + blocks: [...this.props.data.contents.blocks, ...newBlocks.filter((b, i) => sets[i] === 'blocks').map(b => b.block)], + datasets: [...this.props.data.contents.datasets, ...newBlocks.filter((b, i) => sets[i] === 'datasets').map(b => b.block)], + analyzers: [...this.props.data.contents.analyzers, ...newBlocks.filter((b, i) => sets[i] === 'analyzers').map(b => b.block)], + representation: rep, + connections: this.props.data.contents.connections, + }; + + // if the blocks werent copied from an external toolchain, + // check for connection info from the copied blocks and create new connections between the new blocks + if(!connections){ + const copyBlocks = blockData.filter(bd => bd.length > 4); + const newConnections: ConnectionType[] = [...this.props.data.contents.connections].map(({ from, to, channel }) => { + const fromBlock = copyBlocks.find(bd => from.startsWith(`${ bd[4].name }.`)); + const toBlock = copyBlocks.find(bd => to.startsWith(`${ bd[4].name }.`)); + if(!fromBlock || !toBlock) + return false; + const newFromBlockData = newBlocks.find(b => b.block.name === fromBlock[0]); + if(!newFromBlockData) + return false; + else { + return { + from: from.replace(`${ fromBlock[4].name }.`, `${ fromBlock[0] }.`), + to: to.replace(`${ toBlock[4].name }.`, `${ toBlock[0] }.`), + channel: newFromBlockData.block.synchronized_channel || newFromBlockData.block.name, + }; + } + }) + .filter(c => c); + newContents.connections = [...this.props.data.contents.connections, ...newConnections]; + const newRepConns = generateConnectionRepresentations(newConnections); + newContents.representation.connections = {...rep.connections, ...newRepConns}; + } else { + // add the new connections from the copied toolchain + const newConnections = connections; + newContents.connections = [...this.props.data.contents.connections, ...newConnections]; + const newRepConns = generateConnectionRepresentations(newConnections); + newContents.representation.connections = {...rep.connections, ...newRepConns}; + } + + this.setContents(newContents); + } + // 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; @@ -312,8 +490,147 @@ export class ToolchainEditor extends React.PureComponent<Props, State> { }); } + // deletes a block from the toolchain, + // as well as any connections to/from it + // if it was the sole member of a group, deletes the group too + deleteBlocks = (names: string[]) => { + const rep = copyObj(this.props.data.contents.representation); + + names.forEach(name => { + delete rep.blocks[name]; + delete rep.channel_colors[name]; + }); + + rep.connections = Object.entries(rep.connections).filter(([name, rep]) => { + const [from, to] = name.split('/'); + if(names.find(blockName => to.startsWith(`${ blockName }.`) || from.startsWith(`${ blockName }.`))) + return false; + return true; + }) + .reduce((cs, [name, r]) => ({...cs, [name]: r}), {}) + ; + + const newContents = { + ...this.props.data.contents, + blocks: this.props.data.contents.blocks.filter(s => !names.includes(s.name)), + datasets: this.props.data.contents.datasets.filter(s => !names.includes(s.name)), + analyzers: this.props.data.contents.analyzers.filter(s => !names.includes(s.name)), + connections: this.props.data.contents.connections.filter(c => { + if(names.find(blockName => c.to.startsWith(`${ blockName }.`) || c.from.startsWith(`${ blockName }.`))) + return false; + return true; + }), + representation: rep, + }; + + const newGroups = this.props.data.extraContents.groups + .map(({ name, blocks }) => ({ name, blocks: blocks.filter(n => !names.includes(n)) })) + .filter(g => g.blocks.length !== 0); + + this.setState({ + history: generateNewHistory(this.state, this.props.data), + }); + + this.props.updateFunc({ + ...this.props.data, + contents: newContents, + extraContents: { + ...this.props.data.extraContents, + groups: newGroups, + }, + }); + } + + /* BLOCK EDIT MODAL */ + + // updates the block data for a given block + // by sequentially applying all updates made from the ToolchainModal editor + // to the data of the toolchain. + updateBlockData = (data: ModalCache) => { + const set = this.state.modalBlockInfo.set; + const name = this.state.modalBlockInfo.name; + if(!set || !name){ + console.error(`Modal not open for update for ${ data.name }`); + return; + } + + const oldData = this.props.data; + const oldBlock = oldData.contents[set].find(b => b.name === name); + if(!oldBlock){ + console.error(`cannot find block ${ name }`); + return; + } + + let newData = copyObj(oldData); + + // update name + if(name !== data.name) { + newData = this.updateBlockNameFunc(newData, set, name, data.name); + } + + // update channel + if(data.synchronized_channel && oldBlock.synchronized_channel !== data.synchronized_channel){ + newData = this.updateBlockChannelFunc(newData, data.name, set, data.synchronized_channel); + } + + const findNewBlock = () => newData.contents[set].find(b => b.name === data.name); + let newBlock = findNewBlock(); + + // update inputs + if(Array.isArray(data.inputs)){ + data.inputs + .forEach(o => { + switch(o.action){ + case 'change': + // when the modal opens, + // a new "change" operation is added to the cache + // for each input/output already in the block, with a name === original + // to avoid extra processing, skip these if they exist + if(o.original === o.name) + return; + newData = this.updateBlockIONameFunc(newData, newBlock.name, set, 'input', o.original, o.name); + break; + case 'delete': + newData = this.deleteBlockIOFunc(newData, newBlock.name, set, 'input', o.original); + break; + case 'add': + newData = this.addBlockIOFunc(newData, newBlock.name, set, 'input', o.name); + break; + } + newBlock = findNewBlock(); + }); + } + + // update outputs + if(Array.isArray(data.outputs)){ + data.outputs + .forEach(o => { + switch(o.action){ + case 'change': + if(o.original === o.name) + return; + newData = this.updateBlockIONameFunc(newData, newBlock.name, set, 'output', o.original, o.name); + break; + case 'delete': + newData = this.deleteBlockIOFunc(newData, newBlock.name, set, 'output', o.original); + break; + case 'add': + newData = this.addBlockIOFunc(newData, newBlock.name, set, 'output', o.name); + break; + } + newBlock = findNewBlock(); + }); + } + + this.setState((prevState, props) => ({ + history: generateNewHistory(prevState, props.data), + })); + + this.props.updateFunc(newData); + } + // 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) => { + updateBlockNameFunc = (oldData: any, set: BlockSet, oldName: string, newName: string): any => { // func to update from/to strings with old names to new names const updateConn = (from, to): string[] => { const fromSplit = from.split('.'); @@ -324,7 +641,7 @@ export class ToolchainEditor extends React.PureComponent<Props, State> { }; // update the block & conn representation with the new names - const rep = {...this.props.data.contents.representation}; + const rep = {...oldData.contents.representation}; rep.blocks = {...rep.blocks}; rep.blocks[newName] = rep.blocks[oldName]; delete rep.blocks[oldName]; @@ -347,10 +664,10 @@ export class ToolchainEditor extends React.PureComponent<Props, State> { // update blocks & connections const newContents = { - ...this.props.data.contents, - [set]: this.props.data.contents[set].map(s => s.name === oldName ? {...s, name: newName} : s), + ...oldData.contents, + [set]: oldData.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 => { + connections: oldData.contents.connections.map(c => { if(!c.from.includes(oldName) && !c.to.includes(oldName) && c.channel !== oldName) return c; @@ -368,16 +685,16 @@ export class ToolchainEditor extends React.PureComponent<Props, State> { // 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 + newContents['blocks'] = oldData.contents.blocks .map(s => s.synchronized_channel === oldName ? {...s, synchronized_channel: newName} : s) ; - newContents['analyzers'] = this.props.data.contents.analyzers + newContents['analyzers'] = oldData.contents.analyzers .map(s => s.synchronized_channel === oldName ? {...s, synchronized_channel: newName} : s) ; } - const newGroups = this.props.data.extraContents.groups + const newGroups = oldData.extraContents.groups .map(({ name, blocks }) => ({ name, blocks: blocks.map(n => n === oldName ? newName : n) })); this.setState((prevState, props) => ({ history: generateNewHistory(prevState, props.data), @@ -388,18 +705,18 @@ export class ToolchainEditor extends React.PureComponent<Props, State> { } })); - this.props.updateFunc({ - ...this.props.data, + return { + ...oldData, contents: newContents, extraContents: { - ...this.props.data.extraContents, + ...oldData.extraContents, groups: newGroups, }, - }); + }; } // updates a block's input or output name - updateBlockIOName = (blockName: string, set: BlockSet, ioType: 'input' | 'output', oldName: string, newName: string) => { + updateBlockIONameFunc = (oldData: any, blockName: string, set: BlockSet, ioType: 'input' | 'output', oldName: string, newName: string) => { const combinedName = `${ blockName }.${ oldName }`; const updateConn = (from, to): string[] => { const fromSplit = from.split('.'); @@ -410,7 +727,7 @@ export class ToolchainEditor extends React.PureComponent<Props, State> { toSplit[1] = to === combinedName ? newName : toSplit[1]; return [fromSplit.join('.'), toSplit.join('.')]; }; - const rep = {...this.props.data.contents.representation}; + const rep = {...oldData.contents.representation}; const newRepConns = Object.entries(rep.connections).map(([name, rep]) => { if(!name.includes(`.${ oldName }`)) @@ -424,8 +741,8 @@ export class ToolchainEditor extends React.PureComponent<Props, State> { .reduce((cs, [name, map]) => ({...cs, [name]: rep}), {}) ; const newContents = { - ...this.props.data.contents, - [set]: this.props.data.contents[set].map(s => { + ...oldData.contents, + [set]: oldData.contents[set].map(s => { if(s.name !== blockName) return s; const newBlock = { @@ -437,7 +754,7 @@ export class ToolchainEditor extends React.PureComponent<Props, State> { newBlock.outputs = newBlock.outputs.map(str => str === oldName ? newName : str); return newBlock; }), - connections: this.props.data.contents.connections.map(c => { + connections: oldData.contents.connections.map(c => { if(!c.from.includes(`.${ oldName }`) && !c.to.includes(`.${ oldName }`)) return c; const updated = updateConn(c.from, c.to); @@ -453,124 +770,18 @@ export class ToolchainEditor extends React.PureComponent<Props, State> { }, }; - 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, + ...oldData, + contents: newContents, }; } - // 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 - // Parameters: - // blockData: each array is info about a new block: - // - the first string is the new name - // - the BlockSet is the set to put the block in - // - the numbers are the x & y coords of the new block - // - last entry is an optional block object to copy from - // connections: - // 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 sets = blockData.map(b => b[1]); - - const rep = { - ...this.props.data.contents.representation, - blocks: { - ...this.props.data.contents.representation.blocks, - ...newBlocks.reduce((newReps, b) => ({...newReps, [b.block.name]: b.rep}), {}), - }, - channel_colors: { - ...this.props.data.contents.representation.channel_colors, - ...newBlocks.filter(b => b.channelColor !== null) - .reduce((newColors, b) => ({...newColors, [b.block.name]: b.channelColor}), {}), - } - }; - - - const newContents = { - blocks: [...this.props.data.contents.blocks, ...newBlocks.filter((b, i) => sets[i] === 'blocks').map(b => b.block)], - datasets: [...this.props.data.contents.datasets, ...newBlocks.filter((b, i) => sets[i] === 'datasets').map(b => b.block)], - analyzers: [...this.props.data.contents.analyzers, ...newBlocks.filter((b, i) => sets[i] === 'analyzers').map(b => b.block)], - representation: rep, - connections: this.props.data.contents.connections, - }; - - // if the blocks werent copied from an external toolchain, - // check for connection info from the copied blocks and create new connections between the new blocks - if(!connections){ - const copyBlocks = blockData.filter(bd => bd.length > 4); - const newConnections: ConnectionType[] = [...this.props.data.contents.connections].map(({ from, to, channel }) => { - const fromBlock = copyBlocks.find(bd => from.startsWith(`${ bd[4].name }.`)); - const toBlock = copyBlocks.find(bd => to.startsWith(`${ bd[4].name }.`)); - if(!fromBlock || !toBlock) - return false; - const newFromBlockData = newBlocks.find(b => b.block.name === fromBlock[0]); - if(!newFromBlockData) - return false; - else { - return { - from: from.replace(`${ fromBlock[4].name }.`, `${ fromBlock[0] }.`), - to: to.replace(`${ toBlock[4].name }.`, `${ toBlock[0] }.`), - channel: newFromBlockData.block.synchronized_channel || newFromBlockData.block.name, - }; - } - }) - .filter(c => c); - newContents.connections = [...this.props.data.contents.connections, ...newConnections]; - const newRepConns = generateConnectionRepresentations(newConnections); - newContents.representation.connections = {...rep.connections, ...newRepConns}; - } else { - // add the new connections from the copied toolchain - const newConnections = connections; - newContents.connections = [...this.props.data.contents.connections, ...newConnections]; - const newRepConns = generateConnectionRepresentations(newConnections); - newContents.representation.connections = {...rep.connections, ...newRepConns}; - } - - this.setContents(newContents); - } // add a new input or output to a block - addBlockIO = (blockName: string, set: BlockSet, ioType: 'input' | 'output', newName: string) => { + addBlockIOFunc = (oldData: any, blockName: string, set: BlockSet, ioType: 'input' | 'output', newName: string) => { const newContents = { - [set]: this.props.data.contents[set].map(s => { + ...oldData.contents, + [set]: oldData.contents[set].map(s => { if(s.name !== blockName) return s; const newBlock = { @@ -584,107 +795,16 @@ export class ToolchainEditor extends React.PureComponent<Props, State> { }), }; - this.setContents(newContents); - } - - // deletes a block from the toolchain, - // as well as any connections to/from it - // if it was the sole member of a group, deletes the group too - deleteBlocks = (names: string[]) => { - const rep = copyObj(this.props.data.contents.representation); - - names.forEach(name => { - delete rep.blocks[name]; - delete rep.channel_colors[name]; - }); - - rep.connections = Object.entries(rep.connections).filter(([name, rep]) => { - const [from, to] = name.split('/'); - if(names.find(blockName => to.startsWith(`${ blockName }.`) || from.startsWith(`${ blockName }.`))) - return false; - return true; - }) - .reduce((cs, [name, r]) => ({...cs, [name]: r}), {}) - ; - - //console.log(Object.keys(rep.connections)); - - const newContents = { - ...this.props.data.contents, - blocks: this.props.data.contents.blocks.filter(s => !names.includes(s.name)), - datasets: this.props.data.contents.datasets.filter(s => !names.includes(s.name)), - analyzers: this.props.data.contents.analyzers.filter(s => !names.includes(s.name)), - connections: this.props.data.contents.connections.filter(c => { - if(names.find(blockName => c.to.startsWith(`${ blockName }.`) || c.from.startsWith(`${ blockName }.`))) - return false; - return true; - }), - representation: rep, - }; - - const newGroups = this.props.data.extraContents.groups - .map(({ name, blocks }) => ({ name, blocks: blocks.filter(n => !names.includes(n)) })) - .filter(g => g.blocks.length !== 0); - - this.setState({ - history: generateNewHistory(this.state, this.props.data), - }); - - this.props.updateFunc({ - ...this.props.data, + return { + ...oldData, contents: newContents, - extraContents: { - ...this.props.data.extraContents, - groups: newGroups, - }, - }); - } - - // deletes a connection - // looks at the representation object and the connections array - deleteConnection = (cn: ConnectionType) => { - const rep = {...this.props.data.contents.representation}; - - rep.connections = Object.entries(rep.connections).filter(([name, rep]) => { - const [from, to] = name.split('/'); - if(from === cn.from && to === cn.to) - return false; - return true; - }) - .reduce((cs, [name, rep]) => ({...cs, [name]: rep}), {}) - ; - - const newConns = this.props.data.contents.connections.filter(c => { - if(c.from === cn.from && c.to === cn.to) - return false; - return true; - }); - - // 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; - - return { - ...b, - synchronized_channel: '', - }; - }; - - const newContents = { - 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); } // deletes an input or output from a block, // as well as any connections connected to it - deleteBlockIO = (blockName: string, set: BlockSet, ioType: 'input' | 'output', ioName: string) => { - const rep = {...this.props.data.contents.representation}; + deleteBlockIOFunc = (oldData: any, blockName: string, set: BlockSet, ioType: 'input' | 'output', ioName: string) => { + const rep = {...oldData.contents.representation}; const connectionLabel = `${ blockName }.${ ioName }`; @@ -702,8 +822,8 @@ export class ToolchainEditor extends React.PureComponent<Props, State> { .reduce((cs, [name, rep]) => ({...cs, [name]: rep}), {}) ; const newContents = { - ...this.props.data.contents, - [set]: this.props.data.contents[set].map(s => { + ...oldData.contents, + [set]: oldData.contents[set].map(s => { if(s.name !== blockName) return s; const newBlock = { @@ -715,7 +835,7 @@ export class ToolchainEditor extends React.PureComponent<Props, State> { newBlock.outputs = newBlock.outputs.filter(str => str !== ioName); return newBlock; }), - connections: this.props.data.contents.connections.filter(c => { + connections: oldData.contents.connections.filter(c => { if(ioType === 'input' && c.to === connectionLabel) return false; if(ioType === 'output' && c.from === connectionLabel) @@ -724,16 +844,19 @@ export class ToolchainEditor extends React.PureComponent<Props, State> { }), }; - this.setContents(newContents); + return { + ...oldData, + contents: newContents, + }; } // changes the block's sync'd channel // also updates all connections from the block - updateBlockChannel = (blockName: string, set: BlockSet, channel: string) => { - const queue = [this.props.data.contents[set].find(b => b.name === blockName)]; + updateBlockChannelFunc = (oldData: any, blockName: string, set: BlockSet, channel: string) => { + const queue = [oldData.contents[set].find(b => b.name === blockName)]; const oldChannel = `${ queue[0].synchronized_channel }`; - const bNames = Object.keys(this.props.data.contents.representation.blocks); - const conns = this.props.data.contents.connections; + const bNames = Object.keys(oldData.contents.representation.blocks); + const conns = oldData.contents.connections; const updatedBlocks: { [string]: BlockType } = {}; const updatedConnections: { [string]: ConnectionType } = {}; while(queue.length > 0){ @@ -757,7 +880,7 @@ export class ToolchainEditor extends React.PureComponent<Props, State> { .filter(c => c.to.startsWith(`${ curr }.`) && c.channel === oldChannel) .length === 1 ) - queue.push(this.findBlock(curr)); + queue.push(findBlock(oldData.contents, curr)); updatedConnections[connectionToId(c)] = { ...c, @@ -768,24 +891,20 @@ export class ToolchainEditor extends React.PureComponent<Props, State> { } const newContents = { - ...this.props.data.contents, - datasets: this.props.data.contents.datasets.map(b => updatedBlocks[b.name] || b), - blocks: this.props.data.contents.blocks.map(b => updatedBlocks[b.name] || b), - analyzers: this.props.data.contents.analyzers.map(b => updatedBlocks[b.name] || b), - connections: this.props.data.contents.connections.map(c => updatedConnections[connectionToId(c)] || c), + ...oldData.contents, + datasets: oldData.contents.datasets.map(b => updatedBlocks[b.name] || b), + blocks: oldData.contents.blocks.map(b => updatedBlocks[b.name] || b), + analyzers: oldData.contents.analyzers.map(b => updatedBlocks[b.name] || b), + connections: oldData.contents.connections.map(c => updatedConnections[connectionToId(c)] || c), }; - this.setContents(newContents); + return { + ...oldData, + contents: newContents, + }; } - // 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; - } + /* CONTEXT MENUS */ svgContextMenuLocation: ?[number, number] = undefined @@ -1199,31 +1318,9 @@ export class ToolchainEditor extends React.PureComponent<Props, State> { }); }} blockNames={Object.keys(this.props.data.contents.representation.blocks) || []} - updateBlockName={(newName: string) => { - this.updateBlockName(set, name, newName); - }} - updateBlockIOName={(ioType: 'input' | 'output', oldName: string, newName: string) => this.updateBlockIOName( - name, - set, - ioType, - oldName, - newName - )} - addBlockIO={(ioType: 'input' | 'output', newName: string) => this.addBlockIO( - name, - set, - ioType, - newName - )} - deleteBlockIO={(ioType: 'input' | 'output', ioName: string) => this.deleteBlockIO( - name, - set, - ioType, - ioName - )} deleteBlock={() => this.deleteBlocks([name])} possibleChannels={possibleChannels} - updateBlockChannel={(channel: string) => this.updateBlockChannel(name, set, channel)} + updateBlockData={this.updateBlockData} /> ); } diff --git a/conda/js/src/components/toolchain/ToolchainEditor.spec.jsx b/conda/js/src/components/toolchain/ToolchainEditor.spec.jsx index 3d9b95737928a655728193c4338b5e46ba860b81..cb4123685f12cf28238af33dbf0588457cab8084 100644 --- a/conda/js/src/components/toolchain/ToolchainEditor.spec.jsx +++ b/conda/js/src/components/toolchain/ToolchainEditor.spec.jsx @@ -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({ @@ -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({ @@ -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({ @@ -442,7 +443,7 @@ describe('<ToolchainEditor />', function() { 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 () => { diff --git a/conda/js/src/components/toolchain/ToolchainModal.jsx b/conda/js/src/components/toolchain/ToolchainModal.jsx index 0ed9a03bb678843d1f1d586c711e4cbfc3b54495..0f24d068da5e257a481b6ec93e76e1063a492f2a 100644 --- a/conda/js/src/components/toolchain/ToolchainModal.jsx +++ b/conda/js/src/components/toolchain/ToolchainModal.jsx @@ -29,7 +29,7 @@ import { import CacheInput from '../CacheInput.jsx'; import DeleteInputBtn from '../DeleteInputBtn.jsx'; -import { generateNewKey } from '@helpers'; +import { generateNewKey, copyObj } from '@helpers'; import type { BlockType } from '@helpers/toolchainTypes'; type Props = { @@ -43,41 +43,136 @@ type Props = { possibleChannels: { [string]: string }, // func to close modal toggle: () => any, - // change the block name - updateBlockName: (newName: string) => any, - // change the name of an input or output - updateBlockIOName: (ioType: 'input' | 'output', oldName: string, newName: string) => any, - // create an input or output - addBlockIO: (ioType: 'input' | 'output', newName: string) => any, - // delete an input or output - deleteBlockIO: (ioType: 'input' | 'output', ioName: string) => any, // delete the block deleteBlock: () => any, - // update sync'd channel - updateBlockChannel: (channel: string) => any, + // updates the block info + updateBlockData: (data: ModalCache) => any, +}; + +export type ModalAction = 'delete' | 'add' | 'change'; + +export type ModalCache = { + name: string, + synchronized_channel: ?string, + inputs: ?{ name: string, action: ?ModalAction, original: string }[], + outputs: ?{ name: string, action: ?ModalAction, original: string }[], }; type State = { + // name, synchronized_channel, inputs, outputs + cache: ModalCache, +}; + +const newCacheFromData = (data: BlockType): ModalCache => { + return { + synchronized_channel: data.synchronized_channel || undefined, + name: data.name, + inputs: data.inputs ? data.inputs.map(i => ({ name: i, action: 'change', original: i })) : undefined, + outputs: data.outputs ? data.outputs.map(i => ({ name: i, action: 'change', original: i })) : undefined, + }; }; +const getCurrentObjs = (arr: any) => arr +.filter((o, i, os) => o.action !== 'delete' && !os.slice(i).find(p => p.action === 'delete' && p.name === o.name)); + +const getCurrentNames = (arr: any) => getCurrentObjs(arr).map(o => o.name); + class ToolchainModal extends React.Component<Props, State> { constructor(props: Props){ super(props); } state = { + cache: newCacheFromData(this.props.data), + } + + componentDidUpdate = (prevProps: Props, prevState: State) => { + if(this.props.data && prevProps.data !== this.props.data){ + this.setState({ cache: newCacheFromData(this.props.data) }); + } } - shouldComponentUpdate(nextProps: Props){ - if(nextProps.active !== this.props.active) - return true; - if(nextProps.data !== undefined && nextProps.data !== this.props.data) - return true; - return false; + addIO = (io: boolean) => { + const arr = io ? 'inputs' : 'outputs'; + const newKey = generateNewKey(io ? 'input' : 'output', getCurrentNames(this.state.cache[arr])); + this.setState({ + cache: { + ...this.state.cache, + [arr]: [ + ...this.state.cache[arr], + { + name: newKey, + action: 'add', + original: newKey, + } + ] + } + }); + } + + deleteIO = (io: boolean, name: string) => { + const arr = io ? 'inputs' : 'outputs'; + this.setState({ + cache: { + ...this.state.cache, + [arr]: [ + ...this.state.cache[arr], + { + name: name, + action: 'delete', + original: name, + } + ] + } + }); + } + + changeIO = (io: boolean, oldName: string, newName: string) => { + const arr = io ? 'inputs' : 'outputs'; + const obj = this.state.cache[arr].slice().reverse().find(obj => obj.action !== 'delete' && obj.name === oldName); + if(!obj){ + console.error(`cant find ${ oldName } in:`); + console.table(this.state.cache[arr]); + return; + //throw new Error(); + } + + const newObj = {...obj, name: newName}; + + this.setState({ + cache: { + ...this.state.cache, + [arr]: this.state.cache[arr].map(o => o === obj ? newObj : o), + } + }); + } + + changeName = (newName: string) => { + this.setState({ + cache: { + ...this.state.cache, + name: newName, + } + }); + } + + changeChannel = (channel: string) => { + //console.log(`changing channel from ${ this.state.cache.synchronized_channel } to ${ channel }`); + this.setState({ + cache: { + ...this.state.cache, + synchronized_channel: channel, + } + }); + } + + saveBlock = () => { + this.props.updateBlockData(this.state.cache); + //this.setState({ cache: newCacheFromData(this.props.data) }); } render = () => { - const data = this.props.data; + const data = this.state.cache; if(!data) return null; @@ -100,6 +195,11 @@ class ToolchainModal extends React.Component<Props, State> { ); }; + const inputNames = hasInputs ? getCurrentNames(data.inputs) : []; + const outputNames = hasOutputs ? getCurrentNames(data.outputs) : []; + const hasInputDups = (new Set(inputNames)).size !== inputNames.length; + const hasOutputDups = (new Set(outputNames)).size !== outputNames.length; + return ( <Modal isOpen={this.props.active} @@ -109,6 +209,7 @@ class ToolchainModal extends React.Component<Props, State> { const f = document.querySelector('#tcModalInitFocus'); if(f && f.focus) f.focus(); + this.setState({ cache: newCacheFromData(this.props.data) }); }} > <ModalHeader toggle={this.props.toggle}> @@ -124,7 +225,7 @@ class ToolchainModal extends React.Component<Props, State> { value={data.name || ''} fieldTest={true} onChange={(e) => { - this.props.updateBlockName(e.target.value); + this.changeName(e.target.value); }} validateFunc={(str) => { if(str === data.name){ @@ -140,7 +241,7 @@ class ToolchainModal extends React.Component<Props, State> { }} /> </FormGroup> - { data.hasOwnProperty('synchronized_channel') + { data.synchronized_channel !== undefined && <FormGroup> <Label> @@ -161,7 +262,7 @@ class ToolchainModal extends React.Component<Props, State> { <DropdownItem id={`channel-${ c }`} key={i} - onClick={e => this.props.updateBlockChannel(c)} + onClick={e => this.changeChannel(c)} > { formatChannel(c) } </DropdownItem> @@ -172,43 +273,49 @@ class ToolchainModal extends React.Component<Props, State> { </Label> </FormGroup> } + <Row> + { (hasInputDups || hasOutputDups) && + <Alert color='danger'> + Two inputs or outputs cannot share the same name! + </Alert> + } + </Row> <Row> { hasInputs && <Col sm={hasOutputs ? 6 : 12}> Inputs { - data.inputs.map((input, i, inputs) => - <FormGroup - key={i} - > - <CacheInput - value={input} - fieldTest={true} - onChange={(e) => { - this.props.updateBlockIOName('input', input, e.target.value); - }} - validateFunc={(str) => { - return ( - str === input || - !inputs.includes(str) || - <span>This input name is already in use in this block</span> - ); - }} + getCurrentObjs(data.inputs) + .map((obj, i, inputs) => { + const input = obj.name; + return ( + <FormGroup + key={i} > - <DeleteInputBtn - deleteFunc={e => { - this.props.deleteBlockIO('input', input); + <CacheInput + value={input} + fieldTest={true} + onChange={(e) => { + this.changeIO(true, input, e.target.value); + }} + validateFunc={(str) => { + return true; }} - /> - </CacheInput> - </FormGroup> - ) + > + <DeleteInputBtn + deleteFunc={e => { + this.deleteIO(true, input); + }} + /> + </CacheInput> + </FormGroup> + ); + }) } <Button onClick={e => { - const newKey = generateNewKey('input', data.inputs); - this.props.addBlockIO('input', newKey); + this.addIO(true); }} > Add Input @@ -220,38 +327,36 @@ class ToolchainModal extends React.Component<Props, State> { <Col sm={hasInputs ? 6 : 12}> Outputs { - data.outputs.map((output, i, outputs) => - <FormGroup - key={i} - > - <CacheInput - value={output} - fieldTest - onChange={(e) => { - this.props.updateBlockIOName('output', output, e.target.value); - }} - validateFunc={(str) => { - return ( - str === output || - !outputs.includes(str) || - <span>This output name is already in use in this block</span> - ); - - }} + getCurrentObjs(data.outputs) + .map((obj, i, outputs) => { + const output = obj.name; + return ( + <FormGroup + key={i} > - <DeleteInputBtn - deleteFunc={e => { - this.props.deleteBlockIO('output', output); + <CacheInput + value={output} + fieldTest={true} + onChange={(e) => { + this.changeIO(false, output, e.target.value); }} - /> - </CacheInput> - </FormGroup> - ) + validateFunc={(str) => { + return true; + }} + > + <DeleteInputBtn + deleteFunc={e => { + this.deleteIO(false, output); + }} + /> + </CacheInput> + </FormGroup> + ); + }) } <Button onClick={e => { - const newKey = generateNewKey('output', data.outputs); - this.props.addBlockIO('output', newKey); + this.addIO(false); }} > Add Output @@ -262,7 +367,7 @@ class ToolchainModal extends React.Component<Props, State> { </Form> </ModalBody> <ModalFooter> - <Button block color='danger' + <Button color='danger' onClick={(e) => { this.props.deleteBlock(); this.props.toggle(); @@ -270,6 +375,15 @@ class ToolchainModal extends React.Component<Props, State> { > Delete Block </Button> + <Button color='success' + disabled={hasInputDups || hasOutputDups} + onClick={(e) => { + this.saveBlock(); + this.props.toggle(); + }} + > + Save Changes + </Button> </ModalFooter> </Modal> );