diff --git a/src/botPage/bot/TradeEngine/index.js b/src/botPage/bot/TradeEngine/index.js index 02ee2e475d..b018c85539 100644 --- a/src/botPage/bot/TradeEngine/index.js +++ b/src/botPage/bot/TradeEngine/index.js @@ -16,6 +16,7 @@ import Ticks from './Ticks'; import rootReducer from './state/reducers'; import * as constants from './state/constants'; import { start } from './state/actions'; +import { observer as globalObserver } from '../../../common/utils/observer'; const watchBefore = store => watchScope({ @@ -34,7 +35,7 @@ const watchDuring = store => }); /* The watchScope function is called randomly and resets the prevTick - * which leads to the same problem we try to solve. So prevTick is isolated + * which leads to the same problem we try to solve. So prevTick is isolated */ let prevTick; const watchScope = ({ store, stopScope, passScope, passFlag }) => { @@ -90,6 +91,8 @@ export default class TradeEngine extends Balance(Purchase(Sell(OpenContract(Prop throw createError('NotInitialized', translate('Bot.init is not called')); } + globalObserver.emit('bot.running'); + this.tradeOptions = expectTradeOptions(tradeOptions); this.store.dispatch(start()); diff --git a/src/botPage/common/const.js b/src/botPage/common/const.js index dce8ceaeba..844e3fa331 100644 --- a/src/botPage/common/const.js +++ b/src/botPage/common/const.js @@ -229,6 +229,11 @@ const config = { }, bbResult : [[translate('upper'), '1'], [translate('middle'), '0'], [translate('lower'), '2']], macdFields: [[translate('Histogram'), '0'], [translate('MACD'), '1'], [translate('Signal'), '2']], + gd : { + cid: '646610722767-7ivdbunktgtnumj23en9gkecbgtf2ur7.apps.googleusercontent.com', + aid: 'binarybot-237009', + api: 'AIzaSyBieTeLip_lVQZUimIuJypU1kJyqOvQRgc', + }, }; export async function updateConfigCurrencies() { diff --git a/src/botPage/view/Dialogs/Dialog.js b/src/botPage/view/Dialogs/Dialog.js index 42d542ae07..5abebda1f5 100644 --- a/src/botPage/view/Dialogs/Dialog.js +++ b/src/botPage/view/Dialogs/Dialog.js @@ -1,6 +1,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; import DialogComponent from './DialogComponent'; +import { observer as globalObserver } from '../../../common/utils/observer'; export default class Dialog { constructor(id, title, content, options = {}) { @@ -13,8 +14,16 @@ export default class Dialog { } open() { $(`#${this.componentId}`).dialog('open'); + globalObserver.emit('dialog.opened', this.componentId); } close() { $(`#${this.componentId}`).dialog('close'); } + registerCloseOnOtherDialog() { + globalObserver.register('dialog.opened', dialogId => { + if (dialogId !== this.componentId) { + this.close(); + } + }); + } } diff --git a/src/botPage/view/Dialogs/IntegrationsDialog.js b/src/botPage/view/Dialogs/IntegrationsDialog.js new file mode 100644 index 0000000000..0272301351 --- /dev/null +++ b/src/botPage/view/Dialogs/IntegrationsDialog.js @@ -0,0 +1,29 @@ +import React from 'react'; +import Dialog from './Dialog'; +import GoogleDriveIntegration from '../react-components/Integrations/GoogleDriveIntegration'; +import * as style from '../style'; +import { translate } from '../../../common/i18n'; + +const IntegrationsContent = () => ( +
+ +
+); + +export default class IntegrationsDialog extends Dialog { + constructor() { + const closeDialog = () => { + this.close(); + }; + super( + 'integrations-dialog', + translate('Google Drive Integration'), + , + { + width : 500, + height: 'auto', + } + ); + this.registerCloseOnOtherDialog(); + } +} diff --git a/src/botPage/view/Dialogs/LoadDialog.js b/src/botPage/view/Dialogs/LoadDialog.js new file mode 100644 index 0000000000..145366798f --- /dev/null +++ b/src/botPage/view/Dialogs/LoadDialog.js @@ -0,0 +1,95 @@ +import PropTypes from 'prop-types'; +import React, { PureComponent } from 'react'; +import Dialog from './Dialog'; +import * as style from '../style'; +import { translate } from '../../../common/i18n'; +import googleDrive from '../../../common/integrations/GoogleDrive'; +import { showSpinnerInButton, removeSpinnerInButton } from '../../../common/utils/tools'; + +class LoadContent extends PureComponent { + constructor() { + super(); + this.state = { loadType: 'local' }; + } + + onChange(event) { + this.setState({ loadType: event.target.value }); + } + + submit() { + if (this.state.loadType === 'google-drive') { + const initialButtonText = $(this.submitButton).text(); + showSpinnerInButton($(this.submitButton)); + googleDrive + .createFilePicker() + .then(() => { + this.props.closeDialog(); + removeSpinnerInButton($(this.submitButton), initialButtonText); + }) + .catch(() => { + removeSpinnerInButton($(this.submitButton), initialButtonText); + }); + } else { + $('#files').click(); + this.props.closeDialog(); + } + } + + render() { + return ( +
this.submit()} + > +
+ + this.onChange(e)} + /> + + + + this.onChange(e)} + /> + + +
+
+ +
+
+ ); + } + static props: { closeDialog: PropTypes.func }; +} + +export default class LoadDialog extends Dialog { + constructor() { + const closeDialog = () => { + this.close(); + }; + super('load-dialog', translate('Load blocks'), , style.dialogLayout); + this.registerCloseOnOtherDialog(); + } +} diff --git a/src/botPage/view/Dialogs/Save.js b/src/botPage/view/Dialogs/Save.js deleted file mode 100644 index 43343851fa..0000000000 --- a/src/botPage/view/Dialogs/Save.js +++ /dev/null @@ -1,95 +0,0 @@ -import React, { PureComponent } from 'react'; -import PropTypes from 'prop-types'; -import { translate } from '../../../common/i18n'; -import * as style from '../style'; -import Dialog from './Dialog'; - -class SaveContent extends PureComponent { - constructor() { - super(); - this.state = { - error: null, - }; - } - submit() { - const filename = $(this.filename).val(); - const collection = $(this.isCollection).prop('checked'); - - this.props.onSave({ - filename, - collection, - }); - } - render() { - return ( -
this.submit()} - className="dialog-content" - style={style.content} - > -
-
- -
-
- { - this.isCollection = el; - }} - style={style.checkbox} - /> - -
-
-
- -
-
- ); - } - static props: { - onSave: PropTypes.func, - }; -} - -export default class Save extends Dialog { - constructor() { - const onSave = arg => { - this.limitsPromise(arg); - this.close(); - }; - super('save-dialog', translate('Save blocks as'), , style.dialogLayout); - } - save() { - this.open(); - return new Promise(resolve => { - this.limitsPromise = resolve; - }); - } -} diff --git a/src/botPage/view/Dialogs/SaveDialog.js b/src/botPage/view/Dialogs/SaveDialog.js new file mode 100644 index 0000000000..d12da51c99 --- /dev/null +++ b/src/botPage/view/Dialogs/SaveDialog.js @@ -0,0 +1,175 @@ +import PropTypes from 'prop-types'; +import React, { PureComponent } from 'react'; +import Dialog from './Dialog'; +import { cleanBeforeExport } from '../blockly/utils'; +import * as style from '../style'; +import { translate } from '../../../common/i18n'; +import googleDrive from '../../../common/integrations/GoogleDrive'; +import { observer as globalObserver } from '../../../common/utils/observer'; +import { showSpinnerInButton, removeSpinnerInButton } from '../../../common/utils/tools'; + +class SaveContent extends PureComponent { + constructor() { + super(); + this.state = { + error : null, + saveType: 'local', + }; + } + + submit() { + const filename = $(this.filename).val() || 'binary-bot'; + const collection = $(this.isCollection).prop('checked'); + + if (this.state.saveType === 'google-drive') { + const initialButtonText = $(this.submitButton).text(); + showSpinnerInButton($(this.submitButton)); + + const xml = Blockly.Xml.workspaceToDom(Blockly.mainWorkspace); + cleanBeforeExport(xml); + + xml.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml'); + xml.setAttribute('collection', collection); + + googleDrive + .saveFile({ + name : filename, + content : Blockly.Xml.domToPrettyText(xml), + mimeType: 'application/xml', + }) + .then(() => { + globalObserver.emit('ui.log.success', translate('Successfully uploaded to Google Drive')); + this.props.closeDialog(); + removeSpinnerInButton($(this.submitButton), initialButtonText); + }) + .catch(() => { + removeSpinnerInButton($(this.submitButton), initialButtonText); + }); + } else { + this.props.onSave({ + filename, + collection, + }); + } + } + + onChange(event) { + this.setState({ saveType: event.target.value }); + } + + render() { + return ( +
this.submit()} + className="dialog-content" + style={style.content} + > +
+ { + this.filename = el; + }} + defaultValue="binary-bot" + data-lpignore="true" + autoComplete="false" + /> +
+
+ + this.onChange(e)} + /> + + + + this.onChange(e)} + /> + + +
+
+ { + this.isCollection = el; + }} + style={style.checkbox} + /> + +
+ {translate('Save your blocks and settings for re-use in other strategies')} +
+
+
+ +
+
+ ); + } + + static props: { + onSave: PropTypes.func, + closeDialog: PropTypes.func, + }; +} + +export default class SaveDialog extends Dialog { + constructor() { + const closeDialog = () => { + this.close(); + }; + const onSave = arg => { + this.limitsPromise(arg); + closeDialog(); + }; + super( + 'save-dialog', + translate('Save blocks'), + , + style.dialogLayout + ); + this.registerCloseOnOtherDialog(); + } + + save() { + this.open(); + return new Promise(resolve => { + this.limitsPromise = resolve; + }); + } +} diff --git a/src/botPage/view/TradeInfoPanel/index.js b/src/botPage/view/TradeInfoPanel/index.js index 664abf034b..20c70827a9 100644 --- a/src/botPage/view/TradeInfoPanel/index.js +++ b/src/botPage/view/TradeInfoPanel/index.js @@ -30,6 +30,7 @@ class AnimateTrade extends Component { super(); this.indicatorMessages = { notRunning: translate('Bot is not running.'), + starting : translate('Bot is starting...'), running : translate('Bot is running...'), stopping : translate('Bot is stopping...'), stopped : translate('Bot has stopped.'), @@ -40,18 +41,27 @@ class AnimateTrade extends Component { }; } componentWillMount() { + globalObserver.register('bot.running', () => { + $('.stage-tooltip.top:eq(0)').addClass('running'); + this.setState({ indicatorMessage: this.indicatorMessages.running }); + }); globalObserver.register('bot.stop', () => { $('.stage-tooltip.top:eq(0)').removeClass('running'); this.setState({ indicatorMessage: this.indicatorMessages.stopped }); }); + $('#stopButton').click(() => { $('.stage-tooltip.top:eq(0)').removeClass('running'); this.setState({ indicatorMessage: this.state.stopMessage }); }); + $('#runButton').click(() => { resetAnimation(); $('.stage-tooltip.top:eq(0)').addClass('running'); - this.setState({ indicatorMessage: this.indicatorMessages.running }); + this.setState({ + indicatorMessage: this.indicatorMessages.starting, + stopMessage : this.indicatorMessages.stopped, + }); globalObserver.register('contract.status', contractStatus => { this.animateStage(contractStatus); }); diff --git a/src/botPage/view/View.js b/src/botPage/view/View.js index be027629c8..ef8ce478c3 100644 --- a/src/botPage/view/View.js +++ b/src/botPage/view/View.js @@ -4,7 +4,9 @@ import 'jquery-ui/ui/widgets/dialog'; import _Blockly from './blockly'; import Chart from './Dialogs/Chart'; import Limits from './Dialogs/Limits'; -import Save from './Dialogs/Save'; +import IntegrationsDialog from './Dialogs/IntegrationsDialog'; +import LoadDialog from './Dialogs/LoadDialog'; +import SaveDialog from './Dialogs/SaveDialog'; import TradingView from './Dialogs/TradingView'; import logHandler from './logger'; import LogTable from './LogTable'; @@ -26,6 +28,7 @@ import { addTokenIfValid, } from '../../common/appId'; import { translate } from '../../common/i18n'; +import googleDrive from '../../common/integrations/GoogleDrive'; import { getLanguage } from '../../common/lang'; import { observer as globalObserver } from '../../common/utils/observer'; import { @@ -139,7 +142,9 @@ const clearRealityCheck = () => { }; const limits = new Limits(); -const saveDialog = new Save(); +const integrationsDialog = new IntegrationsDialog(); +const loadDialog = new LoadDialog(); +const saveDialog = new SaveDialog(); const getLandingCompanyForToken = id => { let landingCompany; @@ -346,6 +351,7 @@ export default class View { .then(() => { this.stop(); Elevio.logoutUser(); + googleDrive.signOut(); removeTokens(); }) .catch(() => {}); @@ -382,6 +388,10 @@ export default class View { classes : { 'ui-dialog-titlebar-close': 'icon-close' }, }); + $('#integrations').click(() => integrationsDialog.open()); + + $('#load-xml').click(() => loadDialog.open()); + $('#save-xml').click(() => saveDialog.save().then(arg => this.blockly.save(arg))); $('#undo').click(() => { @@ -449,10 +459,6 @@ export default class View { $('#toggleHeaderButton').click(() => this.showHeader($('#header').is(':hidden'))); - $('#loadXml').click(() => { - $('#files').click(); - }); - $('#logout, #toolbox-logout').click(() => { setBeforeUnload(true); logout(); diff --git a/src/botPage/view/blockly/index.js b/src/botPage/view/blockly/index.js index a4fc3583d5..f464a78d63 100644 --- a/src/botPage/view/blockly/index.js +++ b/src/botPage/view/blockly/index.js @@ -13,6 +13,7 @@ import { fixArgumentAttribute, removeUnavailableMarkets, strategyHasValidTradeTypeCategory, + cleanBeforeExport, } from './utils'; import Interpreter from '../../bot/Interpreter'; import createError from '../../common/error'; @@ -75,7 +76,7 @@ const marketsWereRemoved = xml => { } return false; }; -const loadWorkspace = xml => { +export const loadWorkspace = xml => { if (!strategyHasValidTradeTypeCategory(xml)) return; if (marketsWereRemoved(xml)) return; @@ -101,7 +102,7 @@ const loadWorkspace = xml => { ); }; -const loadBlocks = (xml, dropEvent = {}) => { +export const loadBlocks = (xml, dropEvent = {}) => { if (!strategyHasValidTradeTypeCategory(xml)) return; if (marketsWereRemoved(xml)) return; @@ -293,7 +294,7 @@ export default class _Blockly { try { xml = Blockly.Xml.textToDom(blockStr); } catch (e) { - throw createError('FileLoad', translate('Unrecognized file format.')); + throw createError('FileLoad', translate('Unrecognized file format')); } try { @@ -303,7 +304,7 @@ export default class _Blockly { loadWorkspace(xml); } } catch (e) { - throw createError('FileLoad', translate('Unable to load the block file.')); + throw createError('FileLoad', translate('Unable to load the block file')); } } /* eslint-disable class-methods-use-this */ @@ -311,15 +312,10 @@ export default class _Blockly { const { filename, collection } = arg; setBeforeUnload(true); + const xml = Blockly.Xml.workspaceToDom(Blockly.mainWorkspace); - Array.from(xml.children).forEach(blockDom => { - const blockId = blockDom.getAttribute('id'); - if (!blockId) return; - const block = Blockly.mainWorkspace.getBlockById(blockId); - if ('loaderId' in block) { - blockDom.remove(); - } - }); + cleanBeforeExport(xml); + save(filename, collection, xml); } run(limitations = {}) { diff --git a/src/botPage/view/blockly/utils.js b/src/botPage/view/blockly/utils.js index 237b7af0c9..cacb8aa752 100644 --- a/src/botPage/view/blockly/utils.js +++ b/src/botPage/view/blockly/utils.js @@ -495,3 +495,14 @@ export const hideInteractionsFromBlockly = callback => { callback(); Blockly.Events.recordUndo = true; }; + +export const cleanBeforeExport = xml => { + Array.from(xml.children).forEach(blockDom => { + const blockId = blockDom.getAttribute('id'); + if (!blockId) return; + const block = Blockly.mainWorkspace.getBlockById(blockId); + if ('loaderId' in block) { + blockDom.remove(); + } + }); +}; diff --git a/src/botPage/view/index.js b/src/botPage/view/index.js index 2dbf151553..f0b04e60e8 100644 --- a/src/botPage/view/index.js +++ b/src/botPage/view/index.js @@ -2,9 +2,9 @@ import 'babel-polyfill'; import 'jquery-ui/ui/widgets/dialog'; import 'notifyjs-browser'; +import View from './View'; import '../../common/binary-ui/dropdown'; import Elevio from '../../common/elevio'; -import View from './View'; $.ajaxSetup({ cache: false, diff --git a/src/botPage/view/react-components/Integrations/GoogleDriveIntegration.js b/src/botPage/view/react-components/Integrations/GoogleDriveIntegration.js new file mode 100644 index 0000000000..8d4f4c53e0 --- /dev/null +++ b/src/botPage/view/react-components/Integrations/GoogleDriveIntegration.js @@ -0,0 +1,46 @@ +import React, { PureComponent } from 'react'; +import { translate } from '../../../../common/i18n'; +import googleDrive from '../../../../common/integrations/GoogleDrive'; +import { observer as globalObserver } from '../../../../common/utils/observer'; + +export default class GoogleDriveIntegration extends PureComponent { + constructor() { + super(); + this.state = { isAuthorised: false }; + } + + componentDidMount() { + globalObserver.register('googledrive.authorise', data => this.setState(data)); + } + + // eslint-disable-next-line class-methods-use-this + render() { + return ( +
+
+

Google Drive

+
{translate('Save your blocks and strategies to Google Drive')}
+ {googleDrive.isAuthorised && ( +
+ {`${translate('You are logged in as')} ${googleDrive.profile.getEmail()}`} +
+ )} +
+ +
+ ); + } +} diff --git a/src/common/integrations/GoogleDrive.js b/src/common/integrations/GoogleDrive.js new file mode 100644 index 0000000000..1cdb4e61ee --- /dev/null +++ b/src/common/integrations/GoogleDrive.js @@ -0,0 +1,332 @@ +/* global google,gapi */ +import { getLanguage } from '../lang'; +import { observer as globalObserver } from '../utils/observer'; +import { translate, trackAndEmitError } from '../utils/tools'; +import { loadWorkspace, loadBlocks } from '../../botPage/view/blockly'; +import config from '../../botPage/common/const'; + +class GoogleDrive { + constructor() { + this.botFolderName = `Binary Bot - ${translate('Strategies')}`; + this.setInfo(config); + this.googleAuth = null; + this.isAuthorised = null; + this.profile = null; + + $.getScript('https://apis.google.com/js/api.js', () => this.init()); + } + + init() { + gapi.load('client:auth2:picker', { + callback: () => { + gapi.client + .init({ + apiKey : this.apiKey, + clientId : this.clientId, + scope : 'https://www.googleapis.com/auth/drive.file', + discoveryDocs: ['https://www.googleapis.com/discovery/v1/apis/drive/v3/rest'], + }) + .then( + () => { + this.googleAuth = gapi.auth2.getAuthInstance(); + this.googleAuth.isSignedIn.listen(isSignedIn => this.updateSigninStatus(isSignedIn)); + this.updateSigninStatus(this.googleAuth.isSignedIn.get()); + + $('#integrations').removeClass('invisible'); + $('#save-google-drive') + .parent() + .removeClass('invisible'); + $('#load-google-drive') + .parent() + .removeClass('invisible'); + }, + error => { + if (window.trackJs) { + trackJs.track( + `${translate( + 'There was an error initialising Google Drive' + )} - Error: ${JSON.stringify(error)}` + ); + } + } + ); + }, + onerror: error => { + if (window.trackJs) { + trackJs.track( + `${translate('There was an error loading Google Drive libraries')} - Error: ${JSON.stringify( + error + )}` + ); + } + }, + }); + } + + updateSigninStatus(isSignedIn) { + if (isSignedIn) { + this.profile = this.googleAuth.currentUser.get().getBasicProfile(); + } else { + this.profile = null; + } + this.isAuthorised = isSignedIn; + globalObserver.emit('googledrive.authorise', { isAuthorised: isSignedIn }); + } + + authorise() { + return new Promise((resolve, reject) => { + if (this.isAuthorised) { + resolve(); + } else { + this.googleAuth + .signIn({ prompt: 'select_account' }) + .then(() => resolve()) + .catch(response => { + if (response.error === 'access_denied') { + globalObserver.emit( + 'ui.log.warn', + translate( + 'Please grant permission to view and manage Google Drive folders created with Binary Bot' + ) + ); + } + reject(response); + }); + } + }); + } + + signOut() { + if (this.isAuthorised) { + return this.googleAuth.signOut(); + } + return Promise.resolve(); + } + + setInfo(data) { + this.clientId = data.gd.cid; + this.appId = data.gd.aid; + this.apiKey = data.gd.api; + } + + // eslint-disable-next-line class-methods-use-this + getPickerLanguage() { + const language = getLanguage(); + + if (language === 'zhTw') { + return 'zh-TW'; + } else if (language === 'zhCn') { + return 'zh-CN'; + } + return language; + } + + createFilePicker() { + return new Promise((resolve, reject) => { + // eslint-disable-next-line consistent-return + const userPickedFile = data => { + if (data.action === google.picker.Action.PICKED) { + const fileId = data.docs[0].id; + gapi.client.drive.files + .get({ + alt : 'media', + fileId, + mimeType: 'text/plain', + }) + .then(response => { + try { + const xmlDom = Blockly.Xml.textToDom(response.body); + const loadFunction = + xmlDom.hasAttribute('collection') && xmlDom.getAttribute('collection') === 'true' + ? loadBlocks + : loadWorkspace; + try { + loadFunction(xmlDom); + resolve(); + } catch (error) { + trackAndEmitError(translate('Could not load Google Drive blocks'), error); + reject(error); + } + } catch (error) { + trackAndEmitError(translate('Unrecognized file format'), error); + reject(error); + } + }) + .catch(error => { + if (error.status && error.status === 401) { + this.signOut(); + } + trackAndEmitError(translate('There was an error retrieving data from Google Drive'), error); + reject(error); + }); + } else if (data.action === google.picker.Action.CANCEL) { + reject(); + } + }; + + this.authorise() + .then(() => { + // FilePicker open doesn't give an unauthorised error, so check if we can list files + // first before attempting to open it (user revoked permissions through accounts.google.com) + gapi.client.drive.files + .list() + .then(() => { + const mimeTypes = ['application/xml']; + const docsView = new google.picker.DocsView(); + docsView.setMimeTypes(mimeTypes.join(',')); + docsView.setIncludeFolders(true); + docsView.setOwnedByMe(true); + + const picker = new google.picker.PickerBuilder(); + picker + .setOrigin(`${window.location.protocol}//${window.location.host}`) + .setTitle(translate('Select a Binary Bot strategy')) + .setLocale(this.getPickerLanguage()) + .setAppId(this.appId) + .setOAuthToken(gapi.auth.getToken().access_token) + .addView(docsView) + .setDeveloperKey(this.apiKey) + .setCallback(userPickedFile) + .build() + .setVisible(true); + }) + .catch(error => { + if (error.status && error.status === 401) { + this.signOut(); + } + trackAndEmitError(translate('There was an error listing files from Google Drive'), error); + reject(error); + }); + }) + .catch(error => reject(error)); + }); + } + + getDefaultFolderId() { + return new Promise((resolve, reject) => { + // Avoid duplicate auth flow by checking if user is already authed + const authorisePromise = []; + if (!this.isAuthorised) { + authorisePromise.push(this.authorise); + } + Promise.all(authorisePromise) + .then(() => { + gapi.client.drive.files + .list({ q: 'trashed=false' }) + // eslint-disable-next-line consistent-return + .then(response => { + const botFolder = response.result.files.find( + file => + file.name === this.botFolderName && + file.mimeType === 'application/vnd.google-apps.folder' + ); + if (botFolder) { + return resolve(botFolder.id); + } + gapi.client.drive.files + .create({ + resource: { + name : this.botFolderName, + mimeType: 'application/vnd.google-apps.folder', + fields : 'id', + }, + }) + .then(createFileResponse => resolve(createFileResponse.result.id)) + .catch(error => { + if (error.status && error.status === 401) { + this.signOut(); + } + trackAndEmitError( + translate('There was an error retrieving files from Google Drive'), + error + ); + reject(error); + }); + }) + .catch(error => { + if (error.status && error.status === 401) { + this.signOut(); + } + trackAndEmitError(translate('There was an error listing files from Google Drive'), error); + reject(error); + }); + }) + .catch(() => { + /* Auth error, already handled in authorise()-promise */ + }); + }); + } + + saveFile(options) { + return new Promise((resolve, reject) => { + // eslint-disable-next-line consistent-return + const savePickerCallback = data => { + if (data.action === google.picker.Action.PICKED) { + const folderId = data.docs[0].id; + const strategyFile = new Blob([options.content], { type: options.mimeType }); + const strategyFileMetadata = JSON.stringify({ + name : options.name, + mimeType: options.mimeType, + parents : [folderId], + }); + + const formData = new FormData(); + formData.append('metadata', new Blob([strategyFileMetadata], { type: 'application/json' })); + formData.append('file', strategyFile); + + const xhr = new XMLHttpRequest(); + xhr.responseType = 'json'; + xhr.open('POST', 'https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart'); + xhr.setRequestHeader('Authorization', `Bearer ${gapi.auth.getToken().access_token}`); + xhr.onload = () => { + if (xhr.status === 200) { + resolve(); + } else { + if (xhr.status === 401) { + this.signOut(); + } + trackAndEmitError(translate('There was an error processing your request'), xhr.status); + reject(); + } + }; + xhr.send(formData); + } else if (data.action === google.picker.Action.CANCEL) { + reject(); + } + }; + + this.authorise() + .then(() => { + // Calling getDefaultFolderId() ensures there's at least one folder available to save to. + // FilePicker doesn't allow for folder creation, so a user without any folder in + // their drive couldn't select anything. + this.getDefaultFolderId() + .then(() => { + const view = new google.picker.DocsView(); + view.setIncludeFolders(true) + .setSelectFolderEnabled(true) + .setMimeTypes('application/vnd.google-apps.folder'); + + const picker = new google.picker.PickerBuilder(); + picker + .setOrigin(`${window.location.protocol}//${window.location.host}`) + .setTitle(translate('Select a folder')) + .addView(view) + .setLocale(this.getPickerLanguage()) + .setAppId(this.appId) + .setOAuthToken(gapi.auth.getToken().access_token) + .setDeveloperKey(this.apiKey) + .setCallback(savePickerCallback) + .build() + .setVisible(true); + }) + .catch(error => reject(error)); + }) + .catch(error => reject(error)); + }); + } +} + +const googleDrive = new GoogleDrive(); + +export default googleDrive; diff --git a/src/common/utils/tools.js b/src/common/utils/tools.js index 6d61978926..4fddee4609 100644 --- a/src/common/utils/tools.js +++ b/src/common/utils/tools.js @@ -1,4 +1,5 @@ import RenderHTML from 'react-render-html'; +import { observer as globalObserver } from './observer'; import { translate as i18nTranslate } from '../../common/i18n'; import { getLanguage } from '../../common/lang'; import AppIdMap from '../../common/appIdResolver'; @@ -94,3 +95,27 @@ export const getExtension = () => { const extension = host.split('.').slice(-1)[0]; return host !== extension ? extension : ''; }; + +export const showSpinnerInButton = $buttonElement => { + $buttonElement + .html(() => { + const barspinner = $('
'); + Array.from(new Array(5)).forEach((x, i) => { + const rect = $(`
`); + barspinner.append(rect); + }); + return barspinner; + }) + .prop('disabled', true); +}; + +export const removeSpinnerInButton = ($buttonElement, initialText) => { + $buttonElement.html(() => initialText).prop('disabled', false); +}; + +export const trackAndEmitError = (message, object = {}) => { + globalObserver.emit('ui.log.error', message); + if (window.trackJs) { + trackJs.track(`${message} - Error: ${JSON.stringify(object)}`); + } +}; diff --git a/src/indexPage/react-components/footer.jsx b/src/indexPage/react-components/footer.jsx index 3fb49fa3ac..32466aeb49 100644 --- a/src/indexPage/react-components/footer.jsx +++ b/src/indexPage/react-components/footer.jsx @@ -32,7 +32,6 @@ const Footer = () => ( ( a { + display: block; + } + } + .integration-user { + color: #b9b9b9; + font-size: 75%; + } + } +} diff --git a/static/css/_toolbox.scss b/static/css/_toolbox.scss index ec1dd85849..79daa64c74 100644 --- a/static/css/_toolbox.scss +++ b/static/css/_toolbox.scss @@ -110,3 +110,13 @@ } } } + +button { + & > .barspinner.white { + position: relative; + margin: 3px auto; + height: 13px; + top: initial; + left: initial; + } +} diff --git a/static/css/index.scss b/static/css/index.scss index 33789bdebc..75082d288e 100644 --- a/static/css/index.scss +++ b/static/css/index.scss @@ -82,6 +82,15 @@ ul.bullet { .show-on-load { display: none; } +#split-container { + display: flex; + flex-direction: row; + width: 100%; + .puzzle-logo { + padding-left: 20px; + } +} + /* Keep this below since css after this * will be interfering with small * screen sizes @@ -98,6 +107,10 @@ ul.bullet { } } @media only screen and (max-width: 480px) { + #split-container { + display: block; + width: auto; + } .top-image { display: block; } diff --git a/static/image/footer/google-plus.svg b/static/image/footer/google-plus.svg deleted file mode 100644 index 2ee6a6596b..0000000000 --- a/static/image/footer/google-plus.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/static/image/google_drive.svg b/static/image/google_drive.svg new file mode 100644 index 0000000000..67d50ce7c6 --- /dev/null +++ b/static/image/google_drive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/templates/bot.mustache b/templates/bot.mustache index a05359fd65..3bb003d7b4 100644 --- a/templates/bot.mustache +++ b/templates/bot.mustache @@ -15,7 +15,9 @@
+ + @@ -110,8 +112,9 @@
- + + diff --git a/templates/index.mustache b/templates/index.mustache index 8c6ab4aa52..e49cf2cb92 100644 --- a/templates/index.mustache +++ b/templates/index.mustache @@ -34,13 +34,13 @@
-
+

.

-
+