From e9b2eb15a17d67fcf4ff89325df4dcf584eea89f Mon Sep 17 00:00:00 2001 From: Kirill Shumilov Date: Thu, 9 Nov 2017 20:15:30 +0300 Subject: [PATCH] feat(xod-client, xod-client-browser, xod-client-electron, xod-fs): add feature to install libraries from cloud in the IDE, integrate in both clients, add saving on FS for desktop client --- packages/xod-arduino/package.json | 2 +- packages/xod-cli/package.json | 3 +- packages/xod-cli/src/xodc-install.js | 121 ++------- .../xod-client-browser/src/containers/App.jsx | 2 + packages/xod-client-electron/src/app/main.js | 1 + .../src/app/workspaceActions.js | 23 ++ packages/xod-client-electron/src/index.jsx | 8 +- .../src/shared/errorFormatter.js | 1 + .../xod-client-electron/src/shared/events.js | 4 + .../src/view/containers/App.jsx | 9 + .../src/view/installLibMiddleware.js | 18 ++ .../test-func/addLibrary.spec.js | 56 ++++ .../test-func/blink.spec.js | 140 ++++++++++ .../test-func/pageObject.js | 53 ++++ .../xod-client-electron/test-func/prepare.js | 73 ++++++ .../test-func/suite.spec.js | 195 -------------- packages/xod-client/package.json | 4 +- .../xod-client/src/core/containers/App.jsx | 2 + .../core/styles/components/PatchGroup.scss | 54 ++-- .../src/core/styles/components/Suggester.scss | 42 +++ .../xod-client/src/core/undoableProject.js | 6 +- packages/xod-client/src/editor/actionTypes.js | 7 + packages/xod-client/src/editor/actions.js | 55 ++++ .../src/editor/components/LibSuggester.jsx | 245 ++++++++++++++++++ .../src/editor/containers/Editor.jsx | 16 ++ packages/xod-client/src/editor/messages.js | 5 +- packages/xod-client/src/editor/reducer.js | 5 + packages/xod-client/src/editor/selectors.js | 5 + packages/xod-client/src/editor/state.js | 1 + packages/xod-client/src/index.js | 5 +- packages/xod-client/src/project/reducer.js | 21 +- packages/xod-client/src/project/selectors.js | 2 +- .../containers/ProjectBrowser.jsx | 77 ++++-- .../xod-client/src/projectBrowser/reducer.js | 19 ++ .../src/projectBrowser/selectors.js | 5 + .../xod-client/src/projectBrowser/state.js | 1 + packages/xod-client/src/utils/menu.js | 4 + packages/xod-client/src/utils/urls.js | 3 + packages/xod-fs/package.json | 1 - packages/xod-fs/src/errorCodes.js | 1 + packages/xod-fs/src/index.js | 2 +- packages/xod-fs/src/save.js | 24 +- packages/xod-fs/src/write.js | 32 +-- packages/xod-func-tools/package.json | 2 +- packages/xod-pm/src/utils.js | 4 +- packages/xod-project/package.json | 2 +- packages/xod-project/src/xodball.js | 22 +- yarn.lock | 4 + 48 files changed, 1006 insertions(+), 381 deletions(-) create mode 100644 packages/xod-client-electron/src/view/installLibMiddleware.js create mode 100644 packages/xod-client-electron/test-func/addLibrary.spec.js create mode 100644 packages/xod-client-electron/test-func/blink.spec.js create mode 100644 packages/xod-client-electron/test-func/prepare.js delete mode 100644 packages/xod-client-electron/test-func/suite.spec.js create mode 100644 packages/xod-client/src/editor/components/LibSuggester.jsx diff --git a/packages/xod-arduino/package.json b/packages/xod-arduino/package.json index 7bdde6f2..ac6550a7 100644 --- a/packages/xod-arduino/package.json +++ b/packages/xod-arduino/package.json @@ -18,7 +18,7 @@ "handlebars": "^4.0.6", "hm-def": "^0.2.0", "ramda": "^0.24.1", - "ramda-fantasy": "^0.7.0", + "ramda-fantasy": "^0.8.0", "sanctuary-def": "^0.9.0", "xod-func-tools": "^0.15.0", "xod-project": "^0.15.0" diff --git a/packages/xod-cli/package.json b/packages/xod-cli/package.json index f0a5a3e6..a4ee80d6 100644 --- a/packages/xod-cli/package.json +++ b/packages/xod-cli/package.json @@ -30,7 +30,8 @@ "xod-arduino": "^0.15.0", "xod-fs": "^0.15.0", "xod-func-tools": "^0.15.0", - "xod-project": "^0.15.0" + "xod-project": "^0.15.0", + "xod-pm": "^0.15.0" }, "devDependencies": { "chai": "^4.0.0", diff --git a/packages/xod-cli/src/xodc-install.js b/packages/xod-cli/src/xodc-install.js index bec1a348..6a251574 100644 --- a/packages/xod-cli/src/xodc-install.js +++ b/packages/xod-cli/src/xodc-install.js @@ -1,115 +1,26 @@ -import * as path from 'path'; import * as xodFs from 'xod-fs'; -import { parseLibUri, toString, toStringWithoutTag } from './lib-uri'; +import { fetchLibData, fetchLibXodball, getLibVersion } from 'xod-pm'; import * as messages from './messages'; -import * as swagger from './swagger'; - -/** - * Checks whether library directory does not already exist. - * @param {Path} libDir - Library installation directory. - * @param {LibUri} libUri - Library URI in the package manager. - * @returns {Promise.} - */ -function libDirDoesNotExist(libDir, libUri) { - return new Promise((resolve, reject) => { - if (!xodFs.doesDirectoryExist(libDir)) return resolve(); - return reject(new Error(`could not install "${toString(libUri)}".\n` + - `"${libDir}" is not empty, remove it manually.`)); - }); -} - -/** - * Parses string to the library URI in the package manager. - * @param string - * @return {Promise.} - */ -function getLibUri(string) { - return parseLibUri(string) - .map(libUri => Promise.resolve.bind(Promise, libUri)) - .getOrElse(Promise.reject.bind(Promise, - new Error(`could not parse "${string}" as /[@].`))) - .apply(); -} - -/** - * Returns the semver from `libUri` or fetches it from the package manager. - * @param swaggerClient - `swagger-js` client. - * @param {LibUri} libUri - Library URI in the package manager. - * @returns {Promise.} - */ -function getSemver(swaggerClient, libUri) { - const { libname, orgname, tag } = libUri; - if (tag !== 'latest') return Promise.resolve(tag); - return swaggerClient.apis.Library.getOrgLib({ libname, orgname }) - .catch((err) => { - if (err.status !== 404) throw swagger.error(err); - throw new Error('could not find library ' + - `"${toStringWithoutTag(libUri)}".`); - }) - .then(({ obj: lib }) => { - const [version] = lib.versions; - if (version) return version; - throw new Error('could not find latest version of ' + - `"${toStringWithoutTag(libUri)}".`); - }); -} - -/** - * Fetches the library version xodball from package manager. - * @param swaggerClient - `swagger-js` client. - * @param {LibUri} libUri - Library URI in the package manager. - * @param {Semver} semver - * @returns {Promise.} - */ -function loadXodball(swaggerClient, libUri, semver) { - const { libname, orgname } = libUri; - return swaggerClient.apis.Version.getLibVersionXodball({ - libname, orgname, semver, - }).then(({ obj: xodball }) => xodball) - .catch((err) => { - if (err.status !== 404) throw swagger.error(err); - throw new Error(`could not find version "${toString(libUri)}".`); - }); -} - -/** - * Returns the library version `Project` from package manager. - * @param swaggerClient - `swagger-js` client. - * @param {LibUri} libUri - Library URI in the package manager. - * @returns {Promise.} - */ -function getProject(swaggerClient, libUri) { - return getSemver(swaggerClient, libUri) - .then(semver => loadXodball(swaggerClient, libUri, semver)) - .catch(() => { - throw new Error(`could not find "${toString(libUri)}".`); - }); -} /** * Installs the library version from package manager. - * @param swaggerUrl - Swagger.json URL for package manager. - * @param {LibUri} libUri - Library URI in the package manager. - * @param {Path} path2 - Installation destination. + * @param {LibUri} libQuery - Library identifier in the package manager (`owner/name@version`). + * @param {Path} distPath - Path to workspace, where should be library installed * @return {Promise.} */ -export default function install(swaggerUrl, libUri, path2) { - return Promise - .all([ - getLibUri(libUri), - swagger.client(swaggerUrl), - xodFs.findClosestWorkspaceDir(path2), - ]) - .then(([libUri2, swaggerClient, workspaceDir]) => { - const orgDir = path.resolve( - xodFs.resolveLibPath(workspaceDir), - xodFs.fsSafeName(libUri2.orgname) - ); - const libDir = path.resolve(orgDir, xodFs.fsSafeName(libUri2.libname)); - return libDirDoesNotExist(libDir, libUri2) - .then(() => getProject(swaggerClient, libUri2)) - .then(xodFs.saveProject(orgDir)) - .then(() => `Installed "${toString(libUri2)}" at "${libDir}".`); +export default function install(swaggerUrl, libQuery, distPath) { + return Promise.all([ + fetchLibData(swaggerUrl, libQuery), + xodFs.findClosestWorkspaceDir(distPath), + ]) + .then(([libData, wsPath]) => { + const params = libData.requestParams; + const libName = `${params.owner}/${params.name}`; + const version = getLibVersion(libData); + + return fetchLibXodball(swaggerUrl, `${libName}@${version}`) + .then(xodball => xodFs.saveProjectAsLibrary(params.owner, xodball, wsPath)) + .then(() => `Installed new library "${libQuery}" into workspace "${wsPath}".`); }) .then(messages.success) .catch((err) => { diff --git a/packages/xod-client-browser/src/containers/App.jsx b/packages/xod-client-browser/src/containers/App.jsx index 17c592ea..0989f52d 100644 --- a/packages/xod-client-browser/src/containers/App.jsx +++ b/packages/xod-client-browser/src/containers/App.jsx @@ -178,6 +178,8 @@ class App extends client.App { onClick(items.exportProject, this.onExport), items.separator, onClick(items.newPatch, this.props.actions.createPatch), + items.separator, + onClick(items.addLibrary, this.props.actions.showLibSuggester), ] ), submenu( diff --git a/packages/xod-client-electron/src/app/main.js b/packages/xod-client-electron/src/app/main.js index c634233e..9da1e30b 100644 --- a/packages/xod-client-electron/src/app/main.js +++ b/packages/xod-client-electron/src/app/main.js @@ -155,6 +155,7 @@ const onReady = () => { }); ipcMain.on(EVENTS.GET_SIDEBAR_PANE_HEIGHT, loadSidebarPaneHeight); ipcMain.on(EVENTS.CHANGE_SIDEBAR_PANE_HEIGHT, saveSidebarPaneHeight); + ipcMain.on(EVENTS.INSTALL_LIBRARY, WA.saveLibrary); createWindow(); win.webContents.on('did-finish-load', () => { diff --git a/packages/xod-client-electron/src/app/workspaceActions.js b/packages/xod-client-electron/src/app/workspaceActions.js index c3e72a7b..b9d5f736 100644 --- a/packages/xod-client-electron/src/app/workspaceActions.js +++ b/packages/xod-client-electron/src/app/workspaceActions.js @@ -16,6 +16,7 @@ import { findProjectMetaByName, resolveDefaultProjectPath, ensureWorkspacePath, + saveProjectAsLibrary, ERROR_CODES as FS_ERROR_CODES, } from 'xod-fs'; import { @@ -171,6 +172,28 @@ export const loadProjectsOrSpawnDefault = R.curry( )(workspacePath) ); +// :: SaveLibPayload :: { request: LibParams, xodball: Xodball } +// :: IpcEvent -> SaveLibPayload -> Promise SaveLibPayload Error +export const saveLibrary = R.curry( + (event, payload) => R.composeP( + () => { + event.sender.send( + EVENTS.INSTALL_LIBRARY_COMPLETE, + payload.request + ); + return payload; + }, + saveProjectAsLibrary(payload.request.owner, payload.xodball), + loadWorkspacePath + )() + .catch((err) => { + event.sender.send( + EVENTS.INSTALL_LIBRARY_FAILED, + errorToPlainObject(err) + ); + }) +); + // ============================================================================= // // Handlers diff --git a/packages/xod-client-electron/src/index.jsx b/packages/xod-client-electron/src/index.jsx index 4f61398f..1b747726 100644 --- a/packages/xod-client-electron/src/index.jsx +++ b/packages/xod-client-electron/src/index.jsx @@ -8,16 +8,22 @@ import popupsReducer from './popups/reducer'; import uploadReducer from './upload/reducer'; import debuggerMiddleware from './debugger/middleware'; +import installLibMiddleware from './view/installLibMiddleware'; const extraReducers = { popups: popupsReducer, upload: uploadReducer, }; +const extraMiddlewares = [ + debuggerMiddleware, + installLibMiddleware, +]; + ReactDOM.render( diff --git a/packages/xod-client-electron/src/shared/errorFormatter.js b/packages/xod-client-electron/src/shared/errorFormatter.js index 3d12a8da..13acecb0 100644 --- a/packages/xod-client-electron/src/shared/errorFormatter.js +++ b/packages/xod-client-electron/src/shared/errorFormatter.js @@ -28,6 +28,7 @@ const ERROR_FORMATTERS = { [XFS_EC.CANT_COPY_DEFAULT_PROJECT]: err => `Could not copy default project at ${err.path}: ${err.message}`, [XFS_EC.CANT_ENUMERATE_PROJECTS]: err => `Could not enumerate projects at ${err.path}: ${err.message}`, [XFS_EC.CANT_SAVE_PROJECT]: err => `Could not save the project at ${err.path}: ${err.message}`, + [XFS_EC.CANT_SAVE_LIBRARY]: err => `Could not save the library at ${err.path}: ${err.message}`, [EC.CANT_CREATE_NEW_PROJECT]: err => `Could not create a new project: ${err.message}`, [EC.CANT_OPEN_SELECTED_PROJECT]: err => `Could not open selected project: ${err.message}`, diff --git a/packages/xod-client-electron/src/shared/events.js b/packages/xod-client-electron/src/shared/events.js index a1468fbe..cf36a858 100644 --- a/packages/xod-client-electron/src/shared/events.js +++ b/packages/xod-client-electron/src/shared/events.js @@ -34,3 +34,7 @@ export const CONFIRM_CLOSE_WINDOW = 'CONFIRM_CLOSE_WINDOW'; export const GET_SIDEBAR_PANE_HEIGHT = 'GET_SIDEBAR_PANE_HEIGHT'; export const CHANGE_SIDEBAR_PANE_HEIGHT = 'CHANGE_SIDEBAR_PANE_HEIGHT'; + +export const INSTALL_LIBRARY = 'INSTALL_LIBRARY'; +export const INSTALL_LIBRARY_COMPLETE = 'INSTALL_LIBRARY_COMPLETE'; +export const INSTALL_LIBRARY_FAILED = 'INSTALL_LIBRARY_FAILED'; diff --git a/packages/xod-client-electron/src/view/containers/App.jsx b/packages/xod-client-electron/src/view/containers/App.jsx index 2cb3cf9e..879f614e 100644 --- a/packages/xod-client-electron/src/view/containers/App.jsx +++ b/packages/xod-client-electron/src/view/containers/App.jsx @@ -165,6 +165,13 @@ class App extends client.App { EVENTS.GET_SIDEBAR_PANE_HEIGHT, (event, sidebarPaneHeight) => this.setState({ sidebarPaneHeight }) ); + ipcRenderer.on( + EVENTS.INSTALL_LIBRARY_FAILED, + (event, error) => { + console.error(error); // eslint-disable-line no-console + this.props.actions.addError(formatError(error)); + } + ); // Debugger debuggerIPC.subscribeOnDebuggerEvents(ipcRenderer, this); @@ -417,6 +424,8 @@ class App extends client.App { onClick(items.exportProject, this.onExport), items.separator, onClick(items.newPatch, this.props.actions.createPatch), + items.separator, + onClick(items.addLibrary, this.props.actions.showLibSuggester), ] ), submenu( diff --git a/packages/xod-client-electron/src/view/installLibMiddleware.js b/packages/xod-client-electron/src/view/installLibMiddleware.js new file mode 100644 index 00000000..04c40b38 --- /dev/null +++ b/packages/xod-client-electron/src/view/installLibMiddleware.js @@ -0,0 +1,18 @@ +import client from 'xod-client'; +import { ipcRenderer } from 'electron'; + +import { INSTALL_LIBRARY } from '../shared/events'; + +export default () => next => (action) => { + if (action.type === client.INSTALL_LIBRARY_COMPLETE) { + ipcRenderer.send( + INSTALL_LIBRARY, + { + request: action.payload.request, + xodball: action.payload.xodball, + } + ); + } + + return next(action); +}; diff --git a/packages/xod-client-electron/test-func/addLibrary.spec.js b/packages/xod-client-electron/test-func/addLibrary.spec.js new file mode 100644 index 00000000..d2f2b297 --- /dev/null +++ b/packages/xod-client-electron/test-func/addLibrary.spec.js @@ -0,0 +1,56 @@ +import fse from 'fs-extra'; +import path from 'path'; + +import { assert } from 'chai'; + +import prepareSuite from './prepare'; + +import { TRIGGER_MAIN_MENU_ITEM } from '../src/testUtils/triggerMainMenu'; + +describe('Add library in the IDE', () => { + const ide = prepareSuite(); + const wsPath = (...extraPath) => path.resolve(ide.tmpHomeDir, 'xod', ...extraPath); + + it('opens an "Add Library" suggester', () => + ide.app.electron.ipcRenderer.emit(TRIGGER_MAIN_MENU_ITEM, ['File', 'Add Library']) + .then(() => ide.page.assertLibSuggesterShown()) + ); + + it('searches for nonexistent library', () => + ide.page.findLibSuggester().setValue('input', 'xod/nonexistent-library') + .then(ide.page.assertLibsNotFound) + ); + + it('searches for existing library', () => + ide.page.findLibSuggester().setValue('input', 'xod/core@0.11.0') + .then(ide.page.assertLibraryFound) + ); + + it('double clicks on found library to install', () => + ide.page.installLibrary() + .then(() => Promise.all([ + ide.page.assertLibSuggesterHidden(), + ide.page.assertProjectBrowserHasInstallingLib('xod/core'), + ])) + ); + + it('checks that installed xod/core have not `concat-4` node', () => + ide.page.waitUntilLibraryInstalled() + .then(() => ide.page.findPatchGroup('xod/core').click()) + .then(() => ide.page.assertNodeUnavailableInProjectBrowser('concat-4')) + ); + + it('checks that library installed on the FS', () => + assert.eventually.isTrue( + fse.pathExists(wsPath('__lib__', 'xod/core')) + ) + ); + + it('checks that library is on version 0.11.0', () => + assert.eventually.equal( + fse.readJSON(wsPath('__lib__', 'xod/core', 'project.xod')) + .then(proj => proj.version), + '0.11.0' + ) + ); +}); diff --git a/packages/xod-client-electron/test-func/blink.spec.js b/packages/xod-client-electron/test-func/blink.spec.js new file mode 100644 index 00000000..405b7432 --- /dev/null +++ b/packages/xod-client-electron/test-func/blink.spec.js @@ -0,0 +1,140 @@ +import fsp from 'fsp'; +import path from 'path'; + +import { assert } from 'chai'; + +import prepareSuite from './prepare'; + +import { TRIGGER_MAIN_MENU_ITEM } from '../src/testUtils/triggerMainMenu'; + +const workspacePath = subPath => path.resolve(__dirname, '../../../workspace/', subPath); + +describe('IDE: Blink project', () => { + const ide = prepareSuite(); + + it('opens a window', () => + assert.eventually.isTrue(ide.app.browserWindow.isVisible()) + ); + + it('has proper title', () => + assert.eventually.strictEqual(ide.app.client.getTitle(), 'XOD') + ); + + describe('creating a new patch', () => { + it('prompts for a name', () => + ide.page.clickAddPatch() + .then(() => ide.page.assertPopupShown('Create new patch')) + ); + + it('closes popup on confirm', () => + ide.page.findPopup().setValue('input', 'my-blink') + .then(() => ide.page.confirmPopup()) + .then(() => ide.page.assertNoPopups()) + ); + + it('opens new tab for the new patch', () => + ide.page.assertActiveTabHasTitle('my-blink') + ); + }); + + describe('xod/core library', () => { + it('provides a patch group that is collapsed initially', () => + ide.page.assertPatchGroupCollapsed('xod/core') + ); + + it('expands on click', () => + ide.page.findPatchGroup('xod/core').click() + .then(() => ide.page.assertPatchGroupExpanded('xod/core')) + ); + + it('provides clock node', () => + ide.page.assertNodeAvailableInProjectBrowser('clock') + ); + }); + + describe('adding nodes to patch', () => { + it('selects a node in project browser', () => + ide.page.scrollToPatchInProjectBrowser('clock') + .then(() => ide.page.selectPatchInProjectBrowser('clock')) + .then(() => ide.page.assertPatchSelected('clock')) + ); + + it('adds a node to patch', () => + ide.page.clickAddNodeButton('clock') + .then(() => ide.page.findNode('clock').isVisible()) + ); + + it('drags a node in place', () => + assert.eventually.deepEqual( + ide.page.dragNode('clock', 150, 10).getLocation(), + { x: 387, y: 81 } + ) + ); + + it('adds the rest of the nodes for the blink patch', () => + ide.page.addNode('digital-output', 150, 300) + .then(() => ide.page.addNode('flip-flop', 150, 200)) + ); + }); + + describe('creating links between nodes', () => { + it('activates linking mode when clicking on a pin', () => + ide.page.findPin('clock', 'TICK').click() + .then(() => ide.page.assertPinIsSelected('clock', 'TICK')) + ); + + it('marks pins that can be linked with selected pin', () => + Promise.all([ + ide.page.assertPinIsAcceptingLinks('flip-flop', 'SET'), + ide.page.assertPinIsAcceptingLinks('flip-flop', 'TGL'), + ide.page.assertPinIsAcceptingLinks('flip-flop', 'RST'), + ]) + ); + + it('connects two compatible pins with a link', () => + ide.page.findPin('flip-flop', 'TGL').click() + .then(() => ide.page.findLink('pulse')) + ); + + it('adds the rest of the links for the blink patch', () => + ide.page.findPin('flip-flop', 'MEM').click() + .then(() => ide.page.findPin('digital-output', 'SIG').click()) + ); + }); + + describe('binding values to inputs', () => { + it('sets clock interval to 0.25', () => + ide.page.bindValue('clock', 'IVAL', '0.25') + ); + + it('sets digital-output port to 13', () => + ide.page.bindValue('digital-output', 'PORT', '13') + ); + }); + + describe('showing code for arduino', () => { + const expectedCpp = fsp.readFileSync( + workspacePath('blink/__fixtures__/arduino.cpp'), + 'utf-8' + ); + + it('shows code', () => + ide.app.electron.ipcRenderer.emit(TRIGGER_MAIN_MENU_ITEM, ['Deploy', 'Show Code For Arduino']) + .then(() => ide.page.getCodeboxValue()) + .then(code => assert.strictEqual(code, expectedCpp, 'Actual and expected C++ don’t match')) + ); + it('closes show code popup', () => + ide.page.closePopup() + ); + }); + + describe('deleting a patch', () => { + it('deletes "my-blink" patch', () => + ide.page.findPatchGroup('welcome-to-xod').click() + .then(() => ide.page.assertPatchGroupExpanded('welcome-to-xod')) + .then(() => ide.page.deletePatch('my-blink')) + .then(() => ide.page.assertNodeUnavailableInProjectBrowser('my-blink')) + .then(() => ide.page.assertTabWithTitleDoesNotExist('my-blink')) + ); + }); +}); diff --git a/packages/xod-client-electron/test-func/pageObject.js b/packages/xod-client-electron/test-func/pageObject.js index ebffa087..edb1ecc3 100644 --- a/packages/xod-client-electron/test-func/pageObject.js +++ b/packages/xod-client-electron/test-func/pageObject.js @@ -126,6 +126,50 @@ function clickAddNodeButton(client, name) { return client.element(`${getSelectorForPatchInProjectBrowser(name)} .add-node`).click(); } +//----------------------------------------------------------------------------- +// LibSuggester +//----------------------------------------------------------------------------- +function findLibSuggester(client) { + return client.element('.Suggester-libs'); +} + +function assertLibSuggesterShown(client) { + return assert.eventually.isTrue(findLibSuggester(client).isVisible()); +} + +function assertLibsNotFound(client) { + return assert.eventually.isTrue( + findLibSuggester(client).waitForExist('.error', 5000) + ); +} + +function assertLibraryFound(client) { + return assert.eventually.isTrue( + findLibSuggester(client).waitForExist('.Suggester-item--library', 5000) + ); +} + +function installLibrary(client) { + return findLibSuggester(client).doubleClick('.Suggester-item--library'); +} + +function assertLibSuggesterHidden(client) { + return assert.eventually.isFalse( + client.isExisting('.Suggester-libs') + ); +} + +function assertProjectBrowserHasInstallingLib(client, libName) { + return assert.eventually.equal( + client.element('.PatchGroup--installing').getText('.name'), + libName + ); +} + +function waitUntilLibraryInstalled(client) { + return findProjectBrowser(client).waitForExist('.PatchGroup--installing', 5000, true); +} + //----------------------------------------------------------------------------- // Patch //----------------------------------------------------------------------------- @@ -247,6 +291,15 @@ const API = { selectPatchInProjectBrowser, openPatchFromProjectBrowser, deletePatch, + // LibSuggester + findLibSuggester, + assertLibSuggesterShown, + assertLibsNotFound, + assertLibraryFound, + installLibrary, + assertLibSuggesterHidden, + assertProjectBrowserHasInstallingLib, + waitUntilLibraryInstalled, }; /** diff --git a/packages/xod-client-electron/test-func/prepare.js b/packages/xod-client-electron/test-func/prepare.js new file mode 100644 index 00000000..7944cc69 --- /dev/null +++ b/packages/xod-client-electron/test-func/prepare.js @@ -0,0 +1,73 @@ +import os from 'os'; +import fsp from 'fsp'; +import fse from 'fs-extra'; +import path from 'path'; +import chai from 'chai'; +import chaiAsPromised from 'chai-as-promised'; + +// Spectron is hoisted at the root of monorepo +// eslint-disable-next-line import/no-extraneous-dependencies +import { Application } from 'spectron'; + +import Page from './pageObject'; + +const DEBUG = process.env.XOD_DEBUG_TESTS; + +// ============================================================================= +// +// Prepare new suite +// +// ============================================================================= + +export default function prepareSuite() { + chai.use(chaiAsPromised); + + const state = { + passed: true, + }; + + before(() => { + const tmpDir = path.join(os.tmpdir(), 'xod-test-workspace-'); + + state.app = new Application({ + path: '../../node_modules/.bin/electron', + args: ['.'], + }); + + // With a dirty hack we extract out objects that we’d interact later + // in tests and tear-down. Impure, but can’t figure out a better solution + // with Mocha + return fsp.mkdtempP(tmpDir) + .then((home) => { + process.env.USERDATA_DIR = process.env.HOME = state.tmpHomeDir = home; + }) + .then(() => state.app.start()) + .then(() => { + state.page = Page.createPageObject(state.app.client); + chaiAsPromised.transferPromiseness = state.app.transferPromiseness; + }); + }); + + afterEach(function checkForFailure() { + if (this.currentTest.state === 'failed') { + state.passed = false; + } + }); + + after(() => { + if (!state.passed || DEBUG) { + state.app.client.getMainProcessLogs().then((logs) => { + logs.forEach(console.log); // eslint-disable-line no-console + }); + } + + const shouldClose = state.app && state.app.isRunning() && (state.passed || !DEBUG); + return shouldClose + // TODO: simulating click on 'confirm' is probably cleaner + ? state.app.electron.ipcRenderer.send('CONFIRM_CLOSE_WINDOW') + .then(() => fse.remove(state.tmpHomeDir)) + : Promise.resolve(); + }); + + return state; +} diff --git a/packages/xod-client-electron/test-func/suite.spec.js b/packages/xod-client-electron/test-func/suite.spec.js deleted file mode 100644 index 05f669ee..00000000 --- a/packages/xod-client-electron/test-func/suite.spec.js +++ /dev/null @@ -1,195 +0,0 @@ -import os from 'os'; -import fsp from 'fsp'; -import fse from 'fs-extra'; -import path from 'path'; - -import chai, { assert } from 'chai'; -import chaiAsPromised from 'chai-as-promised'; - -// Spectron is hoisted at the root of monorepo -// eslint-disable-next-line import/no-extraneous-dependencies -import { Application } from 'spectron'; - -import Page from './pageObject'; - -import { TRIGGER_MAIN_MENU_ITEM } from '../src/testUtils/triggerMainMenu'; - -chai.use(chaiAsPromised); - -const DEBUG = process.env.XOD_DEBUG_TESTS; - -const workspacePath = subPath => path.resolve(__dirname, '../../../workspace/', subPath); - -describe('IDE', () => { - let page; - let app; - let tmpHomeDir; - let passed = true; - - before(() => { - const tmpDir = path.join(os.tmpdir(), 'xod-test-workspace-'); - - app = new Application({ - path: '../../node_modules/.bin/electron', - args: ['.'], - }); - - // With a dirty hack we extract out objects that we’d interact later - // in tests and tear-down. Impure, but can’t figure out a better solution - // with Mocha - return fsp.mkdtempP(tmpDir) - .then((home) => { process.env.USERDATA_DIR = process.env.HOME = tmpHomeDir = home; }) - .then(() => app.start()) - .then(() => { - page = Page.createPageObject(app.client); - chaiAsPromised.transferPromiseness = app.transferPromiseness; - }); - }); - - afterEach(function checkForFailure() { - if (this.currentTest.state === 'failed') { - passed = false; - } - }); - - after(() => { - if (!passed || DEBUG) { - app.client.getMainProcessLogs().then((logs) => { - logs.forEach(console.log); // eslint-disable-line no-console - }); - } - - const shouldClose = app && app.isRunning() && (passed || !DEBUG); - return shouldClose - // TODO: simulating click on 'confirm' is probably cleaner - ? app.electron.ipcRenderer.send('CONFIRM_CLOSE_WINDOW') - .then(() => fse.removeSync(tmpHomeDir)) - : Promise.resolve(); - }); - - it('opens a window', () => - assert.eventually.isTrue(app.browserWindow.isVisible()) - ); - - it('has proper title', () => - assert.eventually.strictEqual(app.client.getTitle(), 'XOD') - ); - - describe('creating a new patch', () => { - it('prompts for a name', () => - page.clickAddPatch() - .then(() => page.assertPopupShown('Create new patch')) - ); - - it('closes popup on confirm', () => - page.findPopup().setValue('input', 'my-blink') - .then(() => page.confirmPopup()) - .then(() => page.assertNoPopups()) - ); - - it('opens new tab for the new patch', () => - page.assertActiveTabHasTitle('my-blink') - ); - }); - - describe('xod/core library', () => { - it('provides a patch group that is collapsed initially', () => - page.assertPatchGroupCollapsed('xod/core') - ); - - it('expands on click', () => - page.findPatchGroup('xod/core').click() - .then(() => page.assertPatchGroupExpanded('xod/core')) - ); - - it('provides clock node', () => - page.assertNodeAvailableInProjectBrowser('clock') - ); - }); - - describe('adding nodes to patch', () => { - it('selects a node in project browser', () => - page.scrollToPatchInProjectBrowser('clock') - .then(() => page.selectPatchInProjectBrowser('clock')) - .then(() => page.assertPatchSelected('clock')) - ); - - it('adds a node to patch', () => - page.clickAddNodeButton('clock') - .then(() => page.findNode('clock').isVisible()) - ); - - it('drags a node in place', () => - assert.eventually.deepEqual( - page.dragNode('clock', 150, 10).getLocation(), - { x: 387, y: 81 } - ) - ); - - it('adds the rest of the nodes for the blink patch', () => - page.addNode('digital-output', 150, 300) - .then(() => page.addNode('flip-flop', 150, 200)) - ); - }); - - describe('creating links between nodes', () => { - it('activates linking mode when clicking on a pin', () => - page.findPin('clock', 'TICK').click() - .then(() => page.assertPinIsSelected('clock', 'TICK')) - ); - - it('marks pins that can be linked with selected pin', () => - Promise.all([ - page.assertPinIsAcceptingLinks('flip-flop', 'SET'), - page.assertPinIsAcceptingLinks('flip-flop', 'TGL'), - page.assertPinIsAcceptingLinks('flip-flop', 'RST'), - ]) - ); - - it('connects two compatible pins with a link', () => - page.findPin('flip-flop', 'TGL').click() - .then(() => page.findLink('pulse')) - ); - - it('adds the rest of the links for the blink patch', () => - page.findPin('flip-flop', 'MEM').click() - .then(() => page.findPin('digital-output', 'SIG').click()) - ); - }); - - describe('binding values to inputs', () => { - it('sets clock interval to 0.25', () => - page.bindValue('clock', 'IVAL', '0.25') - ); - - it('sets digital-output port to 13', () => - page.bindValue('digital-output', 'PORT', '13') - ); - }); - - describe('showing code for arduino', () => { - const expectedCpp = fsp.readFileSync( - workspacePath('blink/__fixtures__/arduino.cpp'), - 'utf-8' - ); - - it('shows code', () => - app.electron.ipcRenderer.emit(TRIGGER_MAIN_MENU_ITEM, ['Deploy', 'Show Code For Arduino']) - .then(() => page.getCodeboxValue()) - .then(code => assert.strictEqual(code, expectedCpp, 'Actual and expected C++ don’t match')) - ); - it('closes show code popup', () => - page.closePopup() - ); - }); - - describe('deleting a patch', () => { - it('deletes "my-blink" patch', () => - page.findPatchGroup('welcome-to-xod').click() - .then(() => page.assertPatchGroupExpanded('welcome-to-xod')) - .then(() => page.deletePatch('my-blink')) - .then(() => page.assertNodeUnavailableInProjectBrowser('my-blink')) - .then(() => page.assertTabWithTitleDoesNotExist('my-blink')) - ); - }); -}); diff --git a/packages/xod-client/package.json b/packages/xod-client/package.json index 989a9dbb..284672c8 100644 --- a/packages/xod-client/package.json +++ b/packages/xod-client/package.json @@ -20,6 +20,7 @@ "dependencies": { "classnames": "^2.2.5", "commander": "^2.9.0", + "debounce": "^1.1.0", "font-awesome": "^4.6.3", "formsy-react": "^0.18.1", "formsy-react-components": "^0.8.1", @@ -27,7 +28,7 @@ "line-intersection": "^1.0.8", "prop-types": "^15.5.10", "ramda": "^0.24.1", - "ramda-fantasy": "^0.7.0", + "ramda-fantasy": "^0.8.0", "rc-menu": "^5.0.10", "rc-progress": "^2.1.2", "rc-switch": "^1.4.2", @@ -59,6 +60,7 @@ "xod-arduino": "^0.15.0", "xod-func-tools": "^0.15.0", "xod-patch-search": "^0.15.0", + "xod-pm": "^0.15.0", "xod-project": "^0.15.0" }, "devDependencies": { diff --git a/packages/xod-client/src/core/containers/App.jsx b/packages/xod-client/src/core/containers/App.jsx index ae59e7ce..cf7cc95a 100644 --- a/packages/xod-client/src/core/containers/App.jsx +++ b/packages/xod-client/src/core/containers/App.jsx @@ -150,6 +150,7 @@ App.propTypes = { toggleDebugger: PropTypes.func.isRequired, logDebugger: PropTypes.func.isRequired, clearDebugger: PropTypes.func.isRequired, + showLibSuggester: PropTypes.func.isRequired, /* eslint-enable react/no-unused-prop-types */ }), }; @@ -185,4 +186,5 @@ App.actions = { cutEntities: actions.cutEntities, copyEntities: actions.copyEntities, pasteEntities: actions.pasteEntities, + showLibSuggester: actions.showLibSuggester, }; diff --git a/packages/xod-client/src/core/styles/components/PatchGroup.scss b/packages/xod-client/src/core/styles/components/PatchGroup.scss index 5c5fdd11..a554d290 100644 --- a/packages/xod-client/src/core/styles/components/PatchGroup.scss +++ b/packages/xod-client/src/core/styles/components/PatchGroup.scss @@ -1,11 +1,7 @@ .PatchGroup { background-color: $sidebar-color-bg; - &__contentInner { - border-top: 1px solid $sidebar-color-border; - } - - &__trigger { + @mixin collapsibleBlock() { display: block; font-family: $font-family-normal; text-decoration: none; @@ -15,17 +11,6 @@ padding: 10px 10px 10px 30px; color: $sidebar-color-text; user-select: none; - cursor: pointer; - - &:after { - font-family: 'FontAwesome'; - content: '\f0da'; - position: absolute; - right: 13px; - top: 12px; - display: block; - transition: transform 100ms; - } &.my:before, &.library:before { @@ -41,6 +26,43 @@ &.library:before { content: '\f02d'; // TODO: bg image } + } + + &__contentInner { + border-top: 1px solid $sidebar-color-border; + } + + &--installing { + @include collapsibleBlock(); + cursor: wait; + color: darken($sidebar-color-text, 20%); + background: darken($sidebar-color-bg, 3%); + + .fa-spin { + position: absolute; + right: 13px; + top: 12px; + display: block; + } + .version { + margin-left: 0.25em; + color: darken($sidebar-color-text, 40%); + } + } + + &__trigger { + @include collapsibleBlock(); + cursor: pointer; + + &:after { + font-family: 'FontAwesome'; + content: '\f0da'; + position: absolute; + right: 13px; + top: 12px; + display: block; + transition: transform 100ms; + } &.is-open:after { transform: rotateZ(90deg); diff --git a/packages/xod-client/src/core/styles/components/Suggester.scss b/packages/xod-client/src/core/styles/components/Suggester.scss index 315debad..0359bf62 100644 --- a/packages/xod-client/src/core/styles/components/Suggester.scss +++ b/packages/xod-client/src/core/styles/components/Suggester.scss @@ -23,6 +23,22 @@ margin-left: -490px; } + .loading-icon { + position: absolute; + right: 8px; + top: 6px; + width: 18px; + height: 30px; + background: $input-color-bg; + font-size: $font-size-l; + overflow: hidden; + text-align: right; + + .fa { + line-height: 32px; + } + } + input { width: 100%; font-size: $font-size-l; @@ -58,6 +74,12 @@ bottom: 0; cursor: default; } + + .error, .hint { + display: block; + margin: 1em 0 .5em 0; + font-size: $font-size-m; + } } ul { @@ -73,6 +95,21 @@ border-radius: 2px; + &--library { + height: 52px; + + .add { + display: block; + font-size: $font-size-s; + padding-top: 2px; + + .author, .license { + display: inline-block; + padding-right: 2em; + } + } + } + &.is-highlighted { background: $sidebar-color-bg-selected; } @@ -87,6 +124,11 @@ margin-bottom: 2px; margin-top: -2px; line-height: 1.2; + + .version { + font-size: $font-size-m; + margin-left: 0.2em; + } } .description { display: block; diff --git a/packages/xod-client/src/core/undoableProject.js b/packages/xod-client/src/core/undoableProject.js index 9267b384..b4b0b5bd 100644 --- a/packages/xod-client/src/core/undoableProject.js +++ b/packages/xod-client/src/core/undoableProject.js @@ -1,6 +1,6 @@ import R from 'ramda'; import { - listPatches, + listLocalPatches, assocPatchUnsafe, getPatchPath, getPatchByPathUnsafe, @@ -123,11 +123,11 @@ export default (reducer, afterHistoryNavigation = R.identity) => (state, action) default: { const nextState = reducer(state, action); - const previousPatchesList = R.compose(listPatches, getProject)(state); + const previousPatchesList = R.compose(listLocalPatches, getProject)(state); const currentPatches = R.compose( R.indexBy(getPatchPath), - listPatches, + listLocalPatches, getProject )(nextState); diff --git a/packages/xod-client/src/editor/actionTypes.js b/packages/xod-client/src/editor/actionTypes.js index b083d542..cbcb308d 100644 --- a/packages/xod-client/src/editor/actionTypes.js +++ b/packages/xod-client/src/editor/actionTypes.js @@ -23,4 +23,11 @@ export const SHOW_SUGGESTER = 'SHOW_SUGGESTER'; export const HIDE_SUGGESTER = 'HIDE_SUGGESTER'; export const HIGHLIGHT_SUGGESTER_ITEM = 'HIGHLIGHT_SUGGESTER_ITEM'; +export const SHOW_LIB_SUGGESTER = 'SHOW_LIB_SUGGESTER'; +export const HIDE_LIB_SUGGESTER = 'HIDE_LIB_SUGGESTER'; + export const START_DRAGGING_PATCH = 'START_DRAGGING_PATCH'; + +export const INSTALL_LIBRARY_BEGIN = 'INSTALL_LIBRARY_BEGIN'; +export const INSTALL_LIBRARY_COMPLETE = 'INSTALL_LIBRARY_COMPLETE'; +export const INSTALL_LIBRARY_FAILED = 'INSTALL_LIBRARY_FAILED'; diff --git a/packages/xod-client/src/editor/actions.js b/packages/xod-client/src/editor/actions.js index de296083..af972275 100644 --- a/packages/xod-client/src/editor/actions.js +++ b/packages/xod-client/src/editor/actions.js @@ -2,6 +2,7 @@ import R from 'ramda'; import { Maybe } from 'ramda-fantasy'; import * as XP from 'xod-project'; +import { fetchLibXodball, stringifyLibQuery } from 'xod-pm'; import { SELECTION_ENTITY_TYPE, @@ -10,6 +11,8 @@ import { } from './constants'; import { LINK_ERRORS, CLIPBOARD_ERRORS } from '../messages/constants'; +import { libInstalled } from './messages'; + import * as ActionType from './actionTypes'; import { @@ -20,6 +23,7 @@ import { } from '../project/actions'; import { addError, + addConfirmation, } from '../messages/actions'; import * as Selectors from './selectors'; @@ -43,6 +47,7 @@ import { resetClipboardEntitiesPosition, } from './utils'; import { isInput, isEdge } from '../utils/browser'; +import { getPmSwaggerUrl } from '../utils/urls'; import { addPoints } from '../project/nodeLayout'; import { ClipboardEntities } from '../types'; @@ -271,6 +276,16 @@ export const highlightSugessterItem = patchPath => ({ }, }); +// Suggester for libraries. +// Maybe it will be merged with common suggester one day. +export const showLibSuggester = () => ({ + type: ActionType.SHOW_LIB_SUGGESTER, + payload: {}, +}); +export const hideLibSuggester = () => ({ + type: ActionType.HIDE_LIB_SUGGESTER, +}); + // Microsoft Edge only supports Text and URL data types const getClipboardDataType = () => (isEdge() ? 'Text' : CLIPBOARD_DATA_TYPE); @@ -401,3 +416,43 @@ export const cutEntities = event => (dispatch) => { dispatch(copyEntities(event)); dispatch(deleteSelection()); }; + +export const installLibrary = reqParams => (dispatch) => { + dispatch({ + type: ActionType.INSTALL_LIBRARY_BEGIN, + payload: reqParams, + }); + + const libName = `${reqParams.owner}/${reqParams.name}`; + + fetchLibXodball(getPmSwaggerUrl(), stringifyLibQuery(reqParams)) + .then(xodball => R.compose( + (patches) => { + dispatch({ + type: ActionType.INSTALL_LIBRARY_COMPLETE, + payload: { + libName, + request: reqParams, + patches, + xodball, + }, + }); + dispatch( + addConfirmation(libInstalled(libName, xodball.version)) + ); + }, + XP.prepareLibPatchesToInsertIntoProject, + )(libName, xodball)) + .catch((err) => { + dispatch({ + type: ActionType.INSTALL_LIBRARY_FAILED, + payload: { + libName, + request: reqParams, + error: err.message, + errorCode: err.errorCode, + }, + }); + dispatch(addError(err.message)); + }); +}; diff --git a/packages/xod-client/src/editor/components/LibSuggester.jsx b/packages/xod-client/src/editor/components/LibSuggester.jsx new file mode 100644 index 00000000..faa3bebf --- /dev/null +++ b/packages/xod-client/src/editor/components/LibSuggester.jsx @@ -0,0 +1,245 @@ +import R from 'ramda'; +import React from 'react'; +import PropTypes from 'prop-types'; + +import classNames from 'classnames'; +import Autosuggest from 'react-autosuggest'; +import Highlighter from 'react-highlight-words'; +import { Icon } from 'react-fa'; +import debounce from 'debounce'; + +import { fetchLibData, getLibVersion, isLibQueryValid } from 'xod-pm'; +import { isAmong } from 'xod-func-tools'; + +import { getPmSwaggerUrl } from '../../utils/urls'; +import { KEYCODE } from '../../utils/constants'; +import SuggesterContainer from './SuggesterContainer'; +import * as MSG from '../messages'; + +const getSuggestionValue = R.prop('requestParams'); + +const renderNothingFound = () => ( +
+ {MSG.LIB_SUGGESTER_NOTHING_FOUND} +
+); + +const renderTypeToBegin = () => ( +
+ {MSG.LIB_SUGGESTER_TYPE_TO_BEGIN} +
+); + +class LibSuggester extends React.Component { + constructor(props) { + super(props); + + this.state = { + notFound: false, + value: '', + suggestions: [], + loading: false, + }; + + this.input = null; + + this.renderItem = this.renderItem.bind(this); + this.onChange = this.onChange.bind(this); + this.onSuggestionsFetchRequested = this.onSuggestionsFetchRequested.bind(this); + this.onSuggestionsClearRequested = this.onSuggestionsClearRequested.bind(this); + this.onSuggestionSelected = this.onSuggestionSelected.bind(this); + this.storeInputReference = this.storeInputReference.bind(this); + this.renderContent = this.renderContent.bind(this); + + // call it once to prepare debounced function + this.fetchLibData = debounce(this.fetchLibData.bind(this), 500); + } + + componentDidMount() { + if (this.input) { + // A hack to avoid typing any character into input when pressing Hotkey + setTimeout(() => this.input.focus(), 1); + } + } + + onChange(e, { newValue, method }) { + if (isAmong(['up', 'down', 'click'], method)) return; + + this.setState({ + value: newValue, + notFound: (newValue === '') ? false : this.state.notFound, + }); + } + + onSelect(value) { + this.props.onInstallLibrary(value); + } + + onSuggestionsFetchRequested({ reason }) { + if (reason !== 'input-changed') return; + if (isLibQueryValid(this.state.value)) { + this.fetchLibData(); + } + } + + onSuggestionsClearRequested() { + this.setState({ + suggestions: [], + }); + } + + onSuggestionSelected(event, { suggestionValue }) { + this.onSelect(suggestionValue); + } + + fetchLibData() { + const request = this.state.value; + this.setState({ + loading: true, + suggestions: [], + notFound: false, + }); + fetchLibData(getPmSwaggerUrl(), request) + .then(R.of) // TODO: Once it will become an array + .catch(R.always([])) + .then( + data => this.setState({ + suggestions: data, + loading: false, + notFound: (data.length === 0), + }) + ); + } + + storeInputReference(autosuggest) { + if (autosuggest !== null) { + this.input = autosuggest.input; + } + } + + isNothingFound() { + return ( + this.state.notFound && + this.state.value !== '' && + this.state.suggestions.length === 0 && + !this.state.loading + ); + } + isNothingSearched() { + return ( + !this.state.notFound && + this.state.suggestions.length === 0 && + !this.state.loading + ); + } + + renderItem(item, { isHighlighted }) { + const cls = classNames('Suggester-item Suggester-item--library', { + 'is-highlighted': isHighlighted, + }); + + const license = (item.license) + ? {item.license} + : null; + + const libName = `${item.owner}/${item.libname}`; + + return ( +
+ + + {getLibVersion(item)} + +
+ by {item.owner} + {license} +
+ +
+ ); + } + + renderContent(children) { + if (this.isNothingFound()) { + return renderNothingFound(); + } + if (this.isNothingSearched()) { + return renderTypeToBegin(); + } + + return children; + } + + render() { + const { value, suggestions } = this.state; + + const inputProps = { + placeholder: 'Search libraries', + value, + onChange: this.onChange, + onKeyDown: (event) => { + const code = event.keyCode || event.which; + if (code === KEYCODE.ESCAPE && event.target.value === '') { + this.props.onBlur(); + } + }, + onBlur: () => this.props.onBlur(), + type: 'search', + }; + + const loading = (this.state.loading) ? ( +
+ +
+ ) : null; + + const cls = `Suggester Suggester-libs ${this.props.addClassName}`; + + return ( +
+ {loading} + ( + + {this.renderContent(children)} + + )} + ref={this.storeInputReference} + /> +
+ ); + } +} + +LibSuggester.defaultProps = { + addClassName: '', + onBlur: () => {}, +}; + +LibSuggester.propTypes = { + addClassName: PropTypes.string, + onInstallLibrary: PropTypes.func.isRequired, + onBlur: PropTypes.func, +}; + +export default LibSuggester; diff --git a/packages/xod-client/src/editor/containers/Editor.jsx b/packages/xod-client/src/editor/containers/Editor.jsx index 47fd0101..21097fe8 100644 --- a/packages/xod-client/src/editor/containers/Editor.jsx +++ b/packages/xod-client/src/editor/containers/Editor.jsx @@ -24,6 +24,7 @@ import { FOCUS_AREAS, DEBUGGER_TAB_ID } from '../constants'; import Patch from './Patch'; import NoPatch from '../components/NoPatch'; import Suggester from '../components/Suggester'; +import LibSuggester from '../components/LibSuggester'; import Inspector from '../components/Inspector'; import Debugger from '../../debugger/containers/Debugger'; import Breadcrumbs from '../../debugger/containers/Breadcrumbs'; @@ -119,6 +120,14 @@ class Editor extends React.Component { /> ) : null; + const libSuggester = (this.props.isLibSuggesterVisible) ? ( + + ) : null; + const DebuggerContainer = (this.props.isDebuggerVisible) ? : null; const BreadcrumbsContainer = ( this.props.isDebuggerVisible && @@ -170,6 +179,7 @@ class Editor extends React.Component { {DebugSessionStopButton} {openedPatch} {suggester} + {libSuggester} {DebuggerContainer} {BreadcrumbsContainer} @@ -195,6 +205,7 @@ Editor.propTypes = { isDebugSessionRunning: PropTypes.bool, suggesterIsVisible: PropTypes.bool, suggesterPlacePosition: PropTypes.object, + isLibSuggesterVisible: PropTypes.bool, defaultNodePosition: PropTypes.object.isRequired, stopDebuggerSession: PropTypes.func, actions: PropTypes.shape({ @@ -208,6 +219,8 @@ Editor.propTypes = { showSuggester: PropTypes.func.isRequired, hideSuggester: PropTypes.func.isRequired, highlightSugessterItem: PropTypes.func.isRequired, + hideLibSuggester: PropTypes.func.isRequired, + installLibrary: PropTypes.func.isRequired, }), }; @@ -219,6 +232,7 @@ const mapStateToProps = R.applySpec({ patchesIndex: ProjectSelectors.getPatchSearchIndex, suggesterIsVisible: EditorSelectors.isSuggesterVisible, suggesterPlacePosition: EditorSelectors.getSuggesterPlacePosition, + isLibSuggesterVisible: EditorSelectors.isLibSuggesterVisible, isHelpbarVisible: EditorSelectors.isHelpbarVisible, isDebuggerVisible: DebuggerSelectors.isDebuggerVisible, isDebugSessionRunning: DebuggerSelectors.isDebugSession, @@ -237,6 +251,8 @@ const mapDispatchToProps = dispatch => ({ showSuggester: Actions.showSuggester, hideSuggester: Actions.hideSuggester, highlightSugessterItem: Actions.highlightSugessterItem, + hideLibSuggester: Actions.hideLibSuggester, + installLibrary: Actions.installLibrary, }, dispatch), }); diff --git a/packages/xod-client/src/editor/messages.js b/packages/xod-client/src/editor/messages.js index 8f86e6b4..18689fc9 100644 --- a/packages/xod-client/src/editor/messages.js +++ b/packages/xod-client/src/editor/messages.js @@ -1,3 +1,6 @@ export const PATCH_FOR_NODE_IS_MISSING = 'Patch for this node is missing.'; -export default {}; +export const libInstalled = (libName, version) => `${libName} @ ${version} installed successfully`; + +export const LIB_SUGGESTER_TYPE_TO_BEGIN = 'Type owner/libname to find a library'; +export const LIB_SUGGESTER_NOTHING_FOUND = 'No library found'; diff --git a/packages/xod-client/src/editor/reducer.js b/packages/xod-client/src/editor/reducer.js index 0bbeb38a..5d3fcb6a 100644 --- a/packages/xod-client/src/editor/reducer.js +++ b/packages/xod-client/src/editor/reducer.js @@ -534,6 +534,11 @@ const editorReducer = (state = {}, action) => { )(state); case EAT.HIGHLIGHT_SUGGESTER_ITEM: return R.assocPath(['suggester', 'highlightedPatchPath'], action.payload.patchPath, state); + case EAT.SHOW_LIB_SUGGESTER: + return R.assoc('libSuggesterVisible', true, state); + case EAT.INSTALL_LIBRARY_BEGIN: + case EAT.HIDE_LIB_SUGGESTER: + return R.assoc('libSuggesterVisible', false, state); // // debugger diff --git a/packages/xod-client/src/editor/selectors.js b/packages/xod-client/src/editor/selectors.js index 30dd1ab1..805dc557 100644 --- a/packages/xod-client/src/editor/selectors.js +++ b/packages/xod-client/src/editor/selectors.js @@ -135,6 +135,11 @@ export const getSuggesterHighlightedPatchPath = R.pipe( R.prop('highlightedPatchPath') ); +export const isLibSuggesterVisible = R.pipe( + getEditor, + R.prop('libSuggesterVisible') +); + // // debugger breadcrumbs // diff --git a/packages/xod-client/src/editor/state.js b/packages/xod-client/src/editor/state.js index d8ce55f1..3a941c97 100644 --- a/packages/xod-client/src/editor/state.js +++ b/packages/xod-client/src/editor/state.js @@ -22,4 +22,5 @@ export default { offset: DEFAULT_PANNING_OFFSET, }, }, + libSuggesterVisible: false, }; diff --git a/packages/xod-client/src/index.js b/packages/xod-client/src/index.js index 8801fbf8..17f31787 100644 --- a/packages/xod-client/src/index.js +++ b/packages/xod-client/src/index.js @@ -15,7 +15,7 @@ import * as ProjectBrowserActions from './projectBrowser/actions'; import * as PopupActions from './popups/actions'; import * as DebuggerActions from './debugger/actions'; -import { TAB_CLOSE } from './editor/actionTypes'; +import { TAB_CLOSE, INSTALL_LIBRARY_COMPLETE } from './editor/actionTypes'; import { SAVE_PROJECT } from './project/actionTypes'; import * as EditorConstants from './editor/constants'; @@ -54,7 +54,7 @@ export * from './projectBrowser/actions'; export * from './popups/actions'; export * from './debugger/actions'; -export { TAB_CLOSE } from './editor/actionTypes'; +export { TAB_CLOSE, INSTALL_LIBRARY_COMPLETE } from './editor/actionTypes'; export { SAVE_PROJECT } from './project/actionTypes'; export * from './editor/selectors'; @@ -113,6 +113,7 @@ export default Object.assign({ hasUnsavedChanges, TAB_CLOSE, SAVE_PROJECT, + INSTALL_LIBRARY_COMPLETE, }, UserSelectors, EditorSelectors, diff --git a/packages/xod-client/src/project/reducer.js b/packages/xod-client/src/project/reducer.js index 2ed8c9f4..96b7fb31 100644 --- a/packages/xod-client/src/project/reducer.js +++ b/packages/xod-client/src/project/reducer.js @@ -3,7 +3,7 @@ import * as XP from 'xod-project'; import { explodeEither } from 'xod-func-tools'; import * as AT from './actionTypes'; -import { PASTE_ENTITIES } from '../editor/actionTypes'; +import { PASTE_ENTITIES, INSTALL_LIBRARY_COMPLETE } from '../editor/actionTypes'; import { addPoints, @@ -57,6 +57,19 @@ const moveEntities = (positionLens, deltaPosition) => R.map( ) ); +// :: LibName -> Project -> Project +const omitLibPatches = R.curry( + (libName, project) => R.compose( + XP.omitPatches(R.__, project), + R.filter(R.compose( + R.equals(libName), + XP.getLibraryName + )), + R.map(XP.getPatchPath), + XP.listLibraryPatches + )(project) +); + export default (state = {}, action) => { switch (action.type) { // @@ -148,6 +161,12 @@ export default (state = {}, action) => { ); } + case INSTALL_LIBRARY_COMPLETE: + return R.compose( + XP.assocPatchListUnsafe(action.payload.patches), + omitLibPatches(action.payload.libName) + )(state); + // // Bulk actions on multiple entities diff --git a/packages/xod-client/src/project/selectors.js b/packages/xod-client/src/project/selectors.js index fcf4e7b0..603d4a8f 100644 --- a/packages/xod-client/src/project/selectors.js +++ b/packages/xod-client/src/project/selectors.js @@ -145,7 +145,7 @@ const addDeadFlag = R.curry( // :: State -> StrMap RenderableNode export const getRenderableNodes = createMemoizedSelector( [getProject, getCurrentPatch, getCurrentPatchNodes, getConnectedPins], - [R.T, R.equals, R.equals, R.equals], + [R.equals, R.equals, R.equals, R.equals], (project, maybeCurrentPatch, currentPatchNodes, connectedPins) => Maybe.maybe( {}, diff --git a/packages/xod-client/src/projectBrowser/containers/ProjectBrowser.jsx b/packages/xod-client/src/projectBrowser/containers/ProjectBrowser.jsx index 726b0d34..3f456c2a 100644 --- a/packages/xod-client/src/projectBrowser/containers/ProjectBrowser.jsx +++ b/packages/xod-client/src/projectBrowser/containers/ProjectBrowser.jsx @@ -15,6 +15,7 @@ import { isPathLocal, getBaseName, } from 'xod-project'; +import { isAmong } from 'xod-func-tools'; import * as ProjectActions from '../../project/actions'; import * as ProjectBrowserActions from '../actions'; @@ -222,31 +223,59 @@ class ProjectBrowser extends React.Component { } renderLibraryPatches() { - const { libs, selectedPatchPath } = this.props; + const { libs, installingLibs, selectedPatchPath } = this.props; const { setSelection, switchPatch, startDraggingPatch } = this.props.actions; + const installingLibsComponents = R.map( + ({ owner, name, version }) => ({ + name: `${owner}/${name}`, + component: (
+ {owner}/{name} + {version} + +
), + }), + installingLibs + ); + const installingLibNames = R.pluck('name', installingLibsComponents); - return R.toPairs(libs).map(([libName, libPatches]) => ( - - {libPatches.map(({ path, dead }) => - setSelection(path)} - onDoubleClick={() => switchPatch(path)} - onBeginDrag={startDraggingPatch} - hoverButtons={this.libraryPatchesHoveredButtons(path)} - /> - )} - - )); + const libComponents = R.compose( + R.reject(R.compose( + isAmong(installingLibNames), + R.prop('name') + )), + R.map(([libName, libPatches]) => ({ + name: libName, + component: ( + {libPatches.map(({ path, dead }) => + setSelection(path)} + onDoubleClick={() => switchPatch(path)} + onBeginDrag={startDraggingPatch} + hoverButtons={this.libraryPatchesHoveredButtons(path)} + /> + )} + ), + })), + R.toPairs + )(libs); + + return R.compose( + R.map(R.prop('component')), + R.sortBy(R.prop('name')), + R.concat + )(libComponents, installingLibsComponents); } renderPatches(patchType) { @@ -309,6 +338,7 @@ ProjectBrowser.propTypes = { localPatches: sanctuaryPropType($.Array(Patch)), popups: PropTypes.object.isRequired, libs: sanctuaryPropType($.StrMap($.Array(Patch))), + installingLibs: PropTypes.array, defaultNodePosition: PropTypes.object.isRequired, actions: PropTypes.shape({ addNode: PropTypes.func.isRequired, @@ -335,6 +365,7 @@ const mapStateToProps = R.applySpec({ localPatches: ProjectBrowserSelectors.getLocalPatches, popups: PopupSelectors.getProjectBrowserPopups, libs: ProjectBrowserSelectors.getLibs, + installingLibs: ProjectBrowserSelectors.getInstallingLibraries, defaultNodePosition: EditorSelectors.getDefaultNodePlacePosition, }); diff --git a/packages/xod-client/src/projectBrowser/reducer.js b/packages/xod-client/src/projectBrowser/reducer.js index 8280b5c3..65f01a09 100644 --- a/packages/xod-client/src/projectBrowser/reducer.js +++ b/packages/xod-client/src/projectBrowser/reducer.js @@ -13,6 +13,12 @@ import { PATCH_RENAME, } from '../project/actionTypes'; +import { + INSTALL_LIBRARY_BEGIN, + INSTALL_LIBRARY_COMPLETE, + INSTALL_LIBRARY_FAILED, +} from '../editor/actionTypes'; + const selectionReducer = (state, action) => { switch (action.type) { case SET_SELECTION: @@ -36,10 +42,23 @@ const selectionReducer = (state, action) => { } }; +const installingLibrariesReducer = (state, action) => { + switch (action.type) { + case INSTALL_LIBRARY_BEGIN: + return R.append(action.payload, state); + case INSTALL_LIBRARY_FAILED: + case INSTALL_LIBRARY_COMPLETE: + return R.reject(R.equals(action.payload.request), state); + default: + return state; + } +}; + export default (state = initialState, action) => R.merge( state, { selectedPatchPath: selectionReducer(state.selectedPatchPath, action), + installingLibraries: installingLibrariesReducer(state.installingLibraries, action), } ); diff --git a/packages/xod-client/src/projectBrowser/selectors.js b/packages/xod-client/src/projectBrowser/selectors.js index c0b0973d..a7d70db4 100644 --- a/packages/xod-client/src/projectBrowser/selectors.js +++ b/packages/xod-client/src/projectBrowser/selectors.js @@ -69,3 +69,8 @@ export const getLibs = createMemoizedSelector( R.map(markDeadPatches(project)) )(patches) ); + +export const getInstallingLibraries = R.compose( + R.prop('installingLibraries'), + getProjectBrowser +); diff --git a/packages/xod-client/src/projectBrowser/state.js b/packages/xod-client/src/projectBrowser/state.js index 36a0ea99..a164d303 100644 --- a/packages/xod-client/src/projectBrowser/state.js +++ b/packages/xod-client/src/projectBrowser/state.js @@ -1,3 +1,4 @@ export default { selectedPatchPath: null, + installingLibraries: [], }; diff --git a/packages/xod-client/src/utils/menu.js b/packages/xod-client/src/utils/menu.js index eeebe318..3887ac04 100644 --- a/packages/xod-client/src/utils/menu.js +++ b/packages/xod-client/src/utils/menu.js @@ -47,6 +47,10 @@ const rawItems = { label: 'New Patch', command: COMMAND.ADD_PATCH, }, + addLibrary: { + key: 'addLibrary', + label: 'Add Library', + }, edit: { key: 'edit', diff --git a/packages/xod-client/src/utils/urls.js b/packages/xod-client/src/utils/urls.js index 93c51eee..b1c75779 100644 --- a/packages/xod-client/src/utils/urls.js +++ b/packages/xod-client/src/utils/urls.js @@ -35,3 +35,6 @@ const HOSTNAME = process.env.XOD_HOSTNAME || 'xod.io'; // :: String -> String export const getCompileLimitUrl = () => `https://compile.${HOSTNAME}/limits`; + +export const getPmSwaggerUrl = () => + `https://pm.${HOSTNAME}/swagger`; diff --git a/packages/xod-fs/package.json b/packages/xod-fs/package.json index b3403932..93ac5c5a 100644 --- a/packages/xod-fs/package.json +++ b/packages/xod-fs/package.json @@ -11,7 +11,6 @@ "author": "XOD Team ", "license": "AGPL-3.0", "dependencies": { - "file-save": "^0.2.0", "fs-extra": "^3.0.1", "hm-def": "^0.2.0", "json-stable-stringify": "^1.0.1", diff --git a/packages/xod-fs/src/errorCodes.js b/packages/xod-fs/src/errorCodes.js index 694f4f15..77356db4 100644 --- a/packages/xod-fs/src/errorCodes.js +++ b/packages/xod-fs/src/errorCodes.js @@ -6,4 +6,5 @@ export const CANT_COPY_STDLIB = 'CANT_COPY_STDLIB'; export const CANT_COPY_DEFAULT_PROJECT = 'CANT_COPY_DEFAULT_PROJECT'; export const CANT_ENUMERATE_PROJECTS = 'CANT_ENUMERATE_PROJECTS'; export const CANT_SAVE_PROJECT = 'CANT_SAVE_PROJECT'; +export const CANT_SAVE_LIBRARY = 'CANT_SAVE_LIBRARY'; export const INVALID_FILE_CONTENTS = 'INVALID_FILE_CONTENTS'; diff --git a/packages/xod-fs/src/index.js b/packages/xod-fs/src/index.js index e40967db..79399f41 100644 --- a/packages/xod-fs/src/index.js +++ b/packages/xod-fs/src/index.js @@ -2,7 +2,7 @@ import * as ERROR_CODES from './errorCodes'; export { default as pack } from './pack'; export { arrangeByFiles, fsSafeName } from './unpack'; -export { saveArrangedFiles, saveProject } from './save'; +export { saveArrangedFiles, saveProject, saveProjectAsLibrary } from './save'; export { writeJSON, writeFile } from './write'; export { spawnWorkspaceFile, spawnStdLib, spawnDefaultProject } from './spawn'; export { readDir, readFile, readJSON } from './read'; diff --git a/packages/xod-fs/src/save.js b/packages/xod-fs/src/save.js index 4e3b5544..3664621a 100644 --- a/packages/xod-fs/src/save.js +++ b/packages/xod-fs/src/save.js @@ -2,10 +2,10 @@ import path from 'path'; import R from 'ramda'; import { rejectWithCode } from 'xod-func-tools'; -import { resolvePath, expandHomeDir, isPatchFile, isProjectFile } from './utils'; +import { resolvePath, expandHomeDir, isPatchFile, isProjectFile, resolveLibPath } from './utils'; import { writeFile, writeJSON } from './write'; import { Backup } from './backup'; -import { arrangeByFiles } from './unpack'; +import { arrangeByFiles, fsSafeName } from './unpack'; import { omitDefaultOptionsFromPatchFileContents, omitDefaultOptionsFromProjectFileContents, @@ -49,10 +49,9 @@ export const saveArrangedFiles = R.curry((pathToWorkspace, data) => { return Promise.all(savingFiles) .then(backup.clear) - .catch((err) => { - backup.restore() - .then(() => { throw err; }); - }); + .catch(err => backup.restore() + .then(() => Promise.reject(err)) + ); }); }); @@ -76,4 +75,17 @@ export const saveProject = R.curry( .catch(rejectWithCode(ERROR_CODES.CANT_SAVE_PROJECT)) ); +// :: String -> Project -> Path -> Promise Project Error +export const saveProjectAsLibrary = R.curry( + (owner, project, workspacePath) => { + const distPath = R.compose( + libPath => path.resolve(libPath, fsSafeName(owner)), + resolveLibPath + )(workspacePath); + + return saveProject(distPath, project) + .catch(rejectWithCode(ERROR_CODES.CANT_SAVE_LIBRARY)); + } +); + export default saveProject; diff --git a/packages/xod-fs/src/write.js b/packages/xod-fs/src/write.js index 87326c61..ff862e1f 100644 --- a/packages/xod-fs/src/write.js +++ b/packages/xod-fs/src/write.js @@ -1,32 +1,28 @@ import { curry, unless, test, concat, __ } from 'ramda'; -import fileSave from 'file-save'; +import { outputFile } from 'fs-extra'; +// import fileSave from 'file-save'; import stringify from 'json-stable-stringify'; import { resolvePath } from './utils'; // :: outputPath -> data -> Promise -export const writeFile = curry((outputPath, data, encoding) => new Promise( - (resolve, reject) => { - const resolvedPath = resolvePath(outputPath); - const fstream = fileSave(resolvedPath); +export const writeFile = curry((outputPath, data, encoding) => { + const resolvedPath = resolvePath(outputPath); - const dataWithEol = unless( - test(/\n$/g), - concat(__, '\n') - )(data); + const dataWithEol = unless( + test(/\n$/g), + concat(__, '\n') + )(data); - fstream.write(dataWithEol, encoding); - fstream.end(); - fstream.finish(() => resolve({ path: resolvedPath, data })); - fstream.error((err) => { - reject(Object.assign(err, { path: resolvedPath, data })); - }); - } -)); + return outputFile(resolvedPath, dataWithEol, encoding) + .then(() => ({ path: resolvedPath, data })) + .catch( + err => Promise.reject(Object.assign(err, { path: resolvedPath, data })) + ); +}); // :: outputPath -> data -> Promise export const writeJSON = curry((outputPath, data) => writeFile(outputPath, stringify(data, { space: 2 }), 'utf8') - .catch((err) => { throw Object.assign(err, { path: outputPath, data }); }) ); export default { diff --git a/packages/xod-func-tools/package.json b/packages/xod-func-tools/package.json index f3191591..3d6889ab 100644 --- a/packages/xod-func-tools/package.json +++ b/packages/xod-func-tools/package.json @@ -15,7 +15,7 @@ "dependencies": { "hm-def": "^0.2.0", "ramda": "^0.22.1", - "ramda-fantasy": "^0.7.0", + "ramda-fantasy": "^0.8.0", "sanctuary-def": "^0.9.0", "sanctuary-type-identifiers": "^1.0.0" }, diff --git a/packages/xod-pm/src/utils.js b/packages/xod-pm/src/utils.js index 74b80c91..f86fb703 100644 --- a/packages/xod-pm/src/utils.js +++ b/packages/xod-pm/src/utils.js @@ -66,7 +66,9 @@ export const unfoldMaybeLibQuery = R.curry( () => rejectWithCode( ERR_CODES.CANT_PARSE_LIBRARY_REQUEST, new Error(MSG.CANT_PARSE_LIBRARY_REQUEST) - ), + )(), + // ^ this function call prevents from constructing Error without the need, + // so it prevents falsy promise rejection justFn, maybe ) diff --git a/packages/xod-project/package.json b/packages/xod-project/package.json index 7018dde5..32d352a7 100644 --- a/packages/xod-project/package.json +++ b/packages/xod-project/package.json @@ -17,7 +17,7 @@ "dependencies": { "hm-def": "^0.2.0", "ramda": "^0.24.1", - "ramda-fantasy": "^0.7.0", + "ramda-fantasy": "^0.8.0", "sanctuary-def": "^0.9.0", "shortid": "^2.2.6", "xod-func-tools": "^0.15.0" diff --git a/packages/xod-project/src/xodball.js b/packages/xod-project/src/xodball.js index 325de119..4671c724 100644 --- a/packages/xod-project/src/xodball.js +++ b/packages/xod-project/src/xodball.js @@ -7,11 +7,15 @@ import { omitTypeHints, } from 'xod-func-tools'; -import { getPatchPath } from './patch'; +import { + getPatchPath, + resolveNodeTypesInPatch, +} from './patch'; import { listLibraryPatches, omitPatches, injectProjectTypeHints, + listPatchesWithoutBuiltIns, } from './project'; import { addMissingOptionalProjectFields, @@ -68,3 +72,19 @@ export const toXodball = def( ) ) ); + + +export const prepareLibPatchesToInsertIntoProject = def( + 'prepareLibPatchesToInsertIntoProject :: String -> Project -> [Patch]', + (libName, xodball) => R.compose( + explodeEither, + R.map(R.compose( + R.map(R.compose( + resolveNodeTypesInPatch, + R.over(R.lensProp('path'), R.replace('@', libName)), + )), + listPatchesWithoutBuiltIns, + )), + fromXodballData + )(xodball) +); diff --git a/yarn.lock b/yarn.lock index 20a5441b..ddfd99a3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3038,6 +3038,10 @@ dateformat@^1.0.11, dateformat@^1.0.12: get-stdin "^4.0.1" meow "^3.3.0" +debounce@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/debounce/-/debounce-1.1.0.tgz#6a1a4ee2a9dc4b7c24bb012558dbcdb05b37f408" + debug@2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/debug/-/debug-2.2.0.tgz#f87057e995b1a1f6ae6a4960664137bc56f039da"