gristlabs_grist-core/app/client/ui/HomeLeftPane.ts

275 lines
11 KiB
TypeScript
Raw Normal View History

import {loadUserManager} from 'app/client/lib/imports';
import {ImportSourceElement} from 'app/client/lib/ImportSourceElement';
import {reportError} from 'app/client/models/AppModel';
import {docUrl, urlState} from 'app/client/models/gristUrlState';
import {HomeModel} from 'app/client/models/HomeModel';
import {getWorkspaceInfo, workspaceName} from 'app/client/models/WorkspaceInfo';
import {addNewButton, cssAddNewButton} from 'app/client/ui/AddNewButton';
import {docImport, importFromPlugin} from 'app/client/ui/HomeImports';
import {cssLinkText, cssPageEntry, cssPageIcon, cssPageLink, cssSpacer} from 'app/client/ui/LeftPanelCommon';
import {createVideoTourToolsButton} from 'app/client/ui/OpenVideoTour';
import {transientInput} from 'app/client/ui/transientInput';
import {testId, theme} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {menu, menuIcon, menuItem, upgradableMenuItem, upgradeText} from 'app/client/ui2018/menus';
import {confirmModal} from 'app/client/ui2018/modals';
import {shouldHideUiElement} from 'app/common/gristUrls';
import * as roles from 'app/common/roles';
import {Workspace} from 'app/common/UserAPI';
import {computed, dom, domComputed, DomElementArg, observable, Observable, styled} from 'grainjs';
import {createHelpTools, cssLeftPanel, cssScrollPane,
cssSectionHeader, cssTools} from 'app/client/ui/LeftPanelCommon';
export function createHomeLeftPane(leftPanelOpen: Observable<boolean>, home: HomeModel) {
const creating = observable<boolean>(false);
const renaming = observable<Workspace|null>(null);
return cssContent(
dom.autoDispose(creating),
dom.autoDispose(renaming),
addNewButton(leftPanelOpen,
menu(() => addMenu(home, creating), {
placement: 'bottom-start',
// "Add New" menu should have the same width as the "Add New" button that opens it.
stretchToSelector: `.${cssAddNewButton.className}`
}),
testId('dm-add-new')
),
cssScrollPane(
cssPageEntry(
cssPageEntry.cls('-selected', (use) => use(home.currentPage) === "all"),
cssPageLink(cssPageIcon('Home'),
cssLinkText('All Documents'),
urlState().setLinkUrl({ws: undefined, homePage: undefined}),
testId('dm-all-docs'),
),
),
dom.maybe(use => !use(home.singleWorkspace), () =>
cssSectionHeader('Workspaces',
// Give it a testId, because it's a good element to simulate "click-away" in tests.
testId('dm-ws-label')
),
),
dom.forEach(home.workspaces, (ws) => {
if (ws.isSupportWorkspace) { return null; }
const info = getWorkspaceInfo(home.app, ws);
const isTrivial = computed((use) => Boolean(getWorkspaceInfo(home.app, ws).isDefault &&
use(home.singleWorkspace)));
// TODO: Introduce a "SwitchSelector" pattern to avoid the need for N computeds (and N
// recalculations) to select one of N items.
const isRenaming = computed((use) => use(renaming) === ws);
return cssPageEntry(
dom.autoDispose(isRenaming),
dom.autoDispose(isTrivial),
dom.hide(isTrivial),
cssPageEntry.cls('-selected', (use) => use(home.currentWSId) === ws.id),
cssPageLink(cssPageIcon('Folder'), cssLinkText(workspaceName(home.app, ws)),
dom.hide(isRenaming),
urlState().setLinkUrl({ws: ws.id}),
// Don't show menu if workspace is personal and shared by another user; we could
// be a bit more nuanced here, but as of today the menu isn't particularly useful
// as all the menu options are disabled.
!info.self && info.owner ? null : cssMenuTrigger(icon('Dots'),
menu(() => workspaceMenu(home, ws, renaming),
{placement: 'bottom-start', parentSelectorToMark: '.' + cssPageEntry.className}),
// Clicks on the menu trigger shouldn't follow the link that it's contained in.
dom.on('click', (ev) => { ev.stopPropagation(); ev.preventDefault(); }),
testId('dm-workspace-options'),
),
testId('dm-workspace'),
),
cssPageEntry.cls('-renaming', isRenaming),
dom.maybe(isRenaming, () =>
cssPageLink(cssPageIcon('Folder'),
cssEditorInput({
initialValue: ws.name || '',
save: async (val) => (val !== ws.name) ? home.renameWorkspace(ws.id, val) : undefined,
close: () => renaming.set(null),
}, testId('dm-ws-name-editor'))
)
),
);
}),
dom.maybe(creating, () => cssPageEntry(
cssPageLink(cssPageIcon('Folder'),
cssEditorInput({
initialValue: '',
save: async (val) => (val !== '') ? home.createWorkspace(val) : undefined,
close: () => creating.set(false),
}, testId('dm-ws-name-editor'))
)
)),
cssTools(
cssPageEntry(
dom.hide(shouldHideUiElement("templates")),
cssPageEntry.cls('-selected', (use) => use(home.currentPage) === "templates"),
cssPageLink(cssPageIcon('FieldTable'), cssLinkText("Examples & Templates"),
urlState().setLinkUrl({homePage: "templates"}),
testId('dm-templates-page'),
),
),
cssPageEntry(
cssPageEntry.cls('-selected', (use) => use(home.currentPage) === "trash"),
cssPageLink(cssPageIcon('Remove'), cssLinkText("Trash"),
urlState().setLinkUrl({homePage: "trash"}),
testId('dm-trash'),
),
),
cssSpacer(),
createVideoTourToolsButton(),
createHelpTools(home.app),
)
)
);
}
export async function createDocAndOpen(home: HomeModel) {
const destWS = home.newDocWorkspace.get();
if (!destWS) { return; }
try {
const docId = await home.createDoc("Untitled document", destWS === "unsaved" ? "unsaved" : destWS.id);
// Fetch doc information including urlId.
// TODO: consider changing API to return same response as a GET when creating an
// object, which is a semi-standard.
const doc = await home.app.api.getDoc(docId);
await urlState().pushUrl(docUrl(doc));
} catch (err) {
reportError(err);
}
}
export async function importDocAndOpen(home: HomeModel) {
const destWS = home.newDocWorkspace.get();
if (!destWS) { return; }
const docId = await docImport(home.app, destWS === "unsaved" ? "unsaved" : destWS.id);
if (docId) {
const doc = await home.app.api.getDoc(docId);
await urlState().pushUrl(docUrl(doc));
}
}
export async function importFromPluginAndOpen(home: HomeModel, source: ImportSourceElement) {
try {
const destWS = home.newDocWorkspace.get();
if (!destWS) { return; }
const docId = await importFromPlugin(
home.app,
destWS === "unsaved" ? "unsaved" : destWS.id,
source);
if (docId) {
const doc = await home.app.api.getDoc(docId);
await urlState().pushUrl(docUrl(doc));
}
} catch (err) {
reportError(err);
}
}
function addMenu(home: HomeModel, creating: Observable<boolean>): DomElementArg[] {
const org = home.app.currentOrg;
const orgAccess: roles.Role|null = org ? org.access : null;
const needUpgrade = home.app.currentFeatures.maxWorkspacesPerOrg === 1;
return [
menuItem(() => createDocAndOpen(home), menuIcon('Page'), "Create Empty Document",
dom.cls('disabled', !home.newDocWorkspace.get()),
testId("dm-new-doc")
),
menuItem(() => importDocAndOpen(home), menuIcon('Import'), "Import Document",
dom.cls('disabled', !home.newDocWorkspace.get()),
testId("dm-import")
),
domComputed(home.importSources, importSources => ([
...importSources.map((source, i) =>
menuItem(() => importFromPluginAndOpen(home, source),
menuIcon('Import'),
source.importSource.label,
dom.cls('disabled', !home.newDocWorkspace.get()),
testId(`dm-import-plugin`)
))
])),
// For workspaces: if ACL says we can create them, but product says we can't,
// then offer an upgrade link.
upgradableMenuItem(needUpgrade, () => creating.set(true), menuIcon('Folder'), "Create Workspace",
dom.cls('disabled', (use) => !roles.canEdit(orgAccess) || !use(home.available)),
testId("dm-new-workspace")
),
upgradeText(needUpgrade, () => home.app.showUpgradeModal()),
];
}
function workspaceMenu(home: HomeModel, ws: Workspace, renaming: Observable<Workspace|null>) {
function deleteWorkspace() {
confirmModal(`Delete ${ws.name} and all included documents?`, 'Delete',
() => home.deleteWorkspace(ws.id, false),
'Workspace will be moved to Trash.');
}
async function manageWorkspaceUsers() {
const api = home.app.api;
const user = home.app.currentUser;
(await loadUserManager()).showUserManagerModal(api, {
permissionData: api.getWorkspaceAccess(ws.id),
activeUser: user,
resourceType: 'workspace',
resourceId: ws.id,
resource: ws,
});
}
const needUpgrade = home.app.currentFeatures.maxWorkspacesPerOrg === 1;
return [
upgradableMenuItem(needUpgrade, () => renaming.set(ws), "Rename",
dom.cls('disabled', !roles.canEdit(ws.access)),
testId('dm-rename-workspace')),
upgradableMenuItem(needUpgrade, deleteWorkspace, "Delete",
dom.cls('disabled', user => !roles.canEdit(ws.access)),
testId('dm-delete-workspace')),
// TODO: Personal plans can't currently share workspaces, but that restriction
// should formally be documented and defined in `Features`, with this check updated
// to look there instead.
home.app.isPersonal ? null : upgradableMenuItem(needUpgrade, manageWorkspaceUsers,
roles.canEditAccess(ws.access) ? "Manage Users" : "Access Details",
testId('dm-workspace-access')),
upgradeText(needUpgrade, () => home.app.showUpgradeModal()),
];
}
// Below are all the styled elements.
const cssContent = styled(cssLeftPanel, `
--page-icon-margin: 12px;
`);
export const cssEditorInput = styled(transientInput, `
height: 24px;
flex: 1 1 0px;
min-width: 0px;
color: initial;
background-color: ${theme.inputBg};
margin-right: 16px;
font-size: inherit;
`);
const cssMenuTrigger = styled('div', `
margin: 0 4px 0 auto;
height: 24px;
width: 24px;
padding: 4px;
line-height: 0px;
border-radius: 3px;
cursor: default;
display: none;
.${cssPageLink.className}:hover > &, &.weasel-popup-open {
display: block;
}
&:hover, &.weasel-popup-open {
background-color: ${theme.pageOptionsHoverBg};
}
.${cssPageEntry.className}-selected &:hover, .${cssPageEntry.className}-selected &.weasel-popup-open {
background-color: ${theme.pageOptionsSelectedHoverBg};
}
`);