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

This commit is contained in:
Kirill Shumilov
2017-11-09 20:15:30 +03:00
parent 43bb722d7d
commit e9b2eb15a1
48 changed files with 1006 additions and 381 deletions

View File

@@ -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"

View File

@@ -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",

View File

@@ -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.<void>}
*/
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.<LibUri>}
*/
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 <orgname>/<libname>[@<tag>].`)))
.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.<Semver>}
*/
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.<Xodball>}
*/
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.<Project>}
*/
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.<void>}
*/
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) => {

View File

@@ -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(

View File

@@ -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', () => {

View File

@@ -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

View File

@@ -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(
<Root
extraReducers={extraReducers}
extraMiddlewares={[debuggerMiddleware]}
extraMiddlewares={extraMiddlewares}
initialState={initialState} // TODO: Remove project and opened patch when possible
>
<App />

View File

@@ -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}`,

View File

@@ -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';

View File

@@ -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(

View File

@@ -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);
};

View File

@@ -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'
)
);
});

View File

@@ -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++ dont 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'))
);
});
});

View File

@@ -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,
};
/**

View File

@@ -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 wed interact later
// in tests and tear-down. Impure, but cant 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;
}

View File

@@ -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 wed interact later
// in tests and tear-down. Impure, but cant 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++ dont 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'))
);
});
});

View File

@@ -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": {

View File

@@ -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,
};

View File

@@ -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);

View File

@@ -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;

View File

@@ -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);

View File

@@ -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';

View File

@@ -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));
});
};

View File

@@ -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 = () => (
<div className="error">
{MSG.LIB_SUGGESTER_NOTHING_FOUND}
</div>
);
const renderTypeToBegin = () => (
<div className="hint">
{MSG.LIB_SUGGESTER_TYPE_TO_BEGIN}
</div>
);
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)
? <span className="license">{item.license}</span>
: null;
const libName = `${item.owner}/${item.libname}`;
return (
<div className={cls} title={libName}>
<span className="path">
<Highlighter
searchWords={[this.state.value]}
textToHighlight={libName}
/>
<span className="version">{getLibVersion(item)}</span>
</span>
<div className="add">
<span className="author">by {item.owner}</span>
{license}
</div>
<Highlighter
className="description"
searchWords={[this.state.value]}
textToHighlight={item.description}
/>
</div>
);
}
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) ? (
<div className="loading-icon">
<Icon
name="circle-o-notch"
spin
/>
</div>
) : null;
const cls = `Suggester Suggester-libs ${this.props.addClassName}`;
return (
<div className={cls}>
{loading}
<Autosuggest
suggestions={suggestions}
value={value}
inputProps={inputProps}
alwaysRenderSuggestions
highlightFirstSuggestion
getSuggestionValue={getSuggestionValue}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
onSuggestionSelected={this.onSuggestionSelected}
renderSuggestion={this.renderItem}
renderSuggestionsContainer={({ containerProps, children }) => (
<SuggesterContainer containerProps={containerProps}>
{this.renderContent(children)}
</SuggesterContainer>
)}
ref={this.storeInputReference}
/>
</div>
);
}
}
LibSuggester.defaultProps = {
addClassName: '',
onBlur: () => {},
};
LibSuggester.propTypes = {
addClassName: PropTypes.string,
onInstallLibrary: PropTypes.func.isRequired,
onBlur: PropTypes.func,
};
export default LibSuggester;

View File

@@ -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) ? (
<LibSuggester
addClassName={(this.props.isHelpbarVisible) ? 'with-helpbar' : ''}
onInstallLibrary={this.props.actions.installLibrary}
onBlur={this.props.actions.hideLibSuggester}
/>
) : null;
const DebuggerContainer = (this.props.isDebuggerVisible) ? <Debugger /> : null;
const BreadcrumbsContainer = (
this.props.isDebuggerVisible &&
@@ -170,6 +179,7 @@ class Editor extends React.Component {
{DebugSessionStopButton}
{openedPatch}
{suggester}
{libSuggester}
{DebuggerContainer}
{BreadcrumbsContainer}
</Workarea>
@@ -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),
});

View File

@@ -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';

View File

@@ -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

View File

@@ -135,6 +135,11 @@ export const getSuggesterHighlightedPatchPath = R.pipe(
R.prop('highlightedPatchPath')
);
export const isLibSuggesterVisible = R.pipe(
getEditor,
R.prop('libSuggesterVisible')
);
//
// debugger breadcrumbs
//

View File

@@ -22,4 +22,5 @@ export default {
offset: DEFAULT_PANNING_OFFSET,
},
},
libSuggesterVisible: false,
};

View File

@@ -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,

View File

@@ -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

View File

@@ -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(
{},

View File

@@ -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: (<div
className="PatchGroup PatchGroup--installing library"
>
<span className="name">{owner}/{name}</span>
<span className="version">{version}</span>
<Icon name="circle-o-notch" spin />
</div>),
}),
installingLibs
);
const installingLibNames = R.pluck('name', installingLibsComponents);
return R.toPairs(libs).map(([libName, libPatches]) => (
<PatchGroup
key={libName}
type="library"
name={libName}
onClose={this.deselectIfInLibrary(libName)}
>
{libPatches.map(({ path, dead }) =>
<PatchGroupItem
key={path}
patchPath={path}
dead={dead}
label={getBaseName(path)}
isSelected={path === selectedPatchPath}
onClick={() => setSelection(path)}
onDoubleClick={() => switchPatch(path)}
onBeginDrag={startDraggingPatch}
hoverButtons={this.libraryPatchesHoveredButtons(path)}
/>
)}
</PatchGroup>
));
const libComponents = R.compose(
R.reject(R.compose(
isAmong(installingLibNames),
R.prop('name')
)),
R.map(([libName, libPatches]) => ({
name: libName,
component: (<PatchGroup
key={libName}
type="library"
name={libName}
onClose={this.deselectIfInLibrary(libName)}
>
{libPatches.map(({ path, dead }) =>
<PatchGroupItem
key={path}
patchPath={path}
dead={dead}
label={getBaseName(path)}
isSelected={path === selectedPatchPath}
onClick={() => setSelection(path)}
onDoubleClick={() => switchPatch(path)}
onBeginDrag={startDraggingPatch}
hoverButtons={this.libraryPatchesHoveredButtons(path)}
/>
)}
</PatchGroup>),
})),
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,
});

View File

@@ -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),
}
);

View File

@@ -69,3 +69,8 @@ export const getLibs = createMemoizedSelector(
R.map(markDeadPatches(project))
)(patches)
);
export const getInstallingLibraries = R.compose(
R.prop('installingLibraries'),
getProjectBrowser
);

View File

@@ -1,3 +1,4 @@
export default {
selectedPatchPath: null,
installingLibraries: [],
};

View File

@@ -47,6 +47,10 @@ const rawItems = {
label: 'New Patch',
command: COMMAND.ADD_PATCH,
},
addLibrary: {
key: 'addLibrary',
label: 'Add Library',
},
edit: {
key: 'edit',

View File

@@ -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`;

View File

@@ -11,7 +11,6 @@
"author": "XOD Team <dev@xod.io>",
"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",

View File

@@ -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';

View File

@@ -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';

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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"
},

View File

@@ -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
)

View File

@@ -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"

View File

@@ -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)
);

View File

@@ -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"