Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Import/export saved favorites #964

Merged
merged 14 commits into from
Sep 12, 2019
Merged
11 changes: 9 additions & 2 deletions build_scripts/webpack-rules.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,19 @@ const path = require('path')
module.exports = [
{
test: /\.(js|jsx)$/,
exclude: /(node_modules)|(cypher-codemirror)|(test_utils)|(dist)/,
include: [
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a bit worried that this might break something but from my tests it all seems fine.
We should verify each step in the build pipeline after this is merged.

path.resolve('src'),
path.resolve('node_modules/@neo4j/browser-lambda-parser')
],
// exclude: /(node_modules(?!:\/@neo4j))|(cypher-codemirror)|(test_utils)|(dist)/,
use: 'babel-loader'
},
{
test: /\.(png|gif|jpg|svg)$/,
include: [path.resolve(helpers.browserPath, 'modules')],
include: [
path.resolve(helpers.browserPath, 'modules'),
path.resolve('node_modules/@relate-by-ui/css')
],
use: 'file-loader?limit=20480&name=assets/[name]-[hash].[ext]'
},
{
Expand Down
1 change: 1 addition & 0 deletions build_scripts/webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ module.exports = {
},
plugins: getPlugins(),
resolve: {
symlinks: false,
alias: {
'react-dom': '@hot-loader/react-dom',
'src-root': path.resolve(helpers.sourcePath),
Expand Down
4 changes: 1 addition & 3 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,7 @@ module.exports = {
'/dist/',
'/node_modules/'
],
transformIgnorePatterns: [
`/node_modules/(?!lodash-es|@neo4j/browser-lambda-parser)`
],
transformIgnorePatterns: [`/node_modules/(?!lodash-es|@neo4j/browser-lambda-parser|react-dnd|dnd-core)`],
moduleNameMapper: {
'\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga|html)$':
'<rootDir>/test_utils/__mocks__/fileMock.js',
Expand Down
9 changes: 7 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -124,26 +124,30 @@
},
"dependencies": {
"@neo4j/browser-lambda-parser": "^1.0.3",
"@relate-by-ui/css": "^1.0.4",
"@relate-by-ui/saved-scripts": "^1.0.3",
"ascii-data-table": "^2.1.1",
"classnames": "^2.2.5",
"codemirror": "^5.29.0",
"core-js": "3",
"cypher-codemirror": "1.1.5",
"d3": "3",
"dateformat": "^3.0.3",
"deepmerge": "^2.1.1",
"dnd-core": "^2.5.1",
"dompurify": "^1.0.11",
"file-saver": "^1.3.8",
"firebase": "^5.8.3",
"isomorphic-fetch": "^2.2.1",
"jsonic": "^0.3.0",
"jszip": "^3.2.2",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to verify that this works in IE 11.

"lodash-es": "^4.17.15",
"mockdate": "^2.0.5",
"neo4j-driver": "^1.7.5",
"react": "^16.9.0",
"react-addons-pure-render-mixin": "^15.0.2",
"react-dnd": "^2.5.1",
"react-dnd-html5-backend": "^2.5.1",
"react-dnd": "9.3.2",
"react-dnd-html5-backend": "9.3.2",
"react-dom": "^16.8.1",
"react-icons": "^2.2.1",
"react-redux": "^5.0.7",
Expand All @@ -155,6 +159,7 @@
"regenerator-runtime": "^0.13.2",
"rxjs": "^5.4.2",
"save-as": "^0.1.7",
"semantic-ui-react": "^0.88.0",
"semver": "^5.5.0",
"styled-components": "^4.0.0",
"stylis": "^3.4.10",
Expand Down
113 changes: 87 additions & 26 deletions src/browser/components/FileDrop/FileDrop.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,20 @@ import { withBus } from 'react-suber'
import SVGInline from 'react-svg-inline'

import * as editor from 'shared/modules/editor/editorDuck'
import { addFavorite } from 'shared/modules/favorites/favoritesDuck'
import * as favoritesDuck from 'shared/modules/favorites/favoritesDuck'
import * as foldersDuck from 'shared/modules/favorites/foldersDuck'
import { parseGrass } from 'shared/services/grassUtils'
import { updateGraphStyleData } from 'shared/modules/grass/grassDuck'
import {
showErrorMessage,
executeCommand
} from 'shared/modules/commands/commandsDuck'
import {
createLoadFavoritesPayload,
getFolderNamesFromFavorites,
getMissingFoldersFromNames,
readZipFiles
} from './file-drop.utils'

import {
StyledFileDrop,
Expand All @@ -40,12 +47,18 @@ import {
StyledFileDropActionButton
} from './styled'
import icon from 'icons/task-list-download.svg'
import arrayHasItems from '../../../shared/utils/array-has-items'

export function FileDrop (props) {
const [fileHoverState, setFileHoverState] = useState(false)
const [userSelect, setUserSelect] = useState(false)
const [file, setFile] = useState(null)
const { saveCypherToFavorites, importGrass, dispatchErrorMessage } = props
const {
saveCypherToFavorites,
saveManyFavorites,
importGrass,
dispatchErrorMessage
} = props

const resetState = () => {
setFileHoverState(false)
Expand Down Expand Up @@ -76,7 +89,12 @@ export function FileDrop (props) {
}

const handleDragEnter = event => {
if (!fileHoverState && event.dataTransfer.items[0].kind === 'file') {
if (
!fileHoverState &&
event.dataTransfer.types &&
event.dataTransfer.types.length === 1 &&
event.dataTransfer.types[0] === 'Files'
) {
setFileHoverState(true)
}
}
Expand All @@ -95,28 +113,45 @@ export function FileDrop (props) {

const handleDrop = event => {
const files = event.dataTransfer.files
if (files.length === 1) {
event.stopPropagation()
event.preventDefault()

setFile(files[0])

const extension = ((files[0] || {}).name || '').split('.').pop()
if (['cyp', 'cypher', 'cql', 'txt'].includes(extension)) {
setUserSelect(true)
} else if (extension === 'grass') {
fileLoader(files[0], result => {
importGrass(result)
const action = executeCommand(':style')
props.bus.send(action.type, action)
})
} else {
dispatchErrorMessage(`'.${extension}' is not a valid file extension`)
resetState()
}
} else {

if (files.length !== 1) {
resetState()
return
}

event.stopPropagation()
event.preventDefault()

setFile(files[0])

const extension = ((files[0] || {}).name || '').split('.').pop()

if (['cyp', 'cypher', 'cql', 'txt'].includes(extension)) {
setUserSelect(true)

return
}

if (extension === 'zip') {
readZipFiles(files)
.then(saveManyFavorites)
.then(resetState)

return
}

if (extension === 'grass') {
fileLoader(files[0], result => {
importGrass(result)
const action = executeCommand(':style')
props.bus.send(action.type, action)
})

return
}

dispatchErrorMessage(`'.${extension}' is not a valid file extension`)
resetState()
}

const className = ['filedrop']
Expand Down Expand Up @@ -166,10 +201,28 @@ export function FileDrop (props) {
)
}

const mapStateToProps = state => ({
folders: foldersDuck.getFolders(state)
})
const mapDispatchToProps = dispatch => {
return {
saveCypherToFavorites: file => {
dispatch(addFavorite(file))
dispatch(favoritesDuck.addFavorite(file))
},
saveManyFavorites: (favoritesToAdd, allFolders) => {
const folderNames = getFolderNamesFromFavorites(favoritesToAdd)
const missingFolders = getMissingFoldersFromNames(folderNames, allFolders)
const allFoldersIncludingMissing = [...allFolders, ...missingFolders]

if (arrayHasItems(missingFolders)) {
dispatch(foldersDuck.loadFolders(allFoldersIncludingMissing))
}

dispatch(
favoritesDuck.loadFavorites(
createLoadFavoritesPayload(favoritesToAdd, allFoldersIncludingMissing)
)
)
},
importGrass: file => {
const parsedGrass = parseGrass(file)
Expand All @@ -182,10 +235,18 @@ const mapDispatchToProps = dispatch => {
dispatchErrorMessage: message => dispatch(showErrorMessage(message))
}
}
const mergeProps = (stateProps, dispatchProps, ownProps) => ({
...stateProps,
...dispatchProps,
saveManyFavorites: favorites =>
dispatchProps.saveManyFavorites(favorites, stateProps.folders),
...ownProps
})

export default withBus(
connect(
null,
mapDispatchToProps
mapStateToProps,
mapDispatchToProps,
mergeProps
)(FileDrop)
)
129 changes: 129 additions & 0 deletions src/browser/components/FileDrop/file-drop.utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/*
* Copyright (c) 2002-2019 "Neo4j,"
* Neo4j Sweden AB [http://neo4j.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 <http://www.gnu.org/licenses/>.
*
*/

import {
assign,
compact,
endsWith,
filter,
flatMap,
includes,
join,
keyBy,
map,
reverse,
split,
startsWith,
tail,
values
} from 'lodash-es'
import JSZip from 'jszip'
import uuid from 'uuid'

import { CYPHER_FILE_EXTENSION } from 'shared/services/export-favorites'

/**
* Extracts folders from favorites
* @param {Object[]} favorites
* @return {string[]}
*/
export function getFolderNamesFromFavorites (favorites) {
return compact(map(favorites, 'folderName'))
}

/**
* Returns new folder objects for those who do not have a matching name
* @param {string[]} folderNames
* @param {Object[]} allFolders
* @return {Object[]}
*/
export function getMissingFoldersFromNames (folderNames, allFolders) {
const existingNames = map(allFolders, 'name')

return map(
filter(folderNames, folderName => !includes(existingNames, folderName)),
name => ({
name,
id: uuid.v4()
})
)
}

/**
* Creates a LOAD_FAVORITES payload complete with folder IDs when applicable
* @param {Object[]} favoritesToAdd
* @param {Object[]} allFolders
* @return {Object[]}
*/
export function createLoadFavoritesPayload (favoritesToAdd, allFolders) {
const allFavoriteFolders = keyBy(allFolders, 'name')

return map(favoritesToAdd, ({ id, contents, folderName }) =>
assign(
{
id,
content: contents
},
folderName in allFavoriteFolders
? { folder: allFavoriteFolders[folderName].id }
: {}
)
)
}

/**
* Extracts all .cypher files from a .zip archive and converts them to user scripts
* @param {File[]} uploads uploaded .zip files
* @return {Promise<Object[]>}
*/
export async function readZipFiles (uploads) {
const archives = await Promise.all(map(uploads, JSZip.loadAsync))
const allFiles = flatMap(archives, ({ files }) => values(files))
const onlyCypherFiles = filter(
allFiles,
({ name }) =>
!startsWith(name, '__MACOSX') && endsWith(name, CYPHER_FILE_EXTENSION)
)

return Promise.all(
map(onlyCypherFiles, file =>
file.async('string').then(fileContentToFavoriteFactory(file))
)
)
}

/**
* Factory function returning a file to user script object mapper
* @param {File} file
* @return {Function} user scripts mapper
*/
export function fileContentToFavoriteFactory (file) {
/**
* Maps .zip archive file contents to a user script object
* @param {String} contents file contents
* @return {Object} user scripts object
*/
return contents => {
const pathWithoutLeadingSlash = startsWith(file.name, '/')
? file.name.slice(1)
: file.name
const pathParts = split(pathWithoutLeadingSlash, '/')
const folderName = join(reverse(tail(reverse(pathParts))), '/')

return { id: uuid.v4(), contents, folderName }
}
}
Loading