(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
pull/636/head
Alex Hall 9 months ago
parent a1d31e41ad
commit 166312be3a

@ -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<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', `
margin: 16px 0;
display: flex;

@ -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<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[]) {
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;

@ -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')),

@ -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<TriState>, label:
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', `
display: inline-block;
position: relative;

@ -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<DocStateComparison>;
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) {

@ -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)}`);
}
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.vacuum();
await db.close();
}

@ -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');

@ -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 {

Loading…
Cancel
Save