From 166312be3aab700333290ca67c5a18bb32f7f23d Mon Sep 17 00:00:00 2001 From: Alex Hall Date: Fri, 18 Aug 2023 12:54:03 +0200 Subject: [PATCH] (core) Add dialog with options to allow downloading without history or data Summary: {F74398} Refactored the 'radio checkboxes' in the modal for deleting a page and reused them here. The option to download as a template already existed in the server code but wasn't being exercised by the frontend. Also added an option to remove just the history, which is the main motivation for this diff. Test Plan: Expanded the existing nbrowser test. Reviewers: paulfitz Reviewed By: paulfitz Differential Revision: https://phab.getgrist.com/D3999 --- app/client/ui/MakeCopyMenu.ts | 50 +++++++++++++++++++++++++++++++-- app/client/ui/Pages.ts | 52 ++++------------------------------- app/client/ui/ShareMenu.ts | 9 ++---- app/client/ui2018/checkbox.ts | 47 +++++++++++++++++++++++++++++-- app/common/UserAPI.ts | 6 ++-- app/server/lib/DocWorker.ts | 22 +++++++++++---- test/nbrowser/Views.ntest.js | 2 +- test/nbrowser/gristUtils.ts | 2 +- 8 files changed, 121 insertions(+), 69 deletions(-) diff --git a/app/client/ui/MakeCopyMenu.ts b/app/client/ui/MakeCopyMenu.ts index 6c757b00..a23a4636 100644 --- a/app/client/ui/MakeCopyMenu.ts +++ b/app/client/ui/MakeCopyMenu.ts @@ -5,15 +5,24 @@ import {makeT} from 'app/client/lib/localization'; import {AppModel, reportError} from 'app/client/models/AppModel'; +import {DocPageModel} from "app/client/models/DocPageModel"; import {getLoginOrSignupUrl, urlState} from 'app/client/models/gristUrlState'; import {getWorkspaceInfo, ownerName, workspaceName} from 'app/client/models/WorkspaceInfo'; import {cssInput} from 'app/client/ui/cssInput'; import {bigBasicButton, bigPrimaryButtonLink} from 'app/client/ui2018/buttons'; -import {labeledSquareCheckbox} from 'app/client/ui2018/checkbox'; +import {cssRadioCheckboxOptions, labeledSquareCheckbox, radioCheckboxOption} from 'app/client/ui2018/checkbox'; import {testId, theme, vars} from 'app/client/ui2018/cssVars'; import {loadingSpinner} from 'app/client/ui2018/loaders'; import {select} from 'app/client/ui2018/menus'; -import {confirmModal, cssModalBody, cssModalButtons, cssModalWidth, modal, saveModal} from 'app/client/ui2018/modals'; +import { + confirmModal, + cssModalBody, + cssModalButtons, + cssModalTitle, + cssModalWidth, + modal, + saveModal +} from 'app/client/ui2018/modals'; import {FullUser} from 'app/common/LoginSessionAPI'; import * as roles from 'app/common/roles'; import {Document, isTemplatesOrg, Organization, Workspace} from 'app/common/UserAPI'; @@ -278,6 +287,43 @@ class SaveCopyModal extends Disposable { } } +type DownloadOption = 'full' | 'nohistory' | 'template'; + +export function downloadDocModal(doc: Document, pageModel: DocPageModel) { + return modal((ctl, owner) => { + const selected = Observable.create(owner, 'full'); + + return [ + cssModalTitle(`Download document`), + cssRadioCheckboxOptions( + radioCheckboxOption(selected, 'full', t("Download full document and history")), + radioCheckboxOption(selected, 'nohistory', t("Remove document history (can significantly reduce file size)")), + radioCheckboxOption(selected, 'template', t("Remove all data but keep the structure to use as a template")), + ), + cssModalButtons( + dom.domComputed(use => + bigPrimaryButtonLink(`Download`, { + href: pageModel.appModel.api.getDocAPI(doc.id).getDownloadUrl({ + template: use(selected) === "template", + removeHistory: use(selected) === "nohistory" || use(selected) === "template", + }), + target: '_blank', + download: '' + }, + dom.on('click', () => { + ctl.close(); + }), + testId('download-button-link'), + ), + ), + bigBasicButton('Cancel', dom.on('click', () => { + ctl.close(); + })) + ) + ]; + }); +} + export const cssField = styled('div', ` margin: 16px 0; display: flex; diff --git a/app/client/ui/Pages.ts b/app/client/ui/Pages.ts index da0869db..4a9836ef 100644 --- a/app/client/ui/Pages.ts +++ b/app/client/ui/Pages.ts @@ -8,13 +8,12 @@ import MetaTableModel from 'app/client/models/MetaTableModel'; import {find as findInTree, fromTableData, TreeItemRecord, TreeRecord, TreeTableData} from 'app/client/models/TreeModel'; import {TreeViewComponent} from 'app/client/ui/TreeViewComponent'; -import {labeledCircleCheckbox} from 'app/client/ui2018/checkbox'; -import {theme} from 'app/client/ui2018/cssVars'; +import {cssRadioCheckboxOptions, radioCheckboxOption} from 'app/client/ui2018/checkbox'; import {cssLink} from 'app/client/ui2018/links'; import {ISaveModalOptions, saveModal} from 'app/client/ui2018/modals'; import {buildCensoredPage, buildPageDom, PageActions} from 'app/client/ui2018/pages'; import {mod} from 'app/common/gutil'; -import {Computed, Disposable, dom, DomContents, fromKo, makeTestId, observable, Observable, styled} from 'grainjs'; +import {Computed, Disposable, dom, fromKo, makeTestId, observable, Observable, styled} from 'grainjs'; const t = makeT('Pages'); @@ -141,9 +140,9 @@ function buildPrompt(tableNames: string[], onSave: (option: RemoveOption) => Pro body: dom('div', testId('popup'), buildWarning(tableNames), - cssOptions( - buildOption(selected, 'data', t("Delete data and this page.")), - buildOption(selected, 'page', + cssRadioCheckboxOptions( + radioCheckboxOption(selected, 'data', t("Delete data and this page.")), + radioCheckboxOption(selected, 'page', [ // TODO i18n `Keep data and delete page. `, `Table will remain available in `, @@ -161,16 +160,6 @@ function buildPrompt(tableNames: string[], onSave: (option: RemoveOption) => Pro }); } -function buildOption(value: Observable, id: RemoveOption, content: DomContents) { - const selected = Computed.create(null, use => use(value) === id) - .onWrite(val => val ? value.set(id) : void 0); - return dom.update( - labeledCircleCheckbox(selected, content, dom.autoDispose(selected)), - testId(`option-${id}`), - cssBlockCheckbox.cls(''), - cssBlockCheckbox.cls('-block', selected), - ); -} function buildWarning(tables: string[]) { return cssWarning( @@ -178,37 +167,6 @@ function buildWarning(tables: string[]) { ); } -const cssOptions = styled('div', ` - display: flex; - flex-direction: column; - gap: 10px; -`); - -// We need to reset top and left of ::before element, as it is wrongly set -// on the inline checkbox. -// To simulate radio button behavior, we will block user input after option is selected, because -// checkbox doesn't support two-way binding. -const cssBlockCheckbox = styled('div', ` - display: flex; - padding: 10px 8px; - border: 1px solid ${theme.modalBorder}; - border-radius: 3px; - cursor: pointer; - & input::before, & input::after { - top: unset; - left: unset; - } - &:hover { - border-color: ${theme.accentBorder}; - } - &-block { - pointer-events: none; - } - &-block a { - pointer-events: all; - } -`); - const cssWarning = styled('div', ` display: flex; flex-wrap: wrap; diff --git a/app/client/ui/ShareMenu.ts b/app/client/ui/ShareMenu.ts index fe3fa365..97a24491 100644 --- a/app/client/ui/ShareMenu.ts +++ b/app/client/ui/ShareMenu.ts @@ -3,7 +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 {GristTooltips} from 'app/client/ui/GristTooltips'; -import {makeCopy, replaceTrunkWithFork} from 'app/client/ui/MakeCopyMenu'; +import {downloadDocModal, makeCopy, replaceTrunkWithFork} from 'app/client/ui/MakeCopyMenu'; import {sendToDrive} from 'app/client/ui/sendToDrive'; import {hoverTooltip, withInfoTooltip} from 'app/client/ui/tooltips'; import {cssHoverCircle, cssTopBarBtn} from 'app/client/ui/TopBarCss'; @@ -252,11 +252,8 @@ function menuExports(doc: Document, pageModel: DocPageModel) { (isElectron ? menuItem(() => gristDoc.app.comm.showItemInFolder(doc.name), t("Show in folder"), testId('tb-share-option')) : - menuItemLink({ - href: pageModel.appModel.api.getDocAPI(doc.id).getDownloadUrl(), - target: '_blank', download: '' - }, - menuIcon('Download'), t("Download"), testId('tb-share-option')) + menuItem(() => downloadDocModal(doc, pageModel), + menuIcon('Download'), t("Download..."), testId('tb-share-option')) ), menuItemLink({ href: gristDoc.getCsvLink(), target: '_blank', download: ''}, menuIcon('Download'), t("Export CSV"), testId('tb-share-option')), diff --git a/app/client/ui2018/checkbox.ts b/app/client/ui2018/checkbox.ts index a7de9b78..36e9a6f0 100644 --- a/app/client/ui2018/checkbox.ts +++ b/app/client/ui2018/checkbox.ts @@ -15,9 +15,8 @@ * labeledSquareCheckbox(observable(false), 'Include other values', dom.prop('disabled', true)), */ -import { theme } from 'app/client/ui2018/cssVars'; -import { Computed, dom, DomArg, styled } from 'grainjs'; -import { Observable } from 'grainjs'; +import {testId, theme} from 'app/client/ui2018/cssVars'; +import {Computed, dom, DomArg, DomContents, Observable, styled} from 'grainjs'; export const cssLabel = styled('label', ` position: relative; @@ -176,6 +175,48 @@ export function labeledTriStateSquareCheckbox(obs: Observable, label: return triStateCheckbox(obs, cssCheckboxSquare, label, ...domArgs); } +export function radioCheckboxOption(selectedObservable: Observable, optionId: T, content: DomContents) { + const selected = Computed.create(null, use => use(selectedObservable) === optionId) + .onWrite(val => val ? selectedObservable.set(optionId) : void 0); + return dom.update( + labeledCircleCheckbox(selected, content, dom.autoDispose(selected)), + testId(`option-${optionId}`), + cssBlockCheckbox.cls(''), + cssBlockCheckbox.cls('-block', selected), + ); +} + +export const cssRadioCheckboxOptions = styled('div', ` + display: flex; + flex-direction: column; + gap: 10px; +`); + +// We need to reset top and left of ::before element, as it is wrongly set +// on the inline checkbox. +// To simulate radio button behavior, we will block user input after option is selected, because +// checkbox doesn't support two-way binding. +const cssBlockCheckbox = styled('div', ` + display: flex; + padding: 10px 8px; + border: 1px solid ${theme.modalBorder}; + border-radius: 3px; + cursor: pointer; + & input::before, & input::after { + top: unset; + left: unset; + } + &:hover { + border-color: ${theme.accentBorder}; + } + &-block { + pointer-events: none; + } + &-block a { + pointer-events: all; + } +`); + const cssInlineRelative = styled('div', ` display: inline-block; position: relative; diff --git a/app/common/UserAPI.ts b/app/common/UserAPI.ts index ca5cb41a..5a9b7159 100644 --- a/app/common/UserAPI.ts +++ b/app/common/UserAPI.ts @@ -447,7 +447,7 @@ export interface DocAPI { // Currently, leftHash is expected to be an ancestor of rightHash. If rightHash // is HEAD, the result will contain a copy of any rows added or updated. compareVersion(leftHash: string, rightHash: string): Promise; - getDownloadUrl(template?: boolean): string; + getDownloadUrl(options: {template: boolean, removeHistory: boolean}): string; getDownloadXlsxUrl(params?: DownloadDocParams): string; getDownloadCsvUrl(params: DownloadDocParams): string; getDownloadTableSchemaUrl(params: DownloadDocParams): string; @@ -991,8 +991,8 @@ export class DocAPIImpl extends BaseAPI implements DocAPI { return this.requestJson(url.href); } - public getDownloadUrl(template: boolean = false) { - return this._url + `/download?template=${Number(template)}`; + public getDownloadUrl({template, removeHistory}: {template: boolean, removeHistory: boolean}): string { + return this._url + `/download?template=${template}&nohistory=${removeHistory}`; } public getDownloadXlsxUrl(params: DownloadDocParams) { diff --git a/app/server/lib/DocWorker.ts b/app/server/lib/DocWorker.ts index 180fa2e5..1e5eb084 100644 --- a/app/server/lib/DocWorker.ts +++ b/app/server/lib/DocWorker.ts @@ -78,10 +78,13 @@ export class DocWorker { // Get a copy of document for downloading. const tmpPath = await storageManager.getCopy(docId); - if (req.query.template === '1') { - // If template flag is on, remove data and history from the download. + if (isAffirmative(req.query.template)) { await removeData(tmpPath); + await removeHistory(tmpPath); + } else if (isAffirmative(req.query.nohistory)) { + await removeHistory(tmpPath); } + await filterDocumentInPlace(docSessionFromRequest(mreq), tmpPath); // NOTE: We may want to reconsider the mimeType used for Grist files. return res.type('application/x-sqlite3') @@ -197,7 +200,7 @@ async function activeDocMethod(role: 'viewers'|'editors'|null, methodName: strin } /** - * Remove rows from all user tables, and wipe as much history as we can. + * Remove rows from all user tables. */ async function removeData(filename: string) { const db = await SQLiteDB.openDBRaw(filename, OpenMode.OPEN_EXISTING); @@ -207,8 +210,15 @@ async function removeData(filename: string) { for (const tableId of tableIds) { await db.run(`DELETE FROM ${quoteIdent(tableId)}`); } - const history = new ActionHistoryImpl(db); - await history.deleteActions(1); - await db.vacuum(); + await db.close(); +} + +/** + * Wipe as much history as we can. + */ +async function removeHistory(filename: string) { + const db = await SQLiteDB.openDBRaw(filename, OpenMode.OPEN_EXISTING); + const history = new ActionHistoryImpl(db); + await history.deleteActions(1); await db.close(); } diff --git a/test/nbrowser/Views.ntest.js b/test/nbrowser/Views.ntest.js index 3434dd69..acc1f1c1 100644 --- a/test/nbrowser/Views.ntest.js +++ b/test/nbrowser/Views.ntest.js @@ -69,7 +69,7 @@ describe('Views.ntest', function() { assert.equal(await gu.actions.getActiveTab().text(), 'Table1'); assert.deepEqual(await $(`.test-docpage-label`).array().text(), ['Table1', 'Table3']); await gu.actions.tableView('Table1').selectOption('Remove'); - await $(".test-removepage-option-page").click(); + await $(".test-option-page").click(); await $(".test-modal-confirm").click(); await gu.waitForServer(); assert.equal(await gu.actions.getActiveTab().text(), 'Table3'); diff --git a/test/nbrowser/gristUtils.ts b/test/nbrowser/gristUtils.ts index a4f43702..a074abce 100644 --- a/test/nbrowser/gristUtils.ts +++ b/test/nbrowser/gristUtils.ts @@ -1144,7 +1144,7 @@ export async function removePage(name: string|RegExp, options: { const popupTables = await driver.findAll(".test-removepage-table", e => e.getText()); assert.deepEqual(popupTables.sort(), options.tables.sort()); } - await popup.find(`.test-removepage-option-${options.withData ? 'data': 'page'}`).click(); + await popup.find(`.test-option-${options.withData ? 'data': 'page'}`).click(); if (options.cancel) { await driver.find(".test-modal-cancel").click(); } else {