gristlabs_grist-core/app/client/ui/ShareMenu.ts
George Gevoian 25b71c4e57 (core) Polish doc tutorials
Summary:
The GristDocTutorial table is now always visible to users with edit
access to the trunk, and the Share menu is now available within
tutorial forks, making it easier for editors to replace the original
tutorial trunk with changes made in the fork, and for viewers to export
their copy of the tutorial.

Also, changes to the GristDocTutorial table are now immediately reflected
in the tutorial popup.

Test Plan: Browser tests.

Reviewers: jarek

Reviewed By: jarek

Differential Revision: https://phab.getgrist.com/D3930
2023-06-23 23:56:20 -04:00

370 lines
14 KiB
TypeScript

import {loadUserManager} from 'app/client/lib/imports';
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 {sendToDrive} from 'app/client/ui/sendToDrive';
import {hoverTooltip, withInfoTooltip} from 'app/client/ui/tooltips';
import {cssHoverCircle, cssTopBarBtn} from 'app/client/ui/TopBarCss';
import {primaryButton} from 'app/client/ui2018/buttons';
import {mediaXSmall, testId, theme} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {menu, menuAnnotate, menuDivider, menuIcon, menuItem, menuItemLink, menuText} from 'app/client/ui2018/menus';
import {buildUrlId, isFeatureEnabled, parseUrlId} from 'app/common/gristUrls';
import * as roles from 'app/common/roles';
import {Document} from 'app/common/UserAPI';
import {dom, DomContents, styled} from 'grainjs';
import {MenuCreateFunc} from 'popweasel';
import {makeT} from 'app/client/lib/localization';
const t = makeT('ShareMenu');
function buildOriginalUrlId(urlId: string, isSnapshot: boolean): string {
const parts = parseUrlId(urlId);
return isSnapshot ? buildUrlId({...parts, snapshotId: undefined}) : parts.trunkId;
}
/**
* Builds the content of the export menu. The menu button and contents render differently for
* different modes (normal, pre-fork, fork, snapshot).
*/
export function buildShareMenuButton(pageModel: DocPageModel): DomContents {
// The menu needs pageModel.currentDoc to render the button. It further needs pageModel.gristDoc
// to render its contents, but we handle by merely skipping such content if gristDoc is not yet
// available (a user quick enough to open the menu in this state would have to re-open it).
return dom.maybe(pageModel.currentDoc, (doc) => {
const appModel = pageModel.appModel;
const saveCopy = () => makeCopy(doc, appModel, t("Save Document")).catch(reportError);
if (doc.isSnapshot) {
const backToCurrent = () => urlState().pushUrl({doc: buildOriginalUrlId(doc.id, true)});
return shareButton(t("Back to Current"), () => [
menuManageUsers(doc, pageModel),
menuSaveCopy(t("Save Copy"), doc, appModel),
menuOriginal(doc, appModel, {isSnapshot: true}),
menuExports(doc, pageModel),
], {buttonAction: backToCurrent});
} else if (doc.isTutorialFork) {
return shareButton(t("Save Copy"), () => [
menuSaveCopy(t("Save Copy"), doc, appModel),
menuOriginal(doc, appModel, {isTutorialFork: true}),
menuExports(doc, pageModel),
], {buttonAction: saveCopy});
} else if (doc.isPreFork || doc.isBareFork) {
// A new unsaved document, or a fiddle, or a public example.
const saveActionTitle = doc.isBareFork ? t("Save Document") : t("Save Copy");
return shareButton(saveActionTitle, () => [
menuManageUsers(doc, pageModel),
menuSaveCopy(saveActionTitle, doc, appModel),
menuExports(doc, pageModel),
], {buttonAction: saveCopy});
} else if (doc.isFork) {
// For forks, the main actions are "Replace Original" and "Save Copy". When "Replace
// Original" is unavailable (for samples, forks of public docs, etc), we'll consider "Save
// Copy" primary and keep it as an action button on top. Otherwise, show a tag without a
// default action; click opens the menu where the user can choose.
if (!roles.canEdit(doc.trunkAccess || null)) {
return shareButton(t("Save Copy"), () => [
menuManageUsers(doc, pageModel),
menuSaveCopy(t("Save Copy"), doc, appModel),
menuOriginal(doc, appModel),
menuExports(doc, pageModel),
], {buttonAction: saveCopy});
} else {
return shareButton(t("Unsaved"), () => [
menuManageUsers(doc, pageModel),
menuSaveCopy(t("Save Copy"), doc, appModel),
menuOriginal(doc, appModel),
menuExports(doc, pageModel),
]);
}
} else {
return shareButton(null, () => [
menuManageUsers(doc, pageModel),
menuSaveCopy(t("Duplicate Document"), doc, appModel),
menuWorkOnCopy(pageModel),
menuExports(doc, pageModel),
]);
}
});
}
/**
* Render the share button, possibly as a text+icon pair when buttonText is not null. The text
* portion can be an independent action button (when buttonAction is given), or simply a more
* visible extension of the icon that opens the menu.
*/
function shareButton(buttonText: string|null, menuCreateFunc: MenuCreateFunc,
options: {buttonAction?: () => void} = {},
) {
if (!buttonText) {
// Regular circular button that opens a menu.
return cssHoverCircle({ style: `margin: 5px;` },
cssTopBarBtn('Share', dom.cls('tour-share-icon')),
menu(menuCreateFunc, {placement: 'bottom-end'}),
hoverTooltip('Share', {key: 'topBarBtnTooltip'}),
testId('tb-share'),
);
} else if (options.buttonAction) {
// Split button: the left text part calls `buttonAction`, and the circular icon opens menu.
return cssShareButton(
cssShareAction(buttonText,
dom.on('click', options.buttonAction),
testId('tb-share-action'),
),
cssShareCircle(
cssShareIcon('Share'),
menu(menuCreateFunc, {placement: 'bottom-end'}),
hoverTooltip('Share', {key: 'topBarBtnTooltip'}),
testId('tb-share'),
),
);
} else {
// Combined button: the left text part and circular icon open the menu as a single button.
return cssShareButton(
cssShareButton.cls('-combined'),
cssShareAction(buttonText),
cssShareCircle(
cssShareIcon('Share')
),
menu(menuCreateFunc, {placement: 'bottom-end'}),
hoverTooltip('Share', {key: 'topBarBtnTooltip'}),
testId('tb-share'),
);
}
}
// Renders "Manage Users" menu item.
function menuManageUsers(doc: DocInfo, pageModel: DocPageModel) {
return [
menuItem(() => manageUsers(doc, pageModel),
roles.canEditAccess(doc.access) ? t("Manage Users") : t("Access Details"),
dom.cls('disabled', doc.isFork),
testId('tb-share-option')
),
menuDivider(),
];
}
interface MenuOriginalOptions {
/** Defaults to false. */
isSnapshot?: boolean;
/** Defaults to false. */
isTutorialFork?: boolean;
}
/**
* Renders "Return to Original" and "Replace Original" menu items.
*
* When used with snapshots, we say "Current Version" in place of the word "Original".
*
* When used with tutorial forks, the "Return to Original" and "Compare to Original" menu
* items are excluded. Note that it's still possible to return to the original by manually
* setting the open mode in the URL to "/m/default" - if the menu item were to ever be included
* again, it should likely be a shortcut to setting the open mode back to default.
*/
function menuOriginal(doc: Document, appModel: AppModel, options: MenuOriginalOptions = {}) {
const {isSnapshot = false, isTutorialFork = false} = options;
const termToUse = isSnapshot ? t("Current Version") : t("Original");
const origUrlId = buildOriginalUrlId(doc.id, isSnapshot);
const originalUrl = urlState().makeUrl({doc: origUrlId});
// When comparing forks, show changes from the original to the fork. When comparing a snapshot,
// show changes from the snapshot to the original, which seems more natural. The per-snapshot
// comparison links in DocHistory use the same order.
const [leftDocId, rightDocId] = isSnapshot ? [doc.id, origUrlId] : [origUrlId, doc.id];
// Preserve the current state in order to stay on the selected page. TODO: Should auto-switch to
// first page when the requested page is not in the document.
const compareHref = dom.attr('href', (use) => urlState().makeUrl({
...use(urlState().state), doc: leftDocId, params: {compare: rightDocId}}));
const compareUrlId = urlState().state.get().params?.compare;
const comparingSnapshots: boolean = isSnapshot && Boolean(compareUrlId && parseUrlId(compareUrlId).snapshotId);
function replaceOriginal() {
const user = appModel.currentValidUser;
replaceTrunkWithFork(user, doc, appModel, origUrlId).catch(reportError);
}
return [
isTutorialFork ? null : cssMenuSplitLink({href: originalUrl},
cssMenuSplitLinkText(t("Return to {{termToUse}}", {termToUse})), testId('return-to-original'),
cssMenuIconLink({href: originalUrl, target: '_blank'}, testId('open-original'),
cssMenuIcon('FieldLink'),
)
),
menuItem(replaceOriginal, t("Replace {{termToUse}}...", {termToUse}),
// Disable if original is not writable, and also when comparing snapshots (since it's
// unclear which of the versions to use).
dom.cls('disabled', !roles.canEdit(doc.trunkAccess || null) || comparingSnapshots),
testId('replace-original'),
),
isTutorialFork ? null : menuItemLink(compareHref, {target: '_blank'}, t("Compare to {{termToUse}}", {termToUse}),
menuAnnotate('Beta'),
testId('compare-original'),
),
];
}
// Renders "Save Copy..." and "Copy as Template..." menu items. The name of the first action is
// specified in saveActionTitle.
function menuSaveCopy(saveActionTitle: string, doc: Document, appModel: AppModel) {
const saveCopy = () => makeCopy(doc, appModel, saveActionTitle).catch(reportError);
return [
// TODO Disable these when user has no accessible destinations.
menuItem(saveCopy, `${saveActionTitle}...`, testId('save-copy')),
];
}
// Renders "Work on a Copy" menu item.
function menuWorkOnCopy(pageModel: DocPageModel) {
const gristDoc = pageModel.gristDoc.get();
if (!gristDoc) { return null; }
const makeUnsavedCopy = async function() {
const {urlId} = await gristDoc.docComm.fork();
await urlState().pushUrl({doc: urlId});
};
return [
menuItem(makeUnsavedCopy, t("Work on a Copy"), testId('work-on-copy')),
menuText(
withInfoTooltip(
t("Edit without affecting the original"),
GristTooltips.workOnACopy(),
{tooltipMenuOptions: {attach: null}}
)
),
];
}
/**
* The part of the menu with "Download" and "Export CSV" items.
*/
function menuExports(doc: Document, pageModel: DocPageModel) {
const isElectron = (window as any).isRunningUnderElectron;
const gristDoc = pageModel.gristDoc.get();
if (!gristDoc) { return null; }
// Note: This line adds the 'show in folder' option for electron and a download option for hosted.
return [
menuDivider(),
(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'))
),
menuItemLink({ href: gristDoc.getCsvLink(), target: '_blank', download: ''},
menuIcon('Download'), t("Export CSV"), testId('tb-share-option')),
menuItemLink({
href: pageModel.appModel.api.getDocAPI(doc.id).getDownloadXlsxUrl(),
target: '_blank', download: ''
}, menuIcon('Download'), t("Export XLSX"), testId('tb-share-option')),
(!isFeatureEnabled("sendToDrive") ? null : menuItem(() => sendToDrive(doc, pageModel),
menuIcon('Download'), t("Send to Google Drive"), testId('tb-share-option'))),
];
}
/**
* Opens the user-manager for the doc.
*/
async function manageUsers(doc: DocInfo, docPageModel: DocPageModel) {
const appModel: AppModel = docPageModel.appModel;
const api = appModel.api;
const user = appModel.currentValidUser;
(await loadUserManager()).showUserManagerModal(api, {
permissionData: api.getDocAccess(doc.id),
activeUser: user,
resourceType: 'document',
resourceId: doc.id,
resource: doc,
docPageModel,
appModel: docPageModel.appModel,
linkToCopy: urlState().makeUrl(docUrl(doc)),
// On save, re-fetch the document info, to toggle the "Public Access" icon if it changed.
// Skip if personal, since personal cannot affect "Public Access", and the only
// change possible is to remove the user (which would make refreshCurrentDoc fail)
onSave: async (personal) => !personal && docPageModel.refreshCurrentDoc(doc),
reload: () => api.getDocAccess(doc.id),
});
}
const cssShareButton = styled('div', `
display: flex;
align-items: center;
position: relative;
z-index: 0;
margin: 5px;
white-space: nowrap;
--share-btn-bg: ${theme.controlPrimaryBg};
&-combined:hover, &-combined.weasel-popup-open {
--share-btn-bg: ${theme.controlPrimaryHoverBg};
}
`);
// Hide this on very small screens, since it takes up a lot of space and its action is also
// available in the associated menu.
const cssShareAction = styled(primaryButton, `
margin-right: -16px;
padding-right: 24px;
background-color: var(--share-btn-bg);
border-color: var(--share-btn-bg);
@media ${mediaXSmall} {
& {
display: none !important;
}
}
`);
const cssShareCircle = styled(cssHoverCircle, `
z-index: 1;
background-color: var(--share-btn-bg);
border: 1px solid ${theme.topHeaderBg};
&:hover, &.weasel-popup-open {
background-color: ${theme.controlPrimaryHoverBg};
}
`);
const cssShareIcon = styled(cssTopBarBtn, `
background-color: ${theme.controlPrimaryFg};
height: 30px;
width: 30px;
`);
const cssMenuSplitLink = styled(menuItemLink, `
padding: 0;
align-items: stretch;
`);
const cssMenuSplitLinkText = styled('div', `
flex: auto;
padding: var(--weaseljs-menu-item-padding, 8px 24px);
&:not(:hover) {
background-color: ${theme.menuBg};
color: ${theme.menuItemFg};
}
`);
const cssMenuIconLink = styled('a', `
display: block;
flex: none;
padding: 8px 24px;
background-color: ${theme.menuBg};
--icon-color: ${theme.menuItemLinkFg};
&:hover {
background-color: ${theme.menuItemLinkselectedBg};
--icon-color: ${theme.menuItemLinkSelectedFg};
}
`);
const cssMenuIcon = styled(icon, `
display: block;
`);