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