diff --git a/packages/xod-client-browser/src/containers/App.jsx b/packages/xod-client-browser/src/containers/App.jsx index 55670302..09b9e23a 100644 --- a/packages/xod-client-browser/src/containers/App.jsx +++ b/packages/xod-client-browser/src/containers/App.jsx @@ -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()} ); @@ -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), }, }); diff --git a/packages/xod-client-electron/src/view/containers/App.jsx b/packages/xod-client-electron/src/view/containers/App.jsx index a29c7c75..b6a21dee 100644 --- a/packages/xod-client-electron/src/view/containers/App.jsx +++ b/packages/xod-client-electron/src/view/containers/App.jsx @@ -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()} + ); + } + renderPopupCreateNewProject() { return ( 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); diff --git a/packages/xod-client/src/project/actionTypes.js b/packages/xod-client/src/project/actionTypes.js index 7abf86ea..b11d3d69 100644 --- a/packages/xod-client/src/project/actionTypes.js +++ b/packages/xod-client/src/project/actionTypes.js @@ -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'; diff --git a/packages/xod-client/src/project/actions.js b/packages/xod-client/src/project/actions.js index a3e93b7b..e944024e 100644 --- a/packages/xod-client/src/project/actions.js +++ b/packages/xod-client/src/project/actions.js @@ -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 // diff --git a/packages/xod-client/src/project/components/PopupPublishProject.jsx b/packages/xod-client/src/project/components/PopupPublishProject.jsx new file mode 100644 index 00000000..3f47c5af --- /dev/null +++ b/packages/xod-client/src/project/components/PopupPublishProject.jsx @@ -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 ( + +

+ Name: + {username}/{projectName} +

+

+ Version: + {version} +

+

+ License: + {license} +

+

+ Description: + {description} +

+ {isPublishing ? ( +
+ Publishing… +
+ ) : ( +
+ + + +
+ )} +
+ ); +}; + +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; diff --git a/packages/xod-client/src/project/messages.js b/packages/xod-client/src/project/messages.js new file mode 100644 index 00000000..916b9f11 --- /dev/null +++ b/packages/xod-client/src/project/messages.js @@ -0,0 +1,2 @@ +// eslint-disable-next-line import/prefer-default-export +export const SUCCESSFULLY_PUBLISHED = 'Successfully published'; diff --git a/packages/xod-client/src/user/actions.js b/packages/xod-client/src/user/actions.js index d9a0db94..89c34c76 100644 --- a/packages/xod-client/src/user/actions.js +++ b/packages/xod-client/src/user/actions.js @@ -41,6 +41,8 @@ export const fetchGrant = () => dispatch => .then((grant) => { dispatch(setGrant(grant)); dispatch(updateCompileLimit(false)); + + return grant; }); export const login = (username, password) => (dispatch) => { diff --git a/packages/xod-client/src/user/messages.js b/packages/xod-client/src/user/messages.js index 973f331e..1503dc5b 100644 --- a/packages/xod-client/src/user/messages.js +++ b/packages/xod-client/src/user/messages.js @@ -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'; diff --git a/packages/xod-client/src/user/selectors.js b/packages/xod-client/src/user/selectors.js index 40a8baff..9c0b1fbb 100644 --- a/packages/xod-client/src/user/selectors.js +++ b/packages/xod-client/src/user/selectors.js @@ -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')) ); diff --git a/packages/xod-client/src/utils/menu.js b/packages/xod-client/src/utils/menu.js index 08a042f4..150f2ff3 100644 --- a/packages/xod-client/src/utils/menu.js +++ b/packages/xod-client/src/utils/menu.js @@ -51,6 +51,10 @@ const rawItems = { key: 'addLibrary', label: 'Add Library', }, + publish: { + key: 'publish', + label: 'Publish Library', + }, edit: { key: 'edit', diff --git a/packages/xod-client/src/utils/urls.js b/packages/xod-client/src/utils/urls.js index a355fe8b..75f58551 100644 --- a/packages/xod-client/src/utils/urls.js +++ b/packages/xod-client/src/utils/urls.js @@ -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 = () => diff --git a/packages/xod-pm/src/fetch.js b/packages/xod-pm/src/fetch.js index 1ae5fc23..88f4ca51 100644 --- a/packages/xod-pm/src/fetch.js +++ b/packages/xod-pm/src/fetch.js @@ -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 diff --git a/packages/xod-pm/src/index.js b/packages/xod-pm/src/index.js index 68adc17d..027cc68e 100644 --- a/packages/xod-pm/src/index.js +++ b/packages/xod-pm/src/index.js @@ -1,4 +1,5 @@ export * from './fetch'; +export { default as publish } from './publish'; export * from './errorCodes'; export { parseLibQuery, diff --git a/packages/xod-pm/src/lib-uri.js b/packages/xod-pm/src/lib-uri.js new file mode 100644 index 00000000..62b2d0cc --- /dev/null +++ b/packages/xod-pm/src/lib-uri.js @@ -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}`; diff --git a/packages/xod-pm/src/publish.js b/packages/xod-pm/src/publish.js new file mode 100644 index 00000000..0e5e80bc --- /dev/null +++ b/packages/xod-pm/src/publish.js @@ -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); + })); + }); +} + diff --git a/packages/xod-pm/src/utils.js b/packages/xod-pm/src/utils.js index c8a96e80..7382f9fa 100644 --- a/packages/xod-pm/src/utils.js +++ b/packages/xod-pm/src/utils.js @@ -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)}`); +};