(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
This commit is contained in:
Alex Hall 2023-08-18 12:54:03 +02:00
parent a1d31e41ad
commit 166312be3a
8 changed files with 121 additions and 69 deletions

View File

@ -5,15 +5,24 @@
import {makeT} from 'app/client/lib/localization'; import {makeT} from 'app/client/lib/localization';
import {AppModel, reportError} from 'app/client/models/AppModel'; import {AppModel, reportError} from 'app/client/models/AppModel';
import {DocPageModel} from "app/client/models/DocPageModel";
import {getLoginOrSignupUrl, urlState} from 'app/client/models/gristUrlState'; import {getLoginOrSignupUrl, urlState} from 'app/client/models/gristUrlState';
import {getWorkspaceInfo, ownerName, workspaceName} from 'app/client/models/WorkspaceInfo'; import {getWorkspaceInfo, ownerName, workspaceName} from 'app/client/models/WorkspaceInfo';
import {cssInput} from 'app/client/ui/cssInput'; import {cssInput} from 'app/client/ui/cssInput';
import {bigBasicButton, bigPrimaryButtonLink} from 'app/client/ui2018/buttons'; 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 {testId, theme, vars} from 'app/client/ui2018/cssVars';
import {loadingSpinner} from 'app/client/ui2018/loaders'; import {loadingSpinner} from 'app/client/ui2018/loaders';
import {select} from 'app/client/ui2018/menus'; 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 {FullUser} from 'app/common/LoginSessionAPI';
import * as roles from 'app/common/roles'; import * as roles from 'app/common/roles';
import {Document, isTemplatesOrg, Organization, Workspace} from 'app/common/UserAPI'; 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<DownloadOption>(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', ` export const cssField = styled('div', `
margin: 16px 0; margin: 16px 0;
display: flex; display: flex;

View File

@ -8,13 +8,12 @@ import MetaTableModel from 'app/client/models/MetaTableModel';
import {find as findInTree, fromTableData, TreeItemRecord, TreeRecord, import {find as findInTree, fromTableData, TreeItemRecord, TreeRecord,
TreeTableData} from 'app/client/models/TreeModel'; TreeTableData} from 'app/client/models/TreeModel';
import {TreeViewComponent} from 'app/client/ui/TreeViewComponent'; import {TreeViewComponent} from 'app/client/ui/TreeViewComponent';
import {labeledCircleCheckbox} from 'app/client/ui2018/checkbox'; import {cssRadioCheckboxOptions, radioCheckboxOption} from 'app/client/ui2018/checkbox';
import {theme} from 'app/client/ui2018/cssVars';
import {cssLink} from 'app/client/ui2018/links'; import {cssLink} from 'app/client/ui2018/links';
import {ISaveModalOptions, saveModal} from 'app/client/ui2018/modals'; import {ISaveModalOptions, saveModal} from 'app/client/ui2018/modals';
import {buildCensoredPage, buildPageDom, PageActions} from 'app/client/ui2018/pages'; import {buildCensoredPage, buildPageDom, PageActions} from 'app/client/ui2018/pages';
import {mod} from 'app/common/gutil'; 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'); const t = makeT('Pages');
@ -141,9 +140,9 @@ function buildPrompt(tableNames: string[], onSave: (option: RemoveOption) => Pro
body: dom('div', body: dom('div',
testId('popup'), testId('popup'),
buildWarning(tableNames), buildWarning(tableNames),
cssOptions( cssRadioCheckboxOptions(
buildOption(selected, 'data', t("Delete data and this page.")), radioCheckboxOption(selected, 'data', t("Delete data and this page.")),
buildOption(selected, 'page', radioCheckboxOption(selected, 'page',
[ // TODO i18n [ // TODO i18n
`Keep data and delete page. `, `Keep data and delete page. `,
`Table will remain available in `, `Table will remain available in `,
@ -161,16 +160,6 @@ function buildPrompt(tableNames: string[], onSave: (option: RemoveOption) => Pro
}); });
} }
function buildOption(value: Observable<RemoveOption>, 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[]) { function buildWarning(tables: string[]) {
return cssWarning( 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', ` const cssWarning = styled('div', `
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;

View File

@ -3,7 +3,7 @@ import {AppModel, reportError} from 'app/client/models/AppModel';
import {DocInfo, DocPageModel} from 'app/client/models/DocPageModel'; import {DocInfo, DocPageModel} from 'app/client/models/DocPageModel';
import {docUrl, urlState} from 'app/client/models/gristUrlState'; import {docUrl, urlState} from 'app/client/models/gristUrlState';
import {GristTooltips} from 'app/client/ui/GristTooltips'; 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 {sendToDrive} from 'app/client/ui/sendToDrive';
import {hoverTooltip, withInfoTooltip} from 'app/client/ui/tooltips'; import {hoverTooltip, withInfoTooltip} from 'app/client/ui/tooltips';
import {cssHoverCircle, cssTopBarBtn} from 'app/client/ui/TopBarCss'; import {cssHoverCircle, cssTopBarBtn} from 'app/client/ui/TopBarCss';
@ -252,11 +252,8 @@ function menuExports(doc: Document, pageModel: DocPageModel) {
(isElectron ? (isElectron ?
menuItem(() => gristDoc.app.comm.showItemInFolder(doc.name), menuItem(() => gristDoc.app.comm.showItemInFolder(doc.name),
t("Show in folder"), testId('tb-share-option')) : t("Show in folder"), testId('tb-share-option')) :
menuItemLink({ menuItem(() => downloadDocModal(doc, pageModel),
href: pageModel.appModel.api.getDocAPI(doc.id).getDownloadUrl(), menuIcon('Download'), t("Download..."), testId('tb-share-option'))
target: '_blank', download: ''
},
menuIcon('Download'), t("Download"), testId('tb-share-option'))
), ),
menuItemLink({ href: gristDoc.getCsvLink(), target: '_blank', download: ''}, menuItemLink({ href: gristDoc.getCsvLink(), target: '_blank', download: ''},
menuIcon('Download'), t("Export CSV"), testId('tb-share-option')), menuIcon('Download'), t("Export CSV"), testId('tb-share-option')),

View File

@ -15,9 +15,8 @@
* labeledSquareCheckbox(observable(false), 'Include other values', dom.prop('disabled', true)), * labeledSquareCheckbox(observable(false), 'Include other values', dom.prop('disabled', true)),
*/ */
import { theme } from 'app/client/ui2018/cssVars'; import {testId, theme} from 'app/client/ui2018/cssVars';
import { Computed, dom, DomArg, styled } from 'grainjs'; import {Computed, dom, DomArg, DomContents, Observable, styled} from 'grainjs';
import { Observable } from 'grainjs';
export const cssLabel = styled('label', ` export const cssLabel = styled('label', `
position: relative; position: relative;
@ -176,6 +175,48 @@ export function labeledTriStateSquareCheckbox(obs: Observable<TriState>, label:
return triStateCheckbox(obs, cssCheckboxSquare, label, ...domArgs); return triStateCheckbox(obs, cssCheckboxSquare, label, ...domArgs);
} }
export function radioCheckboxOption<T>(selectedObservable: Observable<T>, 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', ` const cssInlineRelative = styled('div', `
display: inline-block; display: inline-block;
position: relative; position: relative;

View File

@ -447,7 +447,7 @@ export interface DocAPI {
// Currently, leftHash is expected to be an ancestor of rightHash. If rightHash // 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. // is HEAD, the result will contain a copy of any rows added or updated.
compareVersion(leftHash: string, rightHash: string): Promise<DocStateComparison>; compareVersion(leftHash: string, rightHash: string): Promise<DocStateComparison>;
getDownloadUrl(template?: boolean): string; getDownloadUrl(options: {template: boolean, removeHistory: boolean}): string;
getDownloadXlsxUrl(params?: DownloadDocParams): string; getDownloadXlsxUrl(params?: DownloadDocParams): string;
getDownloadCsvUrl(params: DownloadDocParams): string; getDownloadCsvUrl(params: DownloadDocParams): string;
getDownloadTableSchemaUrl(params: DownloadDocParams): string; getDownloadTableSchemaUrl(params: DownloadDocParams): string;
@ -991,8 +991,8 @@ export class DocAPIImpl extends BaseAPI implements DocAPI {
return this.requestJson(url.href); return this.requestJson(url.href);
} }
public getDownloadUrl(template: boolean = false) { public getDownloadUrl({template, removeHistory}: {template: boolean, removeHistory: boolean}): string {
return this._url + `/download?template=${Number(template)}`; return this._url + `/download?template=${template}&nohistory=${removeHistory}`;
} }
public getDownloadXlsxUrl(params: DownloadDocParams) { public getDownloadXlsxUrl(params: DownloadDocParams) {

View File

@ -78,10 +78,13 @@ export class DocWorker {
// Get a copy of document for downloading. // Get a copy of document for downloading.
const tmpPath = await storageManager.getCopy(docId); const tmpPath = await storageManager.getCopy(docId);
if (req.query.template === '1') { if (isAffirmative(req.query.template)) {
// If template flag is on, remove data and history from the download.
await removeData(tmpPath); await removeData(tmpPath);
await removeHistory(tmpPath);
} else if (isAffirmative(req.query.nohistory)) {
await removeHistory(tmpPath);
} }
await filterDocumentInPlace(docSessionFromRequest(mreq), tmpPath); await filterDocumentInPlace(docSessionFromRequest(mreq), tmpPath);
// NOTE: We may want to reconsider the mimeType used for Grist files. // NOTE: We may want to reconsider the mimeType used for Grist files.
return res.type('application/x-sqlite3') 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) { async function removeData(filename: string) {
const db = await SQLiteDB.openDBRaw(filename, OpenMode.OPEN_EXISTING); const db = await SQLiteDB.openDBRaw(filename, OpenMode.OPEN_EXISTING);
@ -207,8 +210,15 @@ async function removeData(filename: string) {
for (const tableId of tableIds) { for (const tableId of tableIds) {
await db.run(`DELETE FROM ${quoteIdent(tableId)}`); await db.run(`DELETE FROM ${quoteIdent(tableId)}`);
} }
const history = new ActionHistoryImpl(db); await db.close();
await history.deleteActions(1); }
await db.vacuum();
/**
* 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(); await db.close();
} }

View File

@ -69,7 +69,7 @@ describe('Views.ntest', function() {
assert.equal(await gu.actions.getActiveTab().text(), 'Table1'); assert.equal(await gu.actions.getActiveTab().text(), 'Table1');
assert.deepEqual(await $(`.test-docpage-label`).array().text(), ['Table1', 'Table3']); assert.deepEqual(await $(`.test-docpage-label`).array().text(), ['Table1', 'Table3']);
await gu.actions.tableView('Table1').selectOption('Remove'); await gu.actions.tableView('Table1').selectOption('Remove');
await $(".test-removepage-option-page").click(); await $(".test-option-page").click();
await $(".test-modal-confirm").click(); await $(".test-modal-confirm").click();
await gu.waitForServer(); await gu.waitForServer();
assert.equal(await gu.actions.getActiveTab().text(), 'Table3'); assert.equal(await gu.actions.getActiveTab().text(), 'Table3');

View File

@ -1144,7 +1144,7 @@ export async function removePage(name: string|RegExp, options: {
const popupTables = await driver.findAll(".test-removepage-table", e => e.getText()); const popupTables = await driver.findAll(".test-removepage-table", e => e.getText());
assert.deepEqual(popupTables.sort(), options.tables.sort()); 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) { if (options.cancel) {
await driver.find(".test-modal-cancel").click(); await driver.find(".test-modal-cancel").click();
} else { } else {