mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
238 lines
9.1 KiB
TypeScript
238 lines
9.1 KiB
TypeScript
|
import {loadUserManager} from 'app/client/lib/imports';
|
||
|
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} from 'app/client/ui/HomeImports';
|
||
|
import {createHelpTools, cssLeftPanel, cssScrollPane, cssSectionHeader, cssTools} from 'app/client/ui/LeftPanelCommon';
|
||
|
import {cssLinkText, cssPageEntry, cssPageIcon, cssPageLink} from 'app/client/ui/LeftPanelCommon';
|
||
|
import {transientInput} from 'app/client/ui/transientInput';
|
||
|
import {colors, testId} 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 * as roles from 'app/common/roles';
|
||
|
import {Workspace} from 'app/common/UserAPI';
|
||
|
import {computed, dom, DomElementArg, Observable, observable, styled} from 'grainjs';
|
||
|
|
||
|
export function createHomeLeftPane(leftPanelOpen: Observable<boolean>, home: HomeModel) {
|
||
|
const creating = observable<boolean>(false);
|
||
|
const renaming = observable<Workspace|null>(null);
|
||
|
const samplesWorkspace = computed<Workspace|undefined>((use) =>
|
||
|
use(home.workspaces).find((ws) => Boolean(ws.isSupportWorkspace)));
|
||
|
|
||
|
return cssContent(
|
||
|
dom.autoDispose(creating),
|
||
|
dom.autoDispose(renaming),
|
||
|
dom.autoDispose(samplesWorkspace),
|
||
|
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 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}),
|
||
|
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(
|
||
|
dom.maybe(samplesWorkspace, (ws) =>
|
||
|
cssPageEntry(
|
||
|
cssPageEntry.cls('-selected', (use) => use(home.currentWSId) === ws.id),
|
||
|
cssPageLink(cssPageIcon('FieldTable'), cssLinkText(workspaceName(home.app, ws)),
|
||
|
urlState().setLinkUrl({ws: ws.id}),
|
||
|
testId('dm-samples-workspace'),
|
||
|
),
|
||
|
),
|
||
|
),
|
||
|
cssPageEntry(
|
||
|
cssPageEntry.cls('-selected', (use) => use(home.currentPage) === "trash"),
|
||
|
cssPageLink(cssPageIcon('Remove'), cssLinkText("Trash"),
|
||
|
urlState().setLinkUrl({homePage: "trash"}),
|
||
|
testId('dm-trash'),
|
||
|
),
|
||
|
),
|
||
|
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));
|
||
|
}
|
||
|
}
|
||
|
|
||
|
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")
|
||
|
),
|
||
|
// 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),
|
||
|
];
|
||
|
}
|
||
|
|
||
|
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),
|
||
|
activeEmail: user ? user.email : null,
|
||
|
resourceType: 'workspace',
|
||
|
resourceId: ws.id
|
||
|
});
|
||
|
}
|
||
|
|
||
|
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')),
|
||
|
upgradableMenuItem(needUpgrade, manageWorkspaceUsers, "Manage Users",
|
||
|
dom.cls('disabled', !roles.canEditAccess(ws.access)),
|
||
|
testId('dm-workspace-access')),
|
||
|
upgradeText(needUpgrade),
|
||
|
];
|
||
|
}
|
||
|
|
||
|
// 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;
|
||
|
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: ${colors.darkGrey};
|
||
|
}
|
||
|
.${cssPageEntry.className}-selected &:hover, .${cssPageEntry.className}-selected &.weasel-popup-open {
|
||
|
background-color: ${colors.slate};
|
||
|
}
|
||
|
`);
|