feat(xod-pm, xod-client): add ability to publish library from IDE

Closes #872
This commit is contained in:
Evgeny Kochetkov
2017-11-29 11:50:45 +03:00
parent f4c22d00ef
commit f8b9f2b9cd
21 changed files with 356 additions and 22 deletions

View File

@@ -181,6 +181,8 @@ class App extends client.App {
onClick(items.newPatch, this.props.actions.createPatch),
items.separator,
onClick(items.addLibrary, this.props.actions.showLibSuggester),
items.separator,
onClick(items.publish, this.props.actions.requestPublishProject),
]
),
submenu(
@@ -268,6 +270,7 @@ class App extends client.App {
/>
{this.renderPopupShowCode()}
{this.renderPopupProjectPreferences()}
{this.renderPopupPublishProject()}
{this.renderPopupCreateNewProject()}
</HotKeys>
);
@@ -285,14 +288,17 @@ App.propTypes = R.merge(client.App.propTypes, {
const mapStateToProps = R.applySpec({
hasUnsavedChanges: client.hasUnsavedChanges,
project: client.getProject,
user: client.getUser,
currentPatchPath: client.getCurrentPatchPath,
popups: {
createProject: client.getPopupVisibility(client.POPUP_ID.CREATING_PROJECT),
showCode: client.getPopupVisibility(client.POPUP_ID.SHOWING_CODE),
projectPreferences: client.getPopupVisibility(client.POPUP_ID.EDITING_PROJECT_PREFERENCES),
publishingProject: client.getPopupVisibility(client.POPUP_ID.PUBLISHING_PROJECT),
},
popupsData: {
showCode: client.getPopupData(client.POPUP_ID.SHOWING_CODE),
publishingProject: client.getPopupData(client.POPUP_ID.PUBLISHING_PROJECT),
},
});

View File

@@ -430,6 +430,7 @@ class App extends client.App {
onClick(items.newPatch, this.props.actions.createPatch),
items.separator,
onClick(items.addLibrary, this.props.actions.showLibSuggester),
onClick(items.publish, this.props.actions.requestPublishProject),
]
),
submenu(
@@ -686,6 +687,7 @@ class App extends client.App {
{this.renderPopupUploadConfig()}
{this.renderPopupUploadProcess()}
{this.renderPopupProjectPreferences()}
{this.renderPopupPublishProject()}
{this.renderPopupCreateNewProject()}
<PopupSetWorkspace
workspace={this.state.workspace}
@@ -773,12 +775,16 @@ const mapStateToProps = R.applySpec({
hasUnsavedChanges: client.hasUnsavedChanges,
lastSavedProject: client.getLastSavedProject,
project: client.getProject,
user: client.getUser,
upload: getUploadProcess,
saveProcess: getSaveProcess,
currentPatchPath: client.getCurrentPatchPath,
selectedPort: getSelectedSerialPort,
compileLimitLeft: client.getCompileLimitLeft,
popups: {
// TODO: make keys match with POPUP_IDs
// (for example, `creatingProject` insteand of `createProject`)
// this way we could make an util that takes an array of POPUP_IDs and generates a spec
createProject: client.getPopupVisibility(client.POPUP_ID.CREATING_PROJECT),
projectSelection: client.getPopupVisibility(client.POPUP_ID.OPENING_PROJECT),
switchWorkspace: client.getPopupVisibility(client.POPUP_ID.SWITCHING_WORKSPACE),
@@ -789,12 +795,14 @@ const mapStateToProps = R.applySpec({
projectPreferences: client.getPopupVisibility(
client.POPUP_ID.EDITING_PROJECT_PREFERENCES
),
publishingProject: client.getPopupVisibility(client.POPUP_ID.PUBLISHING_PROJECT),
},
popupsData: {
projectSelection: client.getPopupData(client.POPUP_ID.OPENING_PROJECT),
createWorkspace: client.getPopupData(client.POPUP_ID.CREATING_WORKSPACE),
switchWorkspace: client.getPopupData(client.POPUP_ID.SWITCHING_WORKSPACE),
showCode: client.getPopupData(client.POPUP_ID.SHOWING_CODE),
publishingProject: client.getPopupData(client.POPUP_ID.PUBLISHING_PROJECT),
},
});

View File

@@ -20,6 +20,7 @@ import sanctuaryPropType from '../../utils/sanctuaryPropType';
import PopupPrompt from '../../utils/components/PopupPrompt';
import PopupShowCode from '../../utils/components/PopupShowCode';
import PopupProjectPreferences from '../../project/components/PopupProjectPreferences';
import PopupPublishProject from '../../project/components/PopupPublishProject';
import * as actions from '../actions';
@@ -90,6 +91,20 @@ export default class App extends React.Component {
);
}
renderPopupPublishProject() {
return (
<PopupPublishProject
isVisible={this.props.popups.publishingProject}
project={this.props.project}
user={this.props.user}
isPublishing={this.props.popupsData.publishingProject.isPublishing}
onPublish={this.props.actions.publishProject}
onRequestToEditPreferences={this.props.actions.showProjectPreferences}
onClose={this.props.actions.cancelPublishingProject}
/>
);
}
renderPopupCreateNewProject() {
return (
<PopupPrompt
@@ -116,6 +131,7 @@ export default class App extends React.Component {
App.propTypes = {
project: sanctuaryPropType(Project),
user: PropTypes.object,
currentPatchPath: PropTypes.string,
popups: PropTypes.objectOf(PropTypes.bool),
popupsData: PropTypes.objectOf(PropTypes.object),
@@ -131,9 +147,12 @@ App.propTypes = {
/* eslint-disable react/no-unused-prop-types */
requestCreateProject: PropTypes.func.isRequired,
requestRenameProject: PropTypes.func.isRequired,
requestPublishProject: PropTypes.func.isRequired,
cancelPublishingProject: PropTypes.func.isRequired,
importProject: PropTypes.func.isRequired,
openProject: PropTypes.func.isRequired,
createPatch: PropTypes.func.isRequired,
publishProject: PropTypes.func.isRequired,
addComment: PropTypes.func.isRequired,
undoCurrentPatch: PropTypes.func.isRequired,
redoCurrentPatch: PropTypes.func.isRequired,
@@ -162,8 +181,11 @@ App.actions = {
createProject: actions.createProject,
requestCreateProject: actions.requestCreateProject,
requestRenameProject: actions.requestRenameProject,
requestPublishProject: actions.requestPublishProject,
cancelPublishingProject: actions.cancelPublishingProject,
importProject: actions.importProject,
openProject: actions.openProject,
publishProject: actions.publishProject,
createPatch: actions.requestCreatePatch,
addComment: actions.addComment,
undoCurrentPatch: actions.undoCurrentPatch,

View File

@@ -0,0 +1,14 @@
.PopupPublishProject {
.libName {
color: $color-canvas-selected;
}
.property {
margin: 7px 0;
.propertyLabel {
color: $color-canvas-face-light;
}
}
}

View File

@@ -37,6 +37,7 @@
'components/PatchWrapper',
'components/Pin',
'components/PinLabel',
'components/PopupPublishProject',
'components/ProjectBrowser',
'components/ProjectBrowserToolbar',
'components/Sidebar',

View File

@@ -17,4 +17,6 @@ export const POPUP_ID = { // eslint-disable-line import/prefer-default-export
SHOWING_CODE: 'showingCode',
ARDUINO_IDE_NOT_FOUND: 'arduinoIDENotFound',
PUBLISHING_PROJECT: 'publishingProject',
};

View File

@@ -20,6 +20,11 @@ import {
PATCH_RENAME,
PROJECT_CREATE_REQUESTED,
PROJECT_OPEN_REQUESTED,
PROJECT_PUBLISH_REQUESTED,
PROJECT_PUBLISH_CANCELLED,
PROJECT_PUBLISH_START,
PROJECT_PUBLISH_SUCCESS,
PROJECT_PUBLISH_FAIL,
PROJECT_UPDATE_META,
PROJECT_CREATE,
PROJECT_OPEN,
@@ -72,6 +77,14 @@ const showOnePopup = R.curry(
)
);
const overPopupData = R.curry(
(id, updaterFn, state) => R.over(
R.compose(R.lensProp(id), dataLens),
updaterFn,
state
)
);
// :: State -> State
export const showOnlyPopup = R.curry(
(id, data, state) => R.compose(
@@ -101,6 +114,28 @@ const popupsReducer = (state = initialState, action) => {
case PROJECT_RENAME_REQUESTED:
return showOnlyPopup(POPUP_ID.RENAMING_PROJECT, {}, state);
case PROJECT_PUBLISH_REQUESTED:
return showOnlyPopup(
POPUP_ID.PUBLISHING_PROJECT,
{ isPublishing: false },
state
);
case PROJECT_PUBLISH_CANCELLED:
case PROJECT_PUBLISH_SUCCESS:
return hideOnePopup(POPUP_ID.PUBLISHING_PROJECT, state);
case PROJECT_PUBLISH_START:
return overPopupData(
POPUP_ID.PUBLISHING_PROJECT,
R.assoc('isPublishing', true),
state
);
case PROJECT_PUBLISH_FAIL:
return overPopupData(
POPUP_ID.PUBLISHING_PROJECT,
R.assoc('isPublishing', false),
state
);
case SHOW_CODE_REQUESTED:
return showOnlyPopup(POPUP_ID.SHOWING_CODE, action.payload, state);

View File

@@ -1,5 +1,10 @@
export const PROJECT_CREATE_REQUESTED = 'PROJECT_CREATE_REQUESTED';
export const PROJECT_OPEN_REQUESTED = 'PROJECT_OPEN_REQUESTED';
export const PROJECT_PUBLISH_REQUESTED = 'PROJECT_PUBLISH_REQUESTED';
export const PROJECT_PUBLISH_CANCELLED = 'PROJECT_PUBLISH_CANCELLED';
export const PROJECT_PUBLISH_START = 'PROJECT_PUBLISH_START';
export const PROJECT_PUBLISH_SUCCESS = 'PROJECT_PUBLISH_SUCCESS';
export const PROJECT_PUBLISH_FAIL = 'PROJECT_PUBLISH_FAIL';
export const PROJECT_CREATE = 'PROJECT_CREATE';
export const PROJECT_RENAME = 'PROJECT_RENAME';

View File

@@ -1,13 +1,21 @@
import R from 'ramda';
import * as XP from 'xod-project';
import { publish } from 'xod-pm';
import { addError } from '../messages/actions';
import { foldMaybe } from 'xod-func-tools';
import { addConfirmation, addError } from '../messages/actions';
import { NODETYPE_ERROR_TYPES } from '../editor/constants';
import { PROJECT_BROWSER_ERRORS, NODETYPE_ERRORS } from '../messages/constants';
import * as ActionType from './actionTypes';
import { isPatchPathTaken } from './utils';
import { getCurrentPatchPath } from '../editor/selectors';
import { getGrant } from '../user/selectors';
import { fetchGrant } from '../user/actions';
import { LOG_IN_TO_CONTINUE } from '../user/messages';
import { SUCCESSFULLY_PUBLISHED } from './messages';
import { getProject } from './selectors';
import { getPmSwaggerUrl } from '../utils/urls';
//
// Project
@@ -65,6 +73,50 @@ export const openWorkspace = libs => ({
payload: libs,
});
export const requestPublishProject = () => (dispatch, getState) => {
const isAuthorised = foldMaybe(false, R.T, getGrant(getState()));
if (!isAuthorised) {
dispatch(addError(LOG_IN_TO_CONTINUE));
// TODO: open account pane?
return;
}
dispatch({ type: ActionType.PROJECT_PUBLISH_REQUESTED });
};
export const cancelPublishingProject = () => ({
type: ActionType.PROJECT_PUBLISH_CANCELLED,
});
export const publishProject = () => (dispatch, getState) => {
const state = getState();
const project = getProject(state);
dispatch({ type: ActionType.PROJECT_PUBLISH_START });
dispatch(fetchGrant()) // to obtain freshest auth token
.then((freshGrant) => {
if (freshGrant == null) {
// could happen if user logs out in another tab
dispatch(addError(LOG_IN_TO_CONTINUE));
dispatch({ type: ActionType.PROJECT_PUBLISH_FAIL });
return;
}
publish(getPmSwaggerUrl(), freshGrant, project).then(
() => {
dispatch(addConfirmation(SUCCESSFULLY_PUBLISHED));
dispatch({ type: ActionType.PROJECT_PUBLISH_SUCCESS });
},
(err) => {
dispatch({ type: ActionType.PROJECT_PUBLISH_FAIL });
dispatch(addError(err.message));
}
);
});
};
//
// Patch
//

View File

@@ -0,0 +1,80 @@
import React from 'react';
import Icon from 'react-fa';
import * as XP from 'xod-project';
import sanctuaryPropType from '../../utils/sanctuaryPropType';
import PopupForm from '../../utils/components/PopupForm';
import { HOSTNAME } from '../../utils/urls';
import Button from '../../core/components/Button';
const PopupPublishProject = ({
isVisible,
isPublishing,
project,
user,
onPublish,
onRequestToEditPreferences,
onClose,
}) => {
const projectName = XP.getProjectName(project);
const version = XP.getProjectVersion(project);
const description = XP.getProjectDescription(project);
const license = XP.getProjectLicense(project);
// When popup is hidden, `user` could be Nothing
const { username } = user.getOrElse({});
return (
<PopupForm
className="PopupPublishProject"
isVisible={isVisible}
isClosable={!isPublishing}
title={`You are about to publish on ${HOSTNAME}`}
onClose={onClose}
>
<p className="property">
<span className="propertyLabel">Name: </span>
<span className="libName">{username}/{projectName}</span>
</p>
<p className="property">
<span className="propertyLabel">Version: </span>
{version}
</p>
<p className="property">
<span className="propertyLabel">License: </span>
{license}
</p>
<p className="property">
<span className="propertyLabel">Description: </span>
{description}
</p>
{isPublishing ? (
<div className="ModalFooter">
<Icon name="circle-o-notch" spin size="lg" /> Publishing
</div>
) : (
<div className="ModalFooter">
<Button onClick={onPublish} autoFocus>Publish</Button>
<Button onClick={onRequestToEditPreferences}>Edit</Button>
<Button onClick={onClose}>Cancel</Button>
</div>
)}
</PopupForm>
);
};
PopupPublishProject.propTypes = {
isVisible: React.PropTypes.bool,
isPublishing: React.PropTypes.bool,
user: React.PropTypes.object,
project: sanctuaryPropType(XP.Project),
onPublish: React.PropTypes.func,
onRequestToEditPreferences: React.PropTypes.func,
onClose: React.PropTypes.func,
};
PopupPublishProject.defaultProps = {
isVisible: false,
};
export default PopupPublishProject;

View File

@@ -0,0 +1,2 @@
// eslint-disable-next-line import/prefer-default-export
export const SUCCESSFULLY_PUBLISHED = 'Successfully published';

View File

@@ -41,6 +41,8 @@ export const fetchGrant = () => dispatch =>
.then((grant) => {
dispatch(setGrant(grant));
dispatch(updateCompileLimit(false));
return grant;
});
export const login = (username, password) => (dispatch) => {

View File

@@ -1,2 +1,3 @@
export const INCORRECT_CREDENTIALS = 'Incorrect username or password';
export const SERVICE_UNAVAILABLE = 'Service unavailable';
export const LOG_IN_TO_CONTINUE = 'Please log in to continue';

View File

@@ -24,7 +24,12 @@ export const isAuthorising = createSelector(
R.prop('isAuthorising')
);
export const getUser = createSelector(
export const getGrant = createSelector(
getUserState,
R.pipe(R.path(['grant', 'user']), Maybe)
R.pipe(R.prop('grant'), Maybe)
);
export const getUser = createSelector(
getGrant,
R.map(R.prop('user'))
);

View File

@@ -51,6 +51,10 @@ const rawItems = {
key: 'addLibrary',
label: 'Add Library',
},
publish: {
key: 'publish',
label: 'Publish Library',
},
edit: {
key: 'edit',

View File

@@ -30,7 +30,7 @@ export const getUtmSiteUrl = getUtmUrl(process.env.XOD_SITE_DOMAIN);
// :: String -> String
export const getUtmForumUrl = getUtmUrl(process.env.XOD_FORUM_DOMAIN, '', 'forum');
const HOSTNAME = process.env.XOD_HOSTNAME || 'xod.io';
export const HOSTNAME = process.env.XOD_HOSTNAME || 'xod.io';
// :: () -> String
export const getCompileLimitUrl = () =>

View File

@@ -1,5 +1,4 @@
import R from 'ramda';
import swaggerClient from 'swagger-client';
import {
retryOrFail,
rejectFetchResult,
@@ -11,7 +10,7 @@ import { fromXodballData, listMissingLibraryNames } from 'xod-project';
import * as ERR_CODES from './errorCodes';
import * as MSG from './messages';
import { parseLibQuery, unfoldMaybeLibQuery, rejectUnexistingVersion, getLibName } from './utils';
import { parseLibQuery, unfoldMaybeLibQuery, rejectUnexistingVersion, getLibName, getSwaggerClient } from './utils';
// =============================================================================
//
@@ -25,22 +24,6 @@ const retryExcept404 = retryOrFail(
res => (res.status && res.status === 404),
);
// Create memoized function to prevent fetching swagger URL
// on each fetch request
const getSwaggerClient = (() => {
const memo = {};
return (url) => {
const index = JSON.stringify(url);
if (index in memo) {
return memo[index];
}
return (memo[index] = swaggerClient(url));
};
})();
// =============================================================================
//
// Fetching data

View File

@@ -1,4 +1,5 @@
export * from './fetch';
export { default as publish } from './publish';
export * from './errorCodes';
export {
parseLibQuery,

View File

@@ -0,0 +1,14 @@
// TODO: duplicates xod-cli/lib-uri.js
import { Maybe } from 'ramda-fantasy';
export const createLibUri = (orgname, libname, tag) => ({ orgname, libname, tag });
export const parseLibUri = string => Maybe
.toMaybe(string.match(/^([^@/]+?)\/([^@/]+?)(?:@([^@/]+?))?$/))
.map(([, orgname, libname, tag]) =>
createLibUri(orgname, libname, tag || 'latest'));
export const toStringWithoutTag = libUri => `${libUri.orgname}/${libUri.libname}`;
export const toString = libUri => `${toStringWithoutTag(libUri)}@${libUri.tag}`;

View File

@@ -0,0 +1,61 @@
import * as XP from 'xod-project';
import { getSwaggerClient, swaggerError } from './utils';
import { createLibUri, toString, toStringWithoutTag } from './lib-uri';
const packLibVersion = project => ({
libname: XP.getProjectName(project),
version: {
description: XP.getProjectDescription(project),
folder: { 'xodball.json': XP.toXodball(project) },
semver: `v${XP.getProjectVersion(project)}`,
},
});
export default function publish(swaggerUrl, grant, project) {
return getSwaggerClient(swaggerUrl)
.then((swagger) => {
const { Library, Organization, User, Version } = swagger.apis;
// eslint-disable-next-line no-param-reassign
swagger.authorizations = {
bearer_token: `Bearer ${grant.accessToken}`,
};
const { username } = grant.user;
const orgname = username;
const { libname, version } = packLibVersion(project);
const libUri = createLibUri(orgname, libname, version.semver);
return Organization.getOrg({ orgname })
.catch((err) => {
if (err.status !== 404) throw swaggerError(err);
return User.putUserOrg({ org: {}, orgname, username }).catch((err2) => {
if (err2.status === 403) {
throw new Error(`user "${username}" is not registered.`);
}
if (err2.status === 409) {
throw new Error(`orgname "${orgname}" is already taken.`);
}
throw swaggerError(err2);
});
})
.then(() => Library.getOrgLib({ libname, orgname }).catch((err) => {
if (err.status !== 404) throw swaggerError(err);
return Library.putOrgLib({ lib: {}, libname, orgname }).catch((err2) => {
if (err2.status === 403) {
throw new Error(`user "${username}" can't access ${
toStringWithoutTag(libUri)}.`);
}
throw swaggerError(err2);
});
}))
.then(() => Version.postLibVersion({ libname, orgname, version }).catch((err) => {
if (err.status === 409) {
throw new Error(`version "${toString(libUri)}" already exists.`);
}
throw swaggerError(err);
}));
});
}

View File

@@ -1,4 +1,5 @@
import R from 'ramda';
import swaggerClient from 'swagger-client';
import { Maybe } from 'ramda-fantasy';
import {
notEmpty,
@@ -120,3 +121,38 @@ export const getLibVersion = R.curry(
)(versions);
}
);
// Create memoized function to prevent fetching swagger URL
// on each fetch request
export const getSwaggerClient = (() => {
const memo = {};
return (url) => {
const index = JSON.stringify(url);
if (index in memo) {
return memo[index];
}
return (memo[index] = swaggerClient(url));
};
})();
// TODO: duplicate in xod-cli
export const swaggerError = (err) => {
const { response, status } = err;
let res;
if (response.body && response.body.originalResponse) {
res = JSON.parse(response.body.originalResponse);
} else if (response.body) {
res = response.body;
} else {
res = response;
}
if (status === 400) {
return new Error(R.pathOr('Bad request', ['errors', 0, 'errors', 0, 'message'], res));
}
return new Error(`${status} ${JSON.stringify(res, null, 2)}`);
};