(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 {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;

View File

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

View File

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

View File

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