mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(core) Export to Excel and Send to drive
Summary: Implementing export to excel and send to Google Drive feature. As part of this feature few things were implemented: - Server side google authentication exposed on url: (docs, docs-s, or localhost:8080)/auth/google - Exporting grist documents as an excel file (xlsx) - Storing exported grist document (in excel format) in Google Drive as a spreadsheet document. Server side google authentication requires one new environmental variables - GOOGLE_CLIENT_SECRET (required) used by authentication handler Test Plan: Browser tests for exporting to excel. Reviewers: paulfitz, dsagal Reviewed By: paulfitz Differential Revision: https://phab.getgrist.com/D2924
This commit is contained in:
@@ -3,6 +3,7 @@ import {AppModel, reportError} from 'app/client/models/AppModel';
|
||||
import {DocInfo, DocPageModel} from 'app/client/models/DocPageModel';
|
||||
import {docUrl, urlState} from 'app/client/models/gristUrlState';
|
||||
import {makeCopy, replaceTrunkWithFork} from 'app/client/ui/MakeCopyMenu';
|
||||
import {sendToDrive} from 'app/client/ui/sendToDrive';
|
||||
import {cssHoverCircle, cssTopBarBtn} from 'app/client/ui/TopBarCss';
|
||||
import {primaryButton} from 'app/client/ui2018/buttons';
|
||||
import {colors, mediaXSmall, testId} from 'app/client/ui2018/cssVars';
|
||||
@@ -222,6 +223,10 @@ function menuExports(doc: Document, pageModel: DocPageModel) {
|
||||
),
|
||||
menuItemLink({ href: gristDoc.getCsvLink(), target: '_blank', download: ''},
|
||||
menuIcon('Download'), 'Export CSV', testId('tb-share-option')),
|
||||
menuItemLink({ href: gristDoc.getXlsxLink(), target: '_blank', download: ''},
|
||||
menuIcon('Download'), 'Export XLSX', testId('tb-share-option')),
|
||||
menuItem(() => sendToDrive(doc, pageModel),
|
||||
menuIcon('Download'), 'Send to Google Drive', testId('tb-share-option')),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
106
app/client/ui/sendToDrive.ts
Normal file
106
app/client/ui/sendToDrive.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals';
|
||||
import {getHomeUrl} from 'app/client/models/AppModel';
|
||||
import {reportError} from 'app/client/models/errors';
|
||||
import {spinnerModal} from 'app/client/ui2018/modals';
|
||||
import type { DocPageModel } from 'app/client/models/DocPageModel';
|
||||
import type { Document } from 'app/common/UserAPI';
|
||||
import type { Disposable } from 'grainjs';
|
||||
|
||||
const G = getBrowserGlobals('window');
|
||||
|
||||
/**
|
||||
* Generates Google Auth endpoint (exposed by Grist) url. For example:
|
||||
* https://docs.getgrist.com/auth/google
|
||||
* @param scope Requested access scope for Google Services
|
||||
* https://developers.google.com/identity/protocols/oauth2/scopes
|
||||
*/
|
||||
function getGoogleAuthEndpoint(scope?: string) {
|
||||
return new URL(`auth/google?scope=${scope || ''}`, getHomeUrl()).href;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends xlsx file to Google Drive. It first authenticates with Google to get encrypted access token,
|
||||
* then it calls "send-to-drive" api endpoint to upload xlsx file to drive and finally it redirects
|
||||
* to the created spreadsheet.
|
||||
* Code that is received from Google contains encrypted access token, server is able to decrypt it
|
||||
* using GOOGLE_CLIENT_SECRET key.
|
||||
*/
|
||||
export function sendToDrive(doc: Document, pageModel: DocPageModel) {
|
||||
// Get current document - it will be used to remove popup listener.
|
||||
const gristDoc = pageModel.gristDoc.get();
|
||||
// Sanity check - gristDoc should be always present
|
||||
if (!gristDoc) { throw new Error("Grist document is not present in Page Model"); }
|
||||
|
||||
// Create send to google drive handler (it will return a spreadsheet url).
|
||||
const send = (code: string) =>
|
||||
// Decorate it with a spinner
|
||||
spinnerModal('Sending file to Google Drive',
|
||||
pageModel.appModel.api.getDocAPI(doc.id)
|
||||
.sendToDrive(code, pageModel.currentDocTitle.get())
|
||||
);
|
||||
|
||||
// Compute google auth server endpoint (grist endpoint for server side google authentication).
|
||||
// This endpoint will redirect user to Google Consent screen and after Google sends a response,
|
||||
// it will render a page (/static/message.html) that will post a message containing message
|
||||
// from Google. Message will be an object { code, error }. We will use the code to invoke
|
||||
// "send-to-drive" api endpoint - that will actually send the xlsx file to Google Drive.
|
||||
const authLink = getGoogleAuthEndpoint();
|
||||
const authWindow = openPopup(authLink);
|
||||
attachListener(gristDoc, authWindow, async (event: MessageEvent) => {
|
||||
// For the first message (we expect only a single message) close the window.
|
||||
authWindow.close();
|
||||
|
||||
// Check response from the popup
|
||||
const response = (event.data || {}) as { code?: string, error?: string };
|
||||
// - when user declined, do nothing,
|
||||
if (response.error === "access_denied") { return; }
|
||||
// - when there is no response code or error code is different from what we expected - report to user.
|
||||
if (!response.code) { reportError(response.error || "Unrecognized or empty error code"); return; }
|
||||
|
||||
// Send file to Google Drive.
|
||||
try {
|
||||
const { url } = await send(response.code);
|
||||
G.window.location.assign(url);
|
||||
} catch (err) {
|
||||
reportError(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Helper function that attaches a handler to message event from a popup window.
|
||||
function attachListener(owner: Disposable, popup: Window, listener: (e: MessageEvent) => any) {
|
||||
const wrapped = (e: MessageEvent) => {
|
||||
// Listen to events only from our window.
|
||||
if (e.source !== popup) { return; }
|
||||
// In case when Grist was reloaded or user navigated away - do nothing.
|
||||
if (owner.isDisposed()) { return; }
|
||||
listener(e);
|
||||
};
|
||||
G.window.addEventListener('message', wrapped);
|
||||
owner.onDispose(() => {
|
||||
G.window.removeEventListener('message', wrapped);
|
||||
});
|
||||
}
|
||||
|
||||
function openPopup(url: string): Window {
|
||||
// Center window on desktop
|
||||
// https://stackoverflow.com/questions/16363474/window-open-on-a-multi-monitor-dual-monitor-system-where-does-window-pop-up
|
||||
const width = 600;
|
||||
const height = 650;
|
||||
const left = window.screenX + (screen.width - width) / 2;
|
||||
const top = (screen.height - height) / 4;
|
||||
let windowFeatures = `top=${top},left=${left},menubar=no,location=no,` +
|
||||
`resizable=yes,scrollbars=yes,status=yes,height=${height},width=${width}`;
|
||||
|
||||
// If window will be too large (for example on mobile) - open as a new tab
|
||||
if (screen.width <= width || screen.height <= height) {
|
||||
windowFeatures = '';
|
||||
}
|
||||
|
||||
const authWindow = G.window.open(url, "GoogleAuthPopup", windowFeatures);
|
||||
if (!authWindow) {
|
||||
// This method should be invoked by an user action.
|
||||
throw new Error("This method should be invoked synchronously");
|
||||
}
|
||||
return authWindow;
|
||||
}
|
||||
Reference in New Issue
Block a user