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 {