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)
+ })
+})