diff --git a/e2e_tests/integration/style.spec.js b/e2e_tests/integration/style.spec.js new file mode 100644 index 00000000000..169506f8bf2 --- /dev/null +++ b/e2e_tests/integration/style.spec.js @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2002-2018 "Neo4j, Inc" + * Network Engine for Objects in Lund AB [http://neotechnology.com] + * + * This file is part of Neo4j. + * + * Neo4j is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/* global Cypress, cy, test, expect */ + +describe(':style', () => { + it('can connect', () => { + const password = Cypress.env('BROWSER_NEW_PASSWORD') || 'newpassword' + cy.connect('neo4j', password) + }) + it('print the current style', () => { + cy.executeCommand(':clear') + cy.executeCommand('CREATE (n:Style) RETURN n') // To generate any style + const query = ':style' + cy.executeCommand(query) + cy + .get('[data-test-id="frameCommand"]', { timeout: 10000 }) + .first() + .should('contain', query) + cy + .get('[data-test-id="frameContents"]', { timeout: 10000 }) + .first() + .should('contain', 'node {') + .should('contain', 'relationship {') + .should('contain', '""') + }) + it('can reset style with button', () => { + cy.executeCommand(':clear') + cy.executeCommand(':style') + cy.get('[data-test-id="exportGrassButton"]', { timeout: 10000 }) + cy + .get('[data-test-id="styleResetButton"]', { timeout: 10000 }) + .first() + .click() + cy + .get('[data-test-id="frameContents"]', { timeout: 10000 }) + .first() + .should('contain', 'No style generated or set yet') + }) +}) diff --git a/src/browser/components/icons/Icons.jsx b/src/browser/components/icons/Icons.jsx index 4c32c7aec24..54fd0847905 100644 --- a/src/browser/components/icons/Icons.jsx +++ b/src/browser/components/icons/Icons.jsx @@ -294,3 +294,7 @@ export const Spinner = () => ( export const ExclamationTriangleIcon = () => ( ) + +export const FireExtinguisherIcon = ({ title = 'Reset' }) => ( + +) diff --git a/src/browser/modules/Sidebar/FileDrop.jsx b/src/browser/modules/Sidebar/FileDrop.jsx index f87d4009568..89bca7442a5 100644 --- a/src/browser/modules/Sidebar/FileDrop.jsx +++ b/src/browser/modules/Sidebar/FileDrop.jsx @@ -24,7 +24,7 @@ import { connect } from 'preact-redux' import Dropzone from 'react-dropzone' import { addFavorite } from 'shared/modules/favorites/favoritesDuck' -import { parseGrass } from 'shared/modules/commands/helpers/grass' +import { parseGrass } from 'shared/services/grassUtils' import { updateGraphStyleData } from 'shared/modules/grass/grassDuck' import { showErrorMessage } from 'shared/modules/commands/commandsDuck' diff --git a/src/browser/modules/Stream/FrameTitlebar.jsx b/src/browser/modules/Stream/FrameTitlebar.jsx index d4dfe4e5db0..2879823abbf 100644 --- a/src/browser/modules/Stream/FrameTitlebar.jsx +++ b/src/browser/modules/Stream/FrameTitlebar.jsx @@ -81,12 +81,24 @@ class FrameTitlebar extends Component { const { svgElement, graphElement, type } = this.props.visElement downloadPNGFromSVG(svgElement, graphElement, type) } - exportSVG () { const { svgElement, graphElement, type } = this.props.visElement downloadSVG(svgElement, graphElement, type) } - + exportGrass (data) { + var blob = new Blob([data], { + type: 'text/plain;charset=utf-8' + }) + saveAs(blob, 'style.grass') + } + canExport = () => { + let props = this.props + const { frame = {} } = props + return ( + (frame.type === 'cypher' && (this.hasData() || props.visElement)) || + (frame.type === 'style' && this.hasData()) + ) + } render () { let props = this.props const { frame = {} } = props @@ -104,9 +116,7 @@ class FrameTitlebar extends Component { - + @@ -121,13 +131,21 @@ class FrameTitlebar extends Component { - + this.exportCSV(props.getRecords())} > Export CSV + + this.exportGrass(props.getRecords())} + > + Export GraSS + + @@ -142,9 +160,7 @@ class FrameTitlebar extends Component { > - -1} - > + props.fullscreenToggle()} @@ -158,8 +174,9 @@ class FrameTitlebar extends Component { > {expandCollapseIcon} - + props.onReRunClick(frame.cmd, frame.id, frame.requestId)} diff --git a/src/browser/modules/Stream/InfoView.jsx b/src/browser/modules/Stream/InfoView.jsx new file mode 100644 index 00000000000..10a5e140c53 --- /dev/null +++ b/src/browser/modules/Stream/InfoView.jsx @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2002-2018 "Neo4j, Inc" + * Network Engine for Objects in Lund AB [http://neotechnology.com] + * + * This file is part of Neo4j. + * + * Neo4j is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +import { Component } from 'preact' +import { + StyledInfoMessage, + StyledHelpContent, + StyledH4, + StyledHelpDescription, + StyledDiv, + StyledHelpFrame +} from './styled' + +export class InfoView extends Component { + shouldComponentUpdate (props, state) { + return false + } + render () { + const { title, description } = this.props + return ( + + + + INFO + {title} + + + {description} + + + + ) + } +} diff --git a/src/browser/modules/Stream/Stream.jsx b/src/browser/modules/Stream/Stream.jsx index 39fa220092b..42236ceac29 100644 --- a/src/browser/modules/Stream/Stream.jsx +++ b/src/browser/modules/Stream/Stream.jsx @@ -31,6 +31,7 @@ import ParamsFrame from './ParamsFrame' import ErrorFrame from './ErrorFrame' import HelpFrame from './HelpFrame' import SchemaFrame from './SchemaFrame' +import StyleFrame from './StyleFrame' import SysInfoFrame from './SysInfoFrame' import ConnectionFrame from './Auth/ConnectionFrame' import DisconnectFrame from './Auth/DisconnectFrame' @@ -67,6 +68,7 @@ const getFrame = type => { status: ServerStatusFrame, 'switch-success': ServerSwitchFrame, 'switch-fail': ServerSwitchFrame, + style: StyleFrame, default: Frame } return trans[type] || trans['default'] diff --git a/src/browser/modules/Stream/StyleFrame.jsx b/src/browser/modules/Stream/StyleFrame.jsx new file mode 100644 index 00000000000..4018f6cc672 --- /dev/null +++ b/src/browser/modules/Stream/StyleFrame.jsx @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2002-2018 "Neo4j, Inc" + * Network Engine for Objects in Lund AB [http://neotechnology.com] + * + * This file is part of Neo4j. + * + * Neo4j is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +import { connect } from 'preact-redux' +import FrameTemplate from './FrameTemplate' +import { + PaddedDiv, + StyledOneRowStatsBar, + StyledRightPartial, + FrameTitlebarButtonSection +} from './styled' +import { FrameButton } from 'browser-components/buttons' +import { objToCss } from 'services/grassUtils' +import { + executeSystemCommand, + executeCommand +} from 'shared/modules/commands/commandsDuck' +import { getCmdChar } from 'shared/modules/settings/settingsDuck' +import { FireExtinguisherIcon } from 'browser-components/icons/Icons' +import { InfoView } from './InfoView' + +const StyleFrame = ({ frame }) => { + let grass = '' + let contents = ( + + ) + if (frame.result) { + grass = objToCss(frame.result) + contents = ( + +
+          {grass ||
+            'Something went wrong when parsing the GraSS. Please reset and try again.'}
+        
+
+ ) + } + return ( + grass} + contents={contents} + statusbar={} + /> + ) +} + +const StyleStatusbar = ({ resetStyleAction, rerunAction, onResetClick }) => { + return ( + + + + onResetClick(resetStyleAction, rerunAction)} + > + + + + + + ) +} + +const mapStateToProps = (state, ownProps) => { + return { + resetStyleAction: executeSystemCommand(`${getCmdChar(state)}style reset`), + rerunAction: executeCommand(ownProps.frame.cmd, ownProps.frame.id) + } +} +const mapDispatchToProps = dispatch => ({ + onResetClick: (resetStyleAction, rerunAction) => { + dispatch(resetStyleAction) + dispatch(rerunAction) + } +}) + +const Statusbar = connect(mapStateToProps, mapDispatchToProps)(StyleStatusbar) + +export default StyleFrame diff --git a/src/browser/modules/Stream/styled.jsx b/src/browser/modules/Stream/styled.jsx index f89d7c024aa..4f851996f72 100644 --- a/src/browser/modules/Stream/styled.jsx +++ b/src/browser/modules/Stream/styled.jsx @@ -203,12 +203,16 @@ export const StyledCypherMessage = styled.div` float: left; ` export const StyledCypherWarningMessage = styled(StyledCypherMessage)` - background-color: #ffa500; + background-color: ${props => props.theme.warning}; color: #ffffff; ` export const StyledCypherErrorMessage = styled(StyledCypherMessage)` - background-color: #e74c3c; + background-color: ${props => props.theme.error}; + color: #ffffff; +` +export const StyledInfoMessage = styled(StyledCypherMessage)` + background-color: ${props => props.theme.info}; color: #ffffff; ` diff --git a/src/browser/styles/themes.js b/src/browser/styles/themes.js index 58f43f99c38..be2cf4d1405 100644 --- a/src/browser/styles/themes.js +++ b/src/browser/styles/themes.js @@ -56,6 +56,7 @@ export const base = { error: '#E74C3C', warning: '#FD952C', auth: '#428BCA', + info: '#428BCA', // Buttons primaryButtonText: '#fff', diff --git a/src/shared/modules/commands/commandsDuck.test.js b/src/shared/modules/commands/commandsDuck.test.js index cd3589b093e..d2f89b026b9 100644 --- a/src/shared/modules/commands/commandsDuck.test.js +++ b/src/shared/modules/commands/commandsDuck.test.js @@ -355,8 +355,8 @@ describe('commandsDuck', () => { { type: commands.KNOWN_COMMAND }, frames.add({ ...action, - type: 'pre', - result: JSON.stringify({ node: { color: '#000' } }, null, 2) + type: 'style', + result: { node: { color: '#000' } } }), { type: 'NOOP' } ]) diff --git a/src/shared/modules/commands/helpers/grass.js b/src/shared/modules/commands/helpers/grass.js index d1fa91c00e9..894e45cb98b 100644 --- a/src/shared/modules/commands/helpers/grass.js +++ b/src/shared/modules/commands/helpers/grass.js @@ -35,86 +35,3 @@ export const fetchRemoteGrass = (url, whitelist = null) => { }) }) } - -export function parseGrass (string) { - let result - try { - result = JSON.parse(string) - } catch (e) { - result = parseGrassCSS(string) - } - return result -} - -function parseGrassCSS (string) { - let chars = string.split('') - let insideString = false - let insideProps = false - let insideBinding = false - let keyword = '' - let props = '' - let rules = {} - let i, j - - for (i = 0; i < chars.length; i++) { - const c = chars[i] - let skipThis = true - switch (c) { - case '{': - if (insideString) { - skipThis = false - } else if (insideProps) { - insideBinding = true - } else { - insideProps = true - } - break - case '}': - if (insideString) { - skipThis = false - } else if (insideBinding) { - insideBinding = false - } else { - insideProps = false - rules[keyword] = props - keyword = '' - props = '' - } - break - case "'": - case '"': - insideString = !insideString - break - default: - skipThis = false - break - } - - if (skipThis) { - continue - } - - if (insideProps) { - props += c - } else if (!c.match(/[\s\n]/)) { - keyword += c - } - } - - const keys = Object.keys(rules) - for (i = 0; i < keys.length; i++) { - const val = rules[keys[i]] - rules[keys[i]] = {} - const props = val.split(';') - for (j = 0; j < props.length; j++) { - const propKeyVal = props[j].split(':') - if (propKeyVal && propKeyVal.length === 2) { - const prop = propKeyVal[0].trim() - const value = propKeyVal[1].trim() - rules[keys[i]][prop] = value - } - } - } - - return rules -} diff --git a/src/shared/modules/commands/helpers/grass.test.js b/src/shared/modules/commands/helpers/grass.test.js index 32fb7febeef..a319b2566c5 100644 --- a/src/shared/modules/commands/helpers/grass.test.js +++ b/src/shared/modules/commands/helpers/grass.test.js @@ -20,7 +20,8 @@ /* global jest */ -import { fetchRemoteGrass, parseGrass } from './grass' +import { fetchRemoteGrass } from './grass' +import { parseGrass } from 'shared/services/grassUtils' jest.mock('services/remote', () => { return { diff --git a/src/shared/services/commandInterpreterHelper.js b/src/shared/services/commandInterpreterHelper.js index 59fdabf9e4f..5b2dc7c53ef 100644 --- a/src/shared/services/commandInterpreterHelper.js +++ b/src/shared/services/commandInterpreterHelper.js @@ -54,10 +54,8 @@ import { parseHttpVerbCommand, isValidURL } from 'shared/modules/commands/helpers/http' -import { - fetchRemoteGrass, - parseGrass -} from 'shared/modules/commands/helpers/grass' +import { fetchRemoteGrass } from 'shared/modules/commands/helpers/grass' +import { parseGrass } from 'shared/services/grassUtils' import { shouldUseCypherThread } from 'shared/modules/settings/settingsDuck' const availableCommands = [ @@ -289,12 +287,8 @@ const availableCommands = [ let param = match && match[1] ? match[1] : '' if (param === '') { - const grassData = JSON.stringify( - getGraphStyleData(store.getState()), - null, - 2 - ) - put(frames.add({ ...action, type: 'pre', result: grassData })) + const grassData = getGraphStyleData(store.getState()) + put(frames.add({ ...action, type: 'style', result: grassData })) } else if (param === 'reset') { put(updateGraphStyleData(null)) } else if (isValidURL(param)) { diff --git a/src/shared/services/grassUtils.js b/src/shared/services/grassUtils.js new file mode 100644 index 00000000000..e103e50a357 --- /dev/null +++ b/src/shared/services/grassUtils.js @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2002-2018 "Neo4j, Inc" + * Network Engine for Objects in Lund AB [http://neotechnology.com] + * + * This file is part of Neo4j. + * + * Neo4j is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +export function parseGrass (string) { + let result + try { + result = JSON.parse(string) + } catch (e) { + result = parseGrassCSS(string) + } + return result +} + +function parseGrassCSS (string) { + let chars = string.split('') + let insideString = false + let insideProps = false + let insideBinding = false + let keyword = '' + let props = '' + let rules = {} + let i, j + + for (i = 0; i < chars.length; i++) { + const c = chars[i] + let skipThis = true + switch (c) { + case '{': + if (insideString) { + skipThis = false + } else if (insideProps) { + insideBinding = true + } else { + insideProps = true + } + break + case '}': + if (insideString) { + skipThis = false + } else if (insideBinding) { + insideBinding = false + } else { + insideProps = false + rules[keyword] = props + keyword = '' + props = '' + } + break + case "'": + case '"': + insideString = !insideString + break + default: + skipThis = false + break + } + + if (skipThis) { + continue + } + + if (insideProps) { + props += c + } else if (!c.match(/[\s\n]/)) { + keyword += c + } + } + + const keys = Object.keys(rules) + for (i = 0; i < keys.length; i++) { + const val = rules[keys[i]] + rules[keys[i]] = {} + const props = val.split(';') + for (j = 0; j < props.length; j++) { + const propKeyVal = props[j].split(':') + if (propKeyVal && propKeyVal.length === 2) { + const prop = propKeyVal[0].trim() + const value = propKeyVal[1].trim() + rules[keys[i]][prop] = value + } + } + } + + return rules +} + +export const objToCss = obj => { + if (typeof obj !== 'object') { + console.error('Need a object but got ', typeof obj, obj) + return false + } + let output = '' + try { + let level = ' ' + for (let selector in obj) { + if (obj.hasOwnProperty(selector)) { + output += selector + ' {\n' + level + for (let style in obj[selector]) { + if (obj[selector].hasOwnProperty(style)) { + output += + style + + ': ' + + quoteSpecialStyles(style, obj[selector][style]) + + ';\n' + + level + } + } + output = output.trim() + '\n' + output += '}\n' + } + } + } catch (e) { + return false + } + return output +} + +const shouldQuoteStyle = style => ['defaultCaption', 'caption'].includes(style) +const quoteSpecialStyles = (style, value) => + (shouldQuoteStyle(style) ? '"' : '') + + value + + (shouldQuoteStyle(style) ? '"' : '') diff --git a/src/shared/services/grassUtils.test.js b/src/shared/services/grassUtils.test.js new file mode 100644 index 00000000000..3a801cc3d05 --- /dev/null +++ b/src/shared/services/grassUtils.test.js @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2002-2018 "Neo4j, Inc" + * Network Engine for Objects in Lund AB [http://neotechnology.com] + * + * This file is part of Neo4j. + * + * Neo4j is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import { parseGrass, objToCss } from 'services/grassUtils' + +describe('parseGrass', () => { + it('should create an object from a valid CSS string', () => { + const css = 'body{ color: "red"; border: "1 px solid white";}' + + // When + const obj = parseGrass(css) + + // Then + expect(obj.body).toBeDefined() + expect(obj.body.color).toEqual('red') + expect(obj.body.border).toEqual('1 px solid white') + }) + it('should create an object from a valid GraSS string', () => { + const css = + 'node {caption: ""; stroke: #ffffff;} relationship {caption: "{name}";} node.Person {caption: "{city} {zip}";}' + + // When + const obj = parseGrass(css) + + // Then + expect(obj.node).toBeDefined() + expect(obj.node.caption).toEqual('') + expect(obj.node.stroke).toEqual('#ffffff') + expect(obj.relationship).toBeDefined() + expect(obj.relationship.caption).toEqual('{name}') + expect(obj['node.Person'].caption).toEqual('{city} {zip}') + }) +}) + +describe('objToCss', () => { + it('should create CSS from obj', () => { + const obj = { + body: { + color: 'red', + border: '1px solid green' + }, + h1: { + color: '#ffffff' + } + } + const expected = `body { + color: red; + border: 1px solid green; +} +h1 { + color: #ffffff; +} +` + + const css = objToCss(obj) + + expect(css).toEqual(expected) + }) + it('should create GraSS from obj', () => { + const obj = { + node: { + color: 'red', + border: '1px solid green' + }, + 'node.Person': { + color: 'green', + border: '1px solid white', + caption: '{name}' + }, + relationship: { + color: '#ffffff', + caption: '' + } + } + const expected = `node { + color: red; + border: 1px solid green; +} +node.Person { + color: green; + border: 1px solid white; + caption: "{name}"; +} +relationship { + color: #ffffff; + caption: ""; +} +` + + const grass = objToCss(obj) + + expect(grass).toEqual(expected) + }) + it('does not break on null', () => { + const obj = null + + const css = objToCss(obj) + expect(css).toEqual('') + }) + it('does not break on string', () => { + const obj = 'no object' + + const css = objToCss(obj) + expect(css).toEqual(false) + }) + it('does not break on undefined', () => { + const css = objToCss() + expect(css).toEqual(false) + }) +})