/** * This module exports a DocMenu component, consisting of an organization dropdown, a sidepane * of workspaces, and a doc list. The organization and workspace selectors filter the doc list. * Orgs, workspaces and docs are fetched asynchronously on build via the passed in API. */ import {loadUserManager} from 'app/client/lib/imports'; import {reportError} from 'app/client/models/AppModel'; import {docUrl, urlState} from 'app/client/models/gristUrlState'; import {getTimeFromNow, HomeModel, makeLocalViewSettings, ViewSettings} from 'app/client/models/HomeModel'; import {getWorkspaceInfo, workspaceName} from 'app/client/models/WorkspaceInfo'; import * as css from 'app/client/ui/DocMenuCss'; import {buildExampleList, buildExampleListBody, buildHomeIntro} from 'app/client/ui/HomeIntro'; import {buildPinnedDoc, createPinnedDocs} from 'app/client/ui/PinnedDocs'; import {shadowScroll} from 'app/client/ui/shadowScroll'; import {transition} from 'app/client/ui/transitions'; import {buttonSelect, cssButtonSelect} from 'app/client/ui2018/buttonSelect'; import {colors} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; import {loadingSpinner} from 'app/client/ui2018/loaders'; import {menu, menuItem, menuText, select} from 'app/client/ui2018/menus'; import {confirmModal, saveModal} from 'app/client/ui2018/modals'; import {IHomePage} from 'app/common/gristUrls'; import {SortPref, ViewPref} from 'app/common/Prefs'; import * as roles from 'app/common/roles'; import {Document, Workspace} from 'app/common/UserAPI'; import {Computed, computed, dom, DomContents, makeTestId, Observable, observable} from 'grainjs'; import sortBy = require('lodash/sortBy'); const testId = makeTestId('test-dm-'); /** * The DocMenu is the main area of the home page, listing all docs. * * Usage: * dom('div', createDocMenu(homeModel)) */ export function createDocMenu(home: HomeModel) { return dom.domComputed(home.loading, loading => ( loading === 'slow' ? css.spinner(loadingSpinner()) : loading ? null : createLoadedDocMenu(home) )); } function createLoadedDocMenu(home: HomeModel) { const flashDocId = observable(null); return css.docList( dom.maybe(!home.app.currentFeatures.workspaces, () => [ css.docListHeader('This service is not available right now'), dom.text('(The organization needs a paid plan)') ]), // currentWS and showIntro observables change together. We capture both in one domComputed call. dom.domComputed<[IHomePage, Workspace|undefined, boolean]>( (use) => [use(home.currentPage), use(home.currentWS), use(home.showIntro)], ([page, workspace, showIntro]) => { const viewSettings: ViewSettings = page === 'trash' ? makeLocalViewSettings(home, 'trash') : workspace ? makeLocalViewSettings(home, workspace.id) : home; return [ // Hide the sort option when only showing examples, since we keep them in a specific order. buildPrefs(viewSettings, {hideSort: Boolean(showIntro || workspace?.isSupportWorkspace)}), // Build the pinned docs dom. Builds nothing if the selectedOrg is unloaded or dom.maybe((use) => use(home.currentWSPinnedDocs).length > 0, () => [ css.docListHeader(css.docHeaderIconDark('PinBig'), 'Pinned Documents'), createPinnedDocs(home), ]), dom.maybe(home.available, () => [ (showIntro && page === 'all' ? null : css.docListHeader( ( page === 'all' ? 'All Documents' : page === 'trash' ? 'Trash' : workspace && [css.docHeaderIcon('Folder'), workspaceName(home.app, workspace)] ), testId('doc-header'), ) ), ( (page === 'all') ? dom('div', showIntro ? buildHomeIntro(home) : null, buildAllDocsBlock(home, home.workspaces, showIntro, flashDocId, viewSettings), ) : (page === 'trash') ? dom('div', css.docBlock('Documents stay in Trash for 30 days, after which they get deleted permanently.'), dom.maybe((use) => use(home.trashWorkspaces).length === 0, () => css.docBlock('Trash is empty.') ), buildAllDocsBlock(home, home.trashWorkspaces, false, flashDocId, viewSettings), ) : workspace ? (workspace.isSupportWorkspace ? buildExampleListBody(home, workspace, viewSettings) : css.docBlock( buildWorkspaceDocBlock(home, workspace, flashDocId, viewSettings), testId('doc-block') ) ) : css.docBlock('Workspace not found') ) ]), ]; }), testId('doclist') ); } function buildAllDocsBlock( home: HomeModel, workspaces: Observable, showIntro: boolean, flashDocId: Observable, viewSettings: ViewSettings, ) { const org = home.app.currentOrg; return dom.forEach(workspaces, (ws) => { const isPersonalOrg = Boolean(org && org.owner); if (ws.isSupportWorkspace) { // Show the example docs in the "All Documents" list for all personal orgs // and for non-personal orgs when showing intro. if (!isPersonalOrg && !showIntro) { return null; } return buildExampleList(home, ws, viewSettings); } else { // Show docs in regular workspaces. For empty orgs, we show the intro and skip // the empty workspace headers. Workspaces are still listed in the left panel. if (showIntro) { return null; } return css.docBlock( css.docBlockHeaderLink( css.wsLeft( css.docHeaderIcon('Folder'), workspaceName(home.app, ws), ), (ws.removedAt ? [ css.docRowUpdatedAt(`Deleted ${getTimeFromNow(ws.removedAt)}`), css.docMenuTrigger(icon('Dots')), menu(() => makeRemovedWsOptionsMenu(home, ws), {placement: 'bottom-end', parentSelectorToMark: '.' + css.docRowWrapper.className}), ] : urlState().setLinkUrl({ws: ws.id}) ), dom.hide((use) => Boolean(getWorkspaceInfo(home.app, ws).isDefault && use(home.singleWorkspace))), testId('ws-header'), ), buildWorkspaceDocBlock(home, ws, flashDocId, viewSettings), testId('doc-block') ); } }); } /** * Build the widget for selecting sort and view mode options. * If hideSort is true, will hide the sort dropdown: it has no effect on the list of examples, so * best to hide when those are the only docs shown. */ function buildPrefs(viewSettings: ViewSettings, options: {hideSort: boolean}): DomContents { return css.prefSelectors( // The Sort selector. options.hideSort ? null : dom.update( select(viewSettings.currentSort, [ {value: 'name', label: 'By Name'}, {value: 'date', label: 'By Date Modified'}, ], { buttonCssClass: css.sortSelector.className }, ), testId('sort-mode'), ), // The View selector. buttonSelect(viewSettings.currentView, [ {value: 'icons', icon: 'TypeTable'}, {value: 'list', icon: 'TypeCardList'}, ], cssButtonSelect.cls("-light"), testId('view-mode') ), ); } function buildWorkspaceDocBlock(home: HomeModel, workspace: Workspace, flashDocId: Observable, viewSettings: ViewSettings) { const renaming = observable(null); function renderDocs(sort: 'date'|'name', view: "list"|"icons") { // Docs are sorted by name in HomeModel, we only re-sort if we want a different order. let docs = workspace.docs; if (sort === 'date') { // Note that timestamps are ISO strings, which can be sorted without conversions. docs = sortBy(docs, (doc) => doc.removedAt || doc.updatedAt).reverse(); } return dom.forEach(docs, doc => { if (view === 'icons') { return dom.update( buildPinnedDoc(home, doc, workspace), testId('doc'), ); } // 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) === doc); const flash = computed((use) => use(flashDocId) === doc.id); return css.docRowWrapper( dom.autoDispose(isRenaming), dom.autoDispose(flash), css.docRowLink( doc.removedAt ? null : urlState().setLinkUrl(docUrl(doc)), dom.hide(isRenaming), css.docRowLink.cls('-no-access', !roles.canView(doc.access)), css.docLeft( css.docName(doc.name, testId('doc-name')), css.docPinIcon('PinSmall', dom.show(doc.isPinned)), doc.public ? css.docPublicIcon('Public', testId('public')) : null, ), css.docRowUpdatedAt( (doc.removedAt ? `Deleted ${getTimeFromNow(doc.removedAt)}` : `Edited ${getTimeFromNow(doc.updatedAt)}`), testId('doc-time') ), (doc.removedAt ? [ // For deleted documents, attach the menu to the entire doc row, and include the // "Dots" icon just to clarify that there are options. menu(() => makeRemovedDocOptionsMenu(home, doc, workspace), {placement: 'bottom-end', parentSelectorToMark: '.' + css.docRowWrapper.className}), css.docMenuTrigger(icon('Dots'), testId('doc-options')), ] : css.docMenuTrigger(icon('Dots'), menu(() => makeDocOptionsMenu(home, doc, renaming), {placement: 'bottom-start', parentSelectorToMark: '.' + css.docRowWrapper.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('doc-options'), ) ), // The flash value may change to true, and then immediately to false. We highlight it // using a transition, and scroll into view, when it turns back to false. transition(flash, { prepare(elem, val) { if (!val) { elem.style.backgroundColor = colors.slate.toString(); } }, run(elem, val) { if (!val) { elem.style.backgroundColor = ''; scrollIntoViewIfNeeded(elem); } }, }) ), css.docRowWrapper.cls('-renaming', isRenaming), dom.maybe(isRenaming, () => css.docRowLink( css.docEditorInput({ initialValue: doc.name || '', save: (val) => doRename(home, doc, val, flashDocId), close: () => renaming.set(null), }, testId('doc-name-editor')), css.docRowUpdatedAt(`Edited ${getTimeFromNow(doc.updatedAt)}`, testId('doc-time')), ), ), testId('doc') ); }); } const {currentSort, currentView} = viewSettings; return [ dom.domComputed( (use) => ({sort: use(currentSort), view: use(currentView)}), (opts) => renderDocs(opts.sort, opts.view)), css.docBlock.cls((use) => '-' + use(currentView)), ]; } async function doRename(home: HomeModel, doc: Document, val: string, flashDocId: Observable) { if (val !== doc.name) { try { await home.renameDoc(doc.id, val); // "Flash" the doc.id: setting and immediately resetting flashDocId will cause on of the // "flash" observables in buildWorkspaceDocBlock() to change to true and immediately to false // (resetting to normal state), triggering a highlight transition. flashDocId.set(doc.id); flashDocId.set(null); } catch (err) { reportError(err); } } } // TODO rebuilds of big page chunks (all workspace) cause screen position to jump, sometimes // losing the doc that was e.g. just renamed. // Exported because also used by the PinnedDocs component. export function makeDocOptionsMenu(home: HomeModel, doc: Document, renaming: Observable) { const org = home.app.currentOrg; const orgAccess: roles.Role|null = org ? org.access : null; function deleteDoc() { confirmModal(`Delete "${doc.name}"?`, 'Delete', () => home.deleteDoc(doc.id, false).catch(reportError), 'Document will be moved to Trash.'); } async function manageUsers() { const api = home.app.api; const user = home.app.currentUser; (await loadUserManager()).showUserManagerModal(api, { permissionData: api.getDocAccess(doc.id), activeEmail: user ? user.email : null, resourceType: 'document', resourceId: doc.id, linkToCopy: urlState().makeUrl(docUrl(doc)), }); } return [ menuItem(() => renaming.set(doc), "Rename", dom.cls('disabled', !roles.canEdit(doc.access)), testId('rename-doc') ), menuItem(() => showMoveDocModal(home, doc), 'Move', // Note that moving the doc requires ACL access on the doc. Moving a doc to a workspace // that confers descendant ACL access could otherwise increase the user's access to the doc. // By requiring the user to have ACL edit access on the doc to move it prevents using this // as a tool to gain greater access control over the doc. // Having ACL edit access on the doc means the user is also powerful enough to remove // the doc, so this is the only access check required to move the doc out of this workspace. // The user must also have edit access on the destination, however, for the move to work. dom.cls('disabled', !roles.canEditAccess(doc.access)), testId('move-doc') ), menuItem(deleteDoc, 'Remove', dom.cls('disabled', !roles.canDelete(doc.access)), testId('delete-doc') ), menuItem(() => home.pinUnpinDoc(doc.id, !doc.isPinned).catch(reportError), doc.isPinned ? "Unpin Document" : "Pin Document", dom.cls('disabled', !roles.canEdit(orgAccess)), testId('pin-doc') ), menuItem(manageUsers, "Manage Users", dom.cls('disabled', !roles.canEditAccess(doc.access)), testId('doc-access') ), ]; } export function makeRemovedDocOptionsMenu(home: HomeModel, doc: Document, workspace: Workspace) { function hardDeleteDoc() { confirmModal(`Permanently Delete "${doc.name}"?`, 'Delete Forever', () => home.deleteDoc(doc.id, true).catch(reportError), 'Document will be permanently deleted.'); } return [ menuItem(() => home.restoreDoc(doc), 'Restore', dom.cls('disabled', !roles.canDelete(doc.access) || !!workspace.removedAt), testId('doc-restore') ), menuItem(hardDeleteDoc, 'Delete Forever', dom.cls('disabled', !roles.canDelete(doc.access)), testId('doc-delete-forever') ), (workspace.removedAt ? menuText('To restore this document, restore the workspace first.') : null ) ]; } function makeRemovedWsOptionsMenu(home: HomeModel, ws: Workspace) { return [ menuItem(() => home.restoreWorkspace(ws), 'Restore', dom.cls('disabled', !roles.canDelete(ws.access)), testId('ws-restore') ), menuItem(() => home.deleteWorkspace(ws.id, true), 'Delete Forever', dom.cls('disabled', !roles.canDelete(ws.access) || ws.docs.length > 0), testId('ws-delete-forever') ), (ws.docs.length > 0 ? menuText('You may delete a workspace forever once it has no documents in it.') : null ) ]; } function showMoveDocModal(home: HomeModel, doc: Document) { saveModal((ctl, owner) => { const selected: Observable = Observable.create(owner, null); const body = css.moveDocModalBody( shadowScroll( dom.forEach(home.workspaces, ws => { if (ws.isSupportWorkspace) { return null; } const isCurrent = Boolean(ws.docs.find(_doc => _doc.id === doc.id)); const isEditable = roles.canEdit(ws.access); const disabled = isCurrent || !isEditable; return css.moveDocListItem( css.moveDocListText(workspaceName(home.app, ws)), isCurrent ? css.moveDocListHintText('Current workspace') : null, !isEditable ? css.moveDocListHintText('Requires edit permissions') : null, css.moveDocListItem.cls('-disabled', disabled), css.moveDocListItem.cls('-selected', (use) => use(selected) === ws.id), dom.on('click', () => disabled || selected.set(ws.id)), testId('dest-ws') ); }) ) ); return { title: `Move ${doc.name} to workspace`, body, saveDisabled: Computed.create(owner, (use) => !use(selected)), saveFunc: async () => !selected.get() || home.moveDoc(doc.id, selected.get()!).catch(reportError), saveLabel: 'Move' }; }); } // Scrolls an element into view only if it's above or below the screen. // TODO move to some common utility function scrollIntoViewIfNeeded(target: Element) { const rect = target.getBoundingClientRect(); if (rect.bottom > window.innerHeight) { target.scrollIntoView(false); } if (rect.top < 0) { target.scrollIntoView(true); } }