From 24fc3a2d0025d8c6b719fda35b3ccbc78379a725 Mon Sep 17 00:00:00 2001 From: George Gevoian Date: Wed, 28 Jul 2021 12:02:06 -0700 Subject: [PATCH] (core) Redesign examples and templates UI Summary: The old Examples and Templates workspace is now a page that pulls templates from a new public Grist Templates org. The All Documents view will pull featured templates from that org, where featured templates are simply pinned documents in Grist Templates. The Examples and Templates page will also show the featured templates, as well as the rest of the available templates organized by category. The categories are equivalent to workspaces in Grist Templates, and are generated dynamically. Test Plan: Browser tests. Reviewers: paulfitz, dsagal Reviewed By: paulfitz, dsagal Subscribers: dsagal, paulfitz, jarek Differential Revision: https://phab.getgrist.com/D2930 --- app/client/models/DocPageModel.ts | 24 ++-- app/client/models/HomeModel.ts | 123 +++++++++++++------ app/client/models/gristUrlState.ts | 20 +++- app/client/ui/AppUI.ts | 1 + app/client/ui/DocMenu.ts | 176 +++++++++++++++++++++------- app/client/ui/DocMenuCss.ts | 32 ++++- app/client/ui/ExampleInfo.ts | 18 +-- app/client/ui/HomeIntro.ts | 93 +-------------- app/client/ui/HomeLeftPane.ts | 15 +-- app/client/ui/PinnedDocs.ts | 67 +++++++---- app/client/ui/TemplateDocs.ts | 71 +++++++++++ app/client/ui/Tools.ts | 3 +- app/client/ui/TopBar.ts | 2 +- app/common/UserAPI.ts | 6 + app/common/gristUrls.ts | 6 +- app/gen-server/ApiServer.ts | 16 +++ app/gen-server/lib/HomeDBManager.ts | 90 ++++++++------ test/nbrowser/gristUtils.ts | 110 ++++++++++++++++- test/nbrowser/testUtils.ts | 26 +--- 19 files changed, 591 insertions(+), 308 deletions(-) create mode 100644 app/client/ui/TemplateDocs.ts diff --git a/app/client/models/DocPageModel.ts b/app/client/models/DocPageModel.ts index 1543ae1b..f6253e70 100644 --- a/app/client/models/DocPageModel.ts +++ b/app/client/models/DocPageModel.ts @@ -28,7 +28,6 @@ import {Holder, Observable, subscribe} from 'grainjs'; export interface DocInfo extends Document { isReadonly: boolean; - isSample: boolean; isPreFork: boolean; isFork: boolean; isRecoveryMode: boolean; @@ -59,7 +58,6 @@ export interface DocPageModel { isRecoveryMode: Observable; userOverride: Observable; isBareFork: Observable; - isSample: Observable; importSources: ImportSource[]; @@ -98,7 +96,6 @@ export class DocPageModelImpl extends Disposable implements DocPageModel { (use, doc) => doc ? doc.isRecoveryMode : false); public readonly userOverride = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.userOverride : null); public readonly isBareFork = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.isBareFork : false); - public readonly isSample = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.isSample : false); public readonly importSources: ImportSource[] = []; @@ -123,7 +120,7 @@ export class DocPageModelImpl extends Disposable implements DocPageModel { this.autoDispose(subscribe(urlState().state, (use, state) => { const urlId = state.doc; - const urlOpenMode = state.mode || 'default'; + const urlOpenMode = state.mode; const linkParameters = state.params?.linkParameters; const docKey = this._getDocKey(state); if (docKey !== this._openerDocKey) { @@ -226,7 +223,7 @@ export class DocPageModelImpl extends Disposable implements DocPageModel { ); } - private async _openDoc(flow: AsyncFlow, urlId: string, urlOpenMode: OpenDocMode, + private async _openDoc(flow: AsyncFlow, urlId: string, urlOpenMode: OpenDocMode | undefined, comparisonUrlId: string | undefined, linkParameters: Record | undefined): Promise { console.log(`DocPageModel _openDoc starting for ${urlId} (mode ${urlOpenMode})` + @@ -326,11 +323,21 @@ function addMenu(importSources: ImportSource[], gristDoc: GristDoc, isReadonly: ]; } -function buildDocInfo(doc: Document, mode: OpenDocMode): DocInfo { +function buildDocInfo(doc: Document, mode: OpenDocMode | undefined): DocInfo { const idParts = parseUrlId(doc.urlId || doc.id); const isFork = Boolean(idParts.forkId || idParts.snapshotId); - const isSample = !isFork && Boolean(doc.workspace.isSupportWorkspace); - const openMode = isSample ? 'fork' : mode; + + let openMode = mode; + if (!openMode) { + if (isFork) { + // Ignore the document 'openMode' setting if the doc is an unsaved fork. + openMode = 'default'; + } else { + // Try to use the document's 'openMode' if it's set. + openMode = doc.options?.openMode ?? 'default'; + } + } + const isPreFork = (openMode === 'fork'); const isBareFork = isFork && idParts.trunkId === NEW_DOCUMENT_CODE; const isEditable = canEdit(doc.access) || isPreFork; @@ -339,7 +346,6 @@ function buildDocInfo(doc: Document, mode: OpenDocMode): DocInfo { isFork, isRecoveryMode: false, // we don't know yet, will learn when doc is opened. userOverride: null, // ditto. - isSample, isPreFork, isBareFork, isReadonly: !isEditable, diff --git a/app/client/models/HomeModel.ts b/app/client/models/HomeModel.ts index 2fd5bf8d..66524c46 100644 --- a/app/client/models/HomeModel.ts +++ b/app/client/models/HomeModel.ts @@ -49,6 +49,7 @@ export interface HomeModel { showIntro: Observable; // set if no docs and we should show intro. singleWorkspace: Observable; // set if workspace name should be hidden. trashWorkspaces: Observable; // only set when viewing trash + templateWorkspaces: Observable; // Only set when viewing templates or all documents. // currentWS is undefined when currentPage is not "workspace" or if currentWSId doesn't exist. currentWS: Observable; @@ -56,6 +57,9 @@ export interface HomeModel { // List of pinned docs to show for currentWS. currentWSPinnedDocs: Observable; + // List of featured templates from templateWorkspaces. + featuredTemplates: Observable; + currentSort: Observable; currentView: Observable; @@ -91,6 +95,7 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings public readonly available = Observable.create(this, false); public readonly singleWorkspace = Observable.create(this, true); public readonly trashWorkspaces = Observable.create(this, []); + public readonly templateWorkspaces = Observable.create(this, []); // Get the workspace details for the workspace with id of currentWSId. public readonly currentWS = Computed.create(this, (use) => @@ -103,6 +108,11 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings return sortBy(docs.filter(doc => doc.isPinned), (doc) => doc.name.toLowerCase()); }); + public readonly featuredTemplates = Computed.create(this, this.templateWorkspaces, (_use, templates) => { + const featuredTemplates = flatten((templates).map(t => t.docs)).filter(t => t.isPinned); + return sortBy(featuredTemplates, (t) => t.name.toLowerCase()); + }); + public readonly currentSort: Observable; public readonly currentView: Observable; @@ -111,7 +121,7 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings public readonly newDocWorkspace = Computed.create(this, this.currentPage, this.currentWS, (use, page, ws) => { // Anonymous user can create docs, but in unsaved mode. if (!this.app.currentValidUser) { return "unsaved"; } - if (page === 'trash') { return null; } + if (['templates', 'trash'].includes(page)) { return null; } const destWS = (page === 'all') ? (use(this.workspaces)[0] || null) : ws; return destWS && roles.canEdit(destWS.access) ? destWS : null; }); @@ -222,48 +232,59 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings // Fetches and updates workspaces, which include contained docs as well. private async _updateWorkspaces() { const org = this._app.currentOrg; - if (org) { - this.loading.set(true); - const promises = Promise.all([ - this._fetchWorkspaces(org.id, false).catch(reportError), - (this.currentPage.get() === 'trash') ? this._fetchWorkspaces(org.id, true).catch(reportError) : null, - ]); - if (await isLongerThan(promises, DELAY_BEFORE_SPINNER_MS)) { - this.loading.set("slow"); - } - const [wss, trashWss] = await promises; - - // bundleChanges defers computeds' evaluations until all changes have been applied. - bundleChanges(() => { - this.workspaces.set(wss || []); - this.trashWorkspaces.set(trashWss || []); - this.loading.set(false); - this.available.set(!!wss); - // Hide workspace name if we are showing a single workspace, and active product - // doesn't allow adding workspaces. It is important to check both conditions because - // * A personal org, where workspaces can't be added, can still have multiple - // workspaces via documents shared by other users. - // * An org with workspace support might happen to just have one workspace right - // now, but it is good to show names to highlight the possibility of adding more. - this.singleWorkspace.set(!!wss && wss.length === 1 && _isSingleWorkspaceMode(this._app)); - }); - } else { + if (!org) { this.workspaces.set([]); this.trashWorkspaces.set([]); + this.templateWorkspaces.set([]); + return; } + + this.loading.set(true); + const currentPage = this.currentPage.get(); + const promises = [ + this._fetchWorkspaces(org.id, false).catch(reportError), // workspaces + currentPage === 'trash' ? this._fetchWorkspaces(org.id, true).catch(reportError) : null, // trash + null // templates + ]; + + const shouldFetchTemplates = ['all', 'templates'].includes(currentPage); + if (shouldFetchTemplates) { + const onlyFeatured = currentPage === 'all'; + promises[2] = this._fetchTemplates(onlyFeatured); + } + + const promise = Promise.all(promises); + if (await isLongerThan(promise, DELAY_BEFORE_SPINNER_MS)) { + this.loading.set("slow"); + } + const [wss, trashWss, templateWss] = await promise; + + // bundleChanges defers computeds' evaluations until all changes have been applied. + bundleChanges(() => { + this.workspaces.set(wss || []); + this.trashWorkspaces.set(trashWss || []); + this.templateWorkspaces.set(templateWss || []); + this.loading.set(false); + this.available.set(!!wss); + // Hide workspace name if we are showing a single (non-support) workspace, and active + // product doesn't allow adding workspaces. It is important to check both conditions because: + // * A personal org, where workspaces can't be added, can still have multiple + // workspaces via documents shared by other users. + // * An org with workspace support might happen to just have one workspace right + // now, but it is good to show names to highlight the possibility of adding more. + const nonSupportWss = Array.isArray(wss) ? wss.filter(ws => !ws.isSupportWorkspace) : null; + this.singleWorkspace.set( + !!nonSupportWss && nonSupportWss.length === 1 && _isSingleWorkspaceMode(this._app) + ); + }); } private async _fetchWorkspaces(orgId: number, forRemoved: boolean) { - let wss: Workspace[] = []; - try { - if (forRemoved) { - wss = await this._app.api.forRemoved().getOrgWorkspaces(orgId); - } else { - wss = await this._app.api.getOrgWorkspaces(orgId); - } - } catch (e) { - return null; + let api = this._app.api; + if (forRemoved) { + api = api.forRemoved(); } + const wss = await api.getOrgWorkspaces(orgId); if (this.isDisposed()) { return null; } for (const ws of wss) { ws.docs = sortBy(ws.docs, (doc) => doc.name.toLowerCase()); @@ -274,6 +295,13 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings doc.removedAt = doc.removedAt || ws.removedAt; } } + + // Populate doc.workspace, which is used by DocMenu/PinnedDocs and + // is useful in cases where there are multiple workspaces containing + // pinned documents that need to be sorted in alphabetical order. + for (const doc of ws.docs) { + doc.workspace = doc.workspace ?? ws; + } } // Sort workspaces such that workspaces from the personal orgs of others // come after workspaces from our own personal org; workspaces from personal @@ -286,6 +314,27 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings ws.name.toLowerCase()]); } + private async _fetchTemplates(onlyFeatured: boolean) { + let templateWss: Workspace[] = []; + try { + templateWss = await this._app.api.getTemplates(onlyFeatured); + } catch { + // If the org doesn't exist (404), return nothing and don't report error to user. + return null; + } + if (this.isDisposed()) { return null; } + for (const ws of templateWss) { + for (const doc of ws.docs) { + // Populate doc.workspace, which is used by DocMenu/PinnedDocs and + // is useful in cases where there are multiple workspaces containing + // pinned documents that need to be sorted in alphabetical order. + doc.workspace = doc.workspace ?? ws; + } + ws.docs = sortBy(ws.docs, (doc) => doc.name.toLowerCase()); + } + return templateWss; + } + private async _saveUserOrgPref(key: K, value: UserOrgPrefs[K]) { const org = this._app.currentOrg; if (org) { @@ -315,7 +364,7 @@ function getViewPrefDefault(workspaces: Workspace[]): ViewPref { * Create observables for per-workspace view settings which default to org-wide settings, but can * be changed independently and persisted in localStorage. */ -export function makeLocalViewSettings(home: HomeModel|null, wsId: number|'trash'|'all'): ViewSettings { +export function makeLocalViewSettings(home: HomeModel|null, wsId: number|'trash'|'all'|'templates'): ViewSettings { const userId = home?.app.currentUser?.id || 0; const sort = localStorageObs(`u=${userId}:ws=${wsId}:sort`); const view = localStorageObs(`u=${userId}:ws=${wsId}:view`); diff --git a/app/client/models/gristUrlState.ts b/app/client/models/gristUrlState.ts index 443bca75..44e810aa 100644 --- a/app/client/models/gristUrlState.ts +++ b/app/client/models/gristUrlState.ts @@ -37,9 +37,23 @@ export function urlState(): UrlState { } let _urlState: UrlState|undefined; -// Returns url parameters appropriate for the specified document, specifically `doc` and `slug`. -export function docUrl(doc: Document): IGristUrlState { - return {doc: doc.urlId || doc.id, slug: getSlugIfNeeded(doc)}; +/** + * Returns url parameters appropriate for the specified document. + * + * In addition to setting `doc` and `slug`, it sets additional parameters + * from `params` if any are supplied. + */ +export function docUrl(doc: Document, params: {org?: string} = {}): IGristUrlState { + const state: IGristUrlState = { + doc: doc.urlId || doc.id, + slug: getSlugIfNeeded(doc), + }; + + // TODO: Get non-sample documents with `org` set to fully work (a few tests fail). + if (params.org) { + state.org = params.org; + } + return state; } // Returns the home page for the current org. diff --git a/app/client/ui/AppUI.ts b/app/client/ui/AppUI.ts index 2b90e816..0cf80cd9 100644 --- a/app/client/ui/AppUI.ts +++ b/app/client/ui/AppUI.ts @@ -85,6 +85,7 @@ function pagePanelsHome(owner: IDisposableOwner, appModel: AppModel) { owner.autoDispose(subscribe(pageModel.currentPage, pageModel.currentWS, (use, page, ws) => { const name = ( page === 'trash' ? 'Trash' : + page === 'templates' ? 'Examples & Templates' : ws ? ws.name : appModel.currentOrgName ); document.title = `${name} - Grist`; diff --git a/app/client/ui/DocMenu.ts b/app/client/ui/DocMenu.ts index 6ba8358b..683d7c38 100644 --- a/app/client/ui/DocMenu.ts +++ b/app/client/ui/DocMenu.ts @@ -9,7 +9,7 @@ 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 {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'; @@ -25,6 +25,9 @@ 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'); +import {buildTemplateDocs} from 'app/client/ui/TemplateDocs'; +import {localStorageBoolObs} from 'app/client/lib/localStorageObs'; +import {bigBasicButton} from 'app/client/ui2018/buttons'; const testId = makeTestId('test-dm-'); @@ -56,17 +59,28 @@ function createLoadedDocMenu(home: HomeModel) { ([page, workspace, showIntro]) => { const viewSettings: ViewSettings = page === 'trash' ? makeLocalViewSettings(home, 'trash') : + page === 'templates' ? makeLocalViewSettings(home, 'templates') : 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)}), + // Hide the sort option only when showing intro. + buildPrefs(viewSettings, {hideSort: showIntro}), // 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), + createPinnedDocs(home, home.currentWSPinnedDocs), + ]), + + // Build the featured templates dom if on the Examples & Templates page. + dom.maybe((use) => page === 'templates' && use(home.featuredTemplates).length > 0, () => [ + css.featuredTemplatesHeader( + css.featuredTemplatesIcon('Idea'), + 'Featured', + testId('featured-templates-header') + ), + createPinnedDocs(home, home.featuredTemplates, true), ]), dom.maybe(home.available, () => [ @@ -75,6 +89,10 @@ function createLoadedDocMenu(home: HomeModel) { css.docListHeader( ( page === 'all' ? 'All Documents' : + page === 'templates' ? + dom.domComputed(use => use(home.featuredTemplates).length > 0, (hasFeaturedTemplates) => + hasFeaturedTemplates ? 'More Examples & Templates' : 'Examples & Templates' + ) : page === 'trash' ? 'Trash' : workspace && [css.docHeaderIcon('Folder'), workspaceName(home.app, workspace)] ), @@ -86,6 +104,7 @@ function createLoadedDocMenu(home: HomeModel) { dom('div', showIntro ? buildHomeIntro(home) : null, buildAllDocsBlock(home, home.workspaces, showIntro, flashDocId, viewSettings), + shouldShowTemplates(home, showIntro) ? buildAllDocsTemplates(home, viewSettings) : null, ) : (page === 'trash') ? dom('div', @@ -95,14 +114,16 @@ function createLoadedDocMenu(home: HomeModel) { ), 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') + (page === 'templates') ? + dom('div', + buildAllTemplates(home, home.templateWorkspaces, viewSettings) + ) : + workspace && !workspace.isSupportWorkspace ? + css.docBlock( + buildWorkspaceDocBlock(home, workspace, flashDocId, viewSettings), + testId('doc-block') + ) : + css.docBlock('Workspace not found') ) ]), ]; @@ -115,44 +136,99 @@ 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), - ), + // Don't show the support workspace -- examples/templates are now retrieved from a special org. + // TODO: Remove once support workspaces are removed from the backend. + if (ws.isSupportWorkspace) { return null; } + // 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}) - ), + (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))), - dom.hide((use) => Boolean(getWorkspaceInfo(home.app, ws).isDefault && - use(home.singleWorkspace))), + testId('ws-header'), + ), + buildWorkspaceDocBlock(home, ws, flashDocId, viewSettings), + testId('doc-block') + ); + }); +} - testId('ws-header'), +/** + * Builds the collapsible examples and templates section at the bottom of + * the All Documents page. + * + * If there are no featured templates, builds nothing. + */ +function buildAllDocsTemplates(home: HomeModel, viewSettings: ViewSettings) { + return dom.domComputed(home.featuredTemplates, templates => { + if (templates.length === 0) { return null; } + + const hideTemplatesObs = localStorageBoolObs('hide-examples'); + return css.templatesDocBlock( + dom.autoDispose(hideTemplatesObs), + css.templatesHeader( + 'Examples & Templates', + dom.domComputed(hideTemplatesObs, (collapsed) => + collapsed ? css.templatesHeaderIcon('Expand') : css.templatesHeaderIcon('Collapse') ), - buildWorkspaceDocBlock(home, ws, flashDocId, viewSettings), - testId('doc-block') - ); - } + dom.on('click', () => hideTemplatesObs.set(!hideTemplatesObs.get())), + testId('all-docs-templates-header'), + ), + dom.maybe((use) => !use(hideTemplatesObs), () => [ + buildTemplateDocs(home, templates, viewSettings), + bigBasicButton( + 'Discover More Templates', + urlState().setLinkUrl({homePage: 'templates'}), + testId('all-docs-templates-discover-more'), + ) + ]), + css.docBlock.cls((use) => '-' + use(home.currentView)), + testId('all-docs-templates'), + ); + }); +} + +/** + * Builds all templates. + * + * Templates are grouped by workspace, with each workspace representing a category of + * templates. Categories are rendered as collapsible menus, and the contained templates + * can be viewed in both icon and list view. + * + * Used on the Examples & Templates below the featured templates. + */ +function buildAllTemplates(home: HomeModel, templateWorkspaces: Observable, viewSettings: ViewSettings) { + return dom.forEach(templateWorkspaces, workspace => { + return css.templatesDocBlock( + css.templateBlockHeader( + css.wsLeft( + css.docHeaderIcon('Folder'), + workspace.name, + ), + testId('templates-header'), + ), + buildTemplateDocs(home, workspace.docs, viewSettings), + css.docBlock.cls((use) => '-' + use(viewSettings.currentView)), + testId('templates'), + ); }); } @@ -430,3 +506,13 @@ function scrollIntoViewIfNeeded(target: Element) { target.scrollIntoView(true); } } + +/** + * Returns true if templates should be shown in All Documents. + */ +function shouldShowTemplates(home: HomeModel, showIntro: boolean): boolean { + const org = home.app.currentOrg; + const isPersonalOrg = Boolean(org && org.owner); + // Show templates for all personal orgs, and for non-personal orgs when showing intro. + return isPersonalOrg || showIntro; +} diff --git a/app/client/ui/DocMenuCss.ts b/app/client/ui/DocMenuCss.ts index d52d68fa..db56c962 100644 --- a/app/client/ui/DocMenuCss.ts +++ b/app/client/ui/DocMenuCss.ts @@ -31,6 +31,15 @@ export const docListHeader = styled('div', ` font-weight: ${vars.headerControlTextWeight}; `); +export const templatesHeader = styled(docListHeader, ` + cursor: pointer; +`); + +export const featuredTemplatesHeader = styled(docListHeader, ` + display: flex; + align-items: center; +`); + export const docBlock = styled('div', ` max-width: 550px; min-width: 300px; @@ -41,6 +50,10 @@ export const docBlock = styled('div', ` } `); +export const templatesDocBlock = styled(docBlock, ` + margin-top: 32px; +`); + export const docHeaderIconDark = styled(icon, ` margin-right: 8px; margin-top: -3px; @@ -50,7 +63,18 @@ export const docHeaderIcon = styled(docHeaderIconDark, ` --icon-color: ${colors.slate}; `); -export const docBlockHeaderLink = styled('a', ` +export const featuredTemplatesIcon = styled(icon, ` + margin-right: 8px; + width: 20px; + height: 20px; +`); + +export const templatesHeaderIcon = styled(docHeaderIcon, ` + width: 24px; + height: 24px; +`); + +const docBlockHeader = ` display: flex; align-items: center; height: 40px; @@ -65,7 +89,11 @@ export const docBlockHeaderLink = styled('a', ` outline: none; color: inherit; } -`); +`; + +export const docBlockHeaderLink = styled('a', docBlockHeader); + +export const templateBlockHeader = styled('div', docBlockHeader); export const wsLeft = styled('div', ` flex: 1 0 50%; diff --git a/app/client/ui/ExampleInfo.ts b/app/client/ui/ExampleInfo.ts index 74689f32..f26d2130 100644 --- a/app/client/ui/ExampleInfo.ts +++ b/app/client/ui/ExampleInfo.ts @@ -1,13 +1,9 @@ -import {DomContents} from 'grainjs'; - export interface IExampleInfo { id: number; - matcher: RegExp; + urlId: string; title: string; imgUrl: string; tutorialUrl: string; - bgColor: string; - desc: () => DomContents; welcomeCard: WelcomeCard; } @@ -19,12 +15,10 @@ interface WelcomeCard { export const examples: IExampleInfo[] = [{ id: 1, // Identifies the example in UserPrefs.seenExamples - matcher: /Lightweight CRM/, + urlId: 'lightweight-crm', title: 'Lightweight CRM', imgUrl: 'https://www.getgrist.com/themes/grist/assets/images/use-cases/lightweight-crm.png', tutorialUrl: 'https://support.getgrist.com/lightweight-crm/', - bgColor: '#FDEDD7', - desc: () => 'CRM template and example for linking data, and creating productive layouts.', welcomeCard: { title: 'Welcome to the Lightweight CRM template', text: 'Check out our related tutorial for how to link data, and create ' + @@ -33,12 +27,10 @@ export const examples: IExampleInfo[] = [{ }, }, { id: 2, // Identifies the example in UserPrefs.seenExamples - matcher: /Investment Research/, + urlId: 'investment-research', title: 'Investment Research', imgUrl: 'https://www.getgrist.com/themes/grist/assets/images/use-cases/data-visualization.png', tutorialUrl: 'https://support.getgrist.com/investment-research/', - bgColor: '#CEF2E4', - desc: () => 'Example for analyzing and visualizing with summary tables and linked charts.', welcomeCard: { title: 'Welcome to the Investment Research template', text: 'Check out our related tutorial to learn how to create summary tables and charts, ' + @@ -47,12 +39,10 @@ export const examples: IExampleInfo[] = [{ }, }, { id: 3, // Identifies the example in UserPrefs.seenExamples - matcher: /Afterschool Program/, + urlId: 'afterschool-program', title: 'Afterschool Program', imgUrl: 'https://www.getgrist.com/themes/grist/assets/images/use-cases/business-management.png', tutorialUrl: 'https://support.getgrist.com/afterschool-program/', - bgColor: '#D7E3F5', - desc: () => 'Example for how to model business data, use formulas, and manage complexity.', welcomeCard: { title: 'Welcome to the Afterschool Program template', text: 'Check out our related tutorial for how to model business data, use formulas, ' + diff --git a/app/client/ui/HomeIntro.ts b/app/client/ui/HomeIntro.ts index ead15afb..b347e34e 100644 --- a/app/client/ui/HomeIntro.ts +++ b/app/client/ui/HomeIntro.ts @@ -1,16 +1,12 @@ -import {localStorageBoolObs} from 'app/client/lib/localStorageObs'; -import {docUrl, getLoginOrSignupUrl, urlState} from 'app/client/models/gristUrlState'; -import {HomeModel, ViewSettings} from 'app/client/models/HomeModel'; +import {getLoginOrSignupUrl} from 'app/client/models/gristUrlState'; +import {HomeModel} from 'app/client/models/HomeModel'; import * as css from 'app/client/ui/DocMenuCss'; -import {examples} from 'app/client/ui/ExampleInfo'; import {createDocAndOpen, importDocAndOpen} from 'app/client/ui/HomeLeftPane'; -import {buildPinnedDoc} from 'app/client/ui/PinnedDocs'; import {bigBasicButton} from 'app/client/ui2018/buttons'; -import {colors, mediaXSmall, testId} from 'app/client/ui2018/cssVars'; +import {mediaXSmall, testId} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; import {cssLink} from 'app/client/ui2018/links'; import {commonUrls} from 'app/common/gristUrls'; -import {Document, Workspace} from 'app/common/UserAPI'; import {dom, DomContents, DomCreateFunc, styled} from 'grainjs'; export function buildHomeIntro(homeModel: HomeModel): DomContents { @@ -71,59 +67,6 @@ function makeCreateButtons(homeModel: HomeModel) { ); } -export function buildExampleList(home: HomeModel, workspace: Workspace, viewSettings: ViewSettings) { - const hideExamplesObs = localStorageBoolObs('hide-examples'); - return cssDocBlock( - dom.autoDispose(hideExamplesObs), - cssDocBlockHeader(css.docBlockHeaderLink.cls(''), css.docHeaderIcon('FieldTable'), 'Examples & Templates', - dom.domComputed(hideExamplesObs, (collapsed) => - collapsed ? cssCollapseIcon('Expand') : cssCollapseIcon('Collapse') - ), - dom.on('click', () => hideExamplesObs.set(!hideExamplesObs.get())), - testId('examples-header'), - ), - dom.maybe((use) => !use(hideExamplesObs), () => _buildExampleListDocs(home, workspace, viewSettings)), - css.docBlock.cls((use) => '-' + use(home.currentView)), - testId('examples-list'), - ); -} - -export function buildExampleListBody(home: HomeModel, workspace: Workspace, viewSettings: ViewSettings) { - return cssDocBlock( - _buildExampleListDocs(home, workspace, viewSettings), - css.docBlock.cls((use) => '-' + use(viewSettings.currentView)), - testId('examples-body'), - ); -} - -function _buildExampleListDocs(home: HomeModel, workspace: Workspace, viewSettings: ViewSettings) { - return [ - cssParagraph( - 'Explore these examples, read tutorials based on them, or use any of them as a template.', - testId('examples-desc'), - ), - dom.domComputed(viewSettings.currentView, (view) => - dom.forEach(workspace.docs, doc => buildExampleItem(doc, home, workspace, view)) - ), - ]; -} - -function buildExampleItem(doc: Document, home: HomeModel, workspace: Workspace, view: 'list'|'icons') { - const ex = examples.find((e) => e.matcher.test(doc.name)); - if (view === 'icons') { - return buildPinnedDoc(home, doc, workspace, ex); - } else { - return css.docRowWrapper( - cssDocRowLink( - urlState().setLinkUrl(docUrl(doc)), - cssDocName(ex?.title || doc.name, testId('examples-doc-name')), - ex ? cssItemDetails(ex.desc, testId('examples-desc')) : null, - ), - testId('examples-doc'), - ); - } -} - const cssIntroSplit = styled(css.docBlock, ` display: flex; align-items: center; @@ -171,36 +114,6 @@ const cssBtnIcon = styled(icon, ` margin-right: 8px; `); -const cssDocRowLink = styled(css.docRowLink, ` - display: block; - height: unset; - line-height: 1.6; - padding: 8px 0; -`); - -const cssDocName = styled(css.docName, ` - margin: 0 16px; -`); - -const cssItemDetails = styled('div', ` - margin: 0 16px; - line-height: 1.6; - color: ${colors.slate}; -`); - -const cssDocBlock = styled(css.docBlock, ` - margin-top: 32px; -`); - -const cssDocBlockHeader = styled('div', ` - cursor: pointer; -`); - -const cssCollapseIcon = styled(css.docHeaderIcon, ` - margin-left: 8px; -`); - - // Helper to create an image scaled down to half of its intrinsic size. // Based on https://stackoverflow.com/a/25026615/328565 const cssIntroImage: DomCreateFunc = diff --git a/app/client/ui/HomeLeftPane.ts b/app/client/ui/HomeLeftPane.ts index b47939e5..7991e53d 100644 --- a/app/client/ui/HomeLeftPane.ts +++ b/app/client/ui/HomeLeftPane.ts @@ -19,13 +19,10 @@ import {computed, dom, DomElementArg, Observable, observable, styled} from 'grai export function createHomeLeftPane(leftPanelOpen: Observable, home: HomeModel) { const creating = observable(false); const renaming = observable(null); - const samplesWorkspace = computed((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', @@ -96,13 +93,11 @@ export function createHomeLeftPane(leftPanelOpen: Observable, home: Hom ) )), 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) === "templates"), + cssPageLink(cssPageIcon('FieldTable'), cssLinkText("Examples & Templates"), + urlState().setLinkUrl({homePage: "templates"}), + testId('dm-templates-page'), ), ), cssPageEntry( diff --git a/app/client/ui/PinnedDocs.ts b/app/client/ui/PinnedDocs.ts index 8e41c920..34f522aa 100644 --- a/app/client/ui/PinnedDocs.ts +++ b/app/client/ui/PinnedDocs.ts @@ -1,14 +1,13 @@ import {docUrl, urlState} from 'app/client/models/gristUrlState'; import {getTimeFromNow, HomeModel} from 'app/client/models/HomeModel'; import {makeDocOptionsMenu, makeRemovedDocOptionsMenu} from 'app/client/ui/DocMenu'; -import {IExampleInfo} from 'app/client/ui/ExampleInfo'; import {transientInput} from 'app/client/ui/transientInput'; import {colors, vars} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; import {menu} from 'app/client/ui2018/menus'; import * as roles from 'app/common/roles'; import {Document, Workspace} from 'app/common/UserAPI'; -import {computed, dom, makeTestId, observable, styled} from 'grainjs'; +import {computed, dom, makeTestId, Observable, observable, styled} from 'grainjs'; const testId = makeTestId('test-dm-'); @@ -18,9 +17,9 @@ const testId = makeTestId('test-dm-'); * * Used only by DocMenu. */ -export function createPinnedDocs(home: HomeModel) { +export function createPinnedDocs(home: HomeModel, docs: Observable, isExample = false) { return pinnedDocList( - dom.forEach(home.currentWSPinnedDocs, doc => buildPinnedDoc(home, doc, doc.workspace)), + dom.forEach(docs, doc => buildPinnedDoc(home, doc, doc.workspace, isExample)), testId('pinned-doc-list'), ); } @@ -29,24 +28,24 @@ export function createPinnedDocs(home: HomeModel) { * Build a single doc card with a preview and name. A misnomer because it's now used not only for * pinned docs, but also for the thumnbails (aka "icons") view mode. */ -export function buildPinnedDoc(home: HomeModel, doc: Document, workspace: Workspace, - example?: IExampleInfo): HTMLElement { +export function buildPinnedDoc(home: HomeModel, doc: Document, workspace: Workspace, isExample = false): HTMLElement { const renaming = observable(null); const isRenamingDoc = computed((use) => use(renaming) === doc); - const docTitle = example?.title || doc.name; return pinnedDocWrapper( dom.autoDispose(isRenamingDoc), dom.domComputed(isRenamingDoc, (isRenaming) => pinnedDoc( - isRenaming || doc.removedAt ? null : urlState().setLinkUrl(docUrl(doc)), + isRenaming || doc.removedAt ? + null : + urlState().setLinkUrl(docUrl(doc, isExample ? {org: workspace.orgDomain} : undefined)), pinnedDoc.cls('-no-access', !roles.canView(doc.access)), pinnedDocPreview( - example?.bgColor ? dom.style('background-color', example.bgColor) : null, - (example?.imgUrl ? - cssImage({src: example.imgUrl}) : - [docInitials(docTitle), pinnedDocThumbnail()] + (doc.options?.icon ? + cssImage({src: doc.options.icon}) : + [docInitials(doc.name), pinnedDocThumbnail()] ), - (doc.public && !example ? cssPublicIcon('PublicFilled', testId('public')) : null), + (doc.public && !isExample ? cssPublicIcon('PublicFilled', testId('public')) : null), + pinnedDocPreview.cls('-with-icon', Boolean(doc.options?.icon)), ), pinnedDocFooter( (isRenaming ? @@ -57,21 +56,23 @@ export function buildPinnedDoc(home: HomeModel, doc: Document, workspace: Worksp }, testId('doc-name-editor')) : pinnedDocTitle( - dom.text(docTitle), + dom.text(doc.name), testId('pinned-doc-name'), // Mostly for the sake of tests, allow .test-dm-pinned-doc-name to find documents in // either 'list' or 'icons' views. testId('doc-name') ) ), - cssPinnedDocDesc( - example?.desc || capitalizeFirst(getTimeFromNow(doc.removedAt || doc.updatedAt)), - testId('pinned-doc-desc') - ) + doc.options?.description ? + cssPinnedDocDesc(doc.options.description, testId('pinned-doc-desc')) : + cssPinnedDocTimestamp( + capitalizeFirst(getTimeFromNow(doc.removedAt || doc.updatedAt)), + testId('pinned-doc-desc') + ) ) ) ), - example ? null : (doc.removedAt ? + isExample ? null : (doc.removedAt ? [ // For deleted documents, attach the menu to the entire doc icon, and include the // "Dots" icon just to clarify that there are options. @@ -108,7 +109,7 @@ const pinnedDocList = styled('div', ` margin: 0 0 28px 0; `); -export const pinnedDocWrapper = styled('div', ` +const pinnedDocWrapper = styled('div', ` display: inline-block; flex: 0 0 auto; position: relative; @@ -156,6 +157,10 @@ const pinnedDocPreview = styled('div', ` .${pinnedDoc.className}-no-access > & { opacity: 0.8; } + + &-with-icon { + padding: 0; + } `); const pinnedDocThumbnail = styled('div', ` @@ -216,7 +221,7 @@ const pinnedDocTitle = styled('div', ` text-overflow: ellipsis; `); -export const pinnedDocEditorInput = styled(transientInput, ` +const pinnedDocEditorInput = styled(transientInput, ` margin: 16px 16px 0px 16px; font-weight: bold; min-width: 0px; @@ -231,15 +236,29 @@ export const pinnedDocEditorInput = styled(transientInput, ` background-color: ${colors.mediumGrey}; `); -const cssPinnedDocDesc = styled('div', ` +const cssPinnedDocTimestamp = styled('div', ` margin: 8px 16px 16px 16px; color: ${colors.slate}; `); +const cssPinnedDocDesc = styled(cssPinnedDocTimestamp, ` + margin: 8px 16px 16px 16px; + color: ${colors.slate}; + height: 48px; + -webkit-box-orient: vertical; + display: -webkit-box; + -webkit-line-clamp: 3; + overflow: hidden; + text-overflow: ellipsis; + word-break: break-word; +`); + const cssImage = styled('img', ` position: relative; - max-height: 100%; - max-width: 100%; + background-color: ${colors.light}; + height: 100%; + width: 100%; + object-fit: scale-down; `); const cssPublicIcon = styled(icon, ` diff --git a/app/client/ui/TemplateDocs.ts b/app/client/ui/TemplateDocs.ts new file mode 100644 index 00000000..0aa5bd6c --- /dev/null +++ b/app/client/ui/TemplateDocs.ts @@ -0,0 +1,71 @@ +import {docUrl, urlState} from 'app/client/models/gristUrlState'; +import {colors} from 'app/client/ui2018/cssVars'; +import {Document, Workspace} from 'app/common/UserAPI'; +import {dom, makeTestId, styled} from 'grainjs'; +import {HomeModel, ViewSettings} from 'app/client/models/HomeModel'; +import * as css from 'app/client/ui/DocMenuCss'; +import {buildPinnedDoc} from 'app/client/ui/PinnedDocs'; +import sortBy = require('lodash/sortBy'); + +const testId = makeTestId('test-dm-'); + +/** + * Builds all `templateDocs` according to the specified `viewSettings`. + */ + export function buildTemplateDocs(home: HomeModel, templateDocs: Document[], viewSettings: ViewSettings) { + const {currentView, currentSort} = viewSettings; + return dom.domComputed((use) => [use(currentView), use(currentSort)] as const, (opts) => { + const [view, sort] = opts; + // Template docs are sorted by name in HomeModel. We only re-sort if we want a different order. + let sortedDocs = templateDocs; + if (sort === 'date') { + sortedDocs = sortBy(templateDocs, (d) => d.removedAt || d.updatedAt).reverse(); + } + return cssTemplateDocs(dom.forEach(sortedDocs, d => buildTemplateDoc(home, d, d.workspace, view))); + }); +} + +/** + * Build a single template doc according to `view`. + * + * If `view` is set to 'list', the template will be rendered + * as a clickable row that includes a title and description. + * + * If `view` is set to 'icons', the template will be rendered + * as a clickable tile that includes a title, image and description. + */ +function buildTemplateDoc(home: HomeModel, doc: Document, workspace: Workspace, view: 'list'|'icons') { + if (view === 'icons') { + return buildPinnedDoc(home, doc, workspace, true); + } else { + return css.docRowWrapper( + cssDocRowLink( + urlState().setLinkUrl(docUrl(doc, {org: workspace.orgDomain})), + cssDocName(doc.name, testId('template-doc-title')), + doc.options?.description ? cssDocRowDetails(doc.options.description, testId('template-doc-description')) : null, + ), + testId('template-doc'), + ); + } +} + +const cssDocRowLink = styled(css.docRowLink, ` + display: block; + height: unset; + line-height: 1.6; + padding: 8px 0; +`); + +const cssDocName = styled(css.docName, ` + margin: 0 16px; +`); + +const cssDocRowDetails = styled('div', ` + margin: 0 16px; + line-height: 1.6; + color: ${colors.slate}; +`); + +const cssTemplateDocs = styled('div', ` + margin-bottom: 16px; +`); diff --git a/app/client/ui/Tools.ts b/app/client/ui/Tools.ts index 98cd19ac..21733f65 100644 --- a/app/client/ui/Tools.ts +++ b/app/client/ui/Tools.ts @@ -66,8 +66,7 @@ export function tools(owner: Disposable, gristDoc: GristDoc, leftPanelOpen: Obse ), cssSpacer(), dom.maybe(gristDoc.docPageModel.currentDoc, (doc) => { - if (!doc.workspace.isSupportWorkspace) { return null; } - const ex = examples.find((e) => e.matcher.test(doc.name)); + const ex = examples.find(e => e.urlId === doc.urlId); if (!ex || !ex.tutorialUrl) { return null; } return cssPageEntry( cssPageLink(cssPageIcon('Page'), cssLinkText('How-to Tutorial'), testId('tutorial'), diff --git a/app/client/ui/TopBar.ts b/app/client/ui/TopBar.ts index 6e12f27e..8bad3a47 100644 --- a/app/client/ui/TopBar.ts +++ b/app/client/ui/TopBar.ts @@ -49,7 +49,7 @@ export function createTopBarDoc(owner: MultiHolder, appModel: AppModel, pageMode isFork: pageModel.isFork, isRecoveryMode: pageModel.isRecoveryMode, userOverride: pageModel.userOverride, - isFiddle: Computed.create(owner, (use) => use(pageModel.isPrefork) && !use(pageModel.isSample)), + isFiddle: Computed.create(owner, (use) => use(pageModel.isPrefork)), isSnapshot: Computed.create(owner, doc, (use, _doc) => Boolean(_doc && _doc.idParts.snapshotId)), isPublic: Computed.create(owner, doc, (use, _doc) => Boolean(_doc && _doc.public)), }) diff --git a/app/common/UserAPI.ts b/app/common/UserAPI.ts index 11247a1c..ff81baea 100644 --- a/app/common/UserAPI.ts +++ b/app/common/UserAPI.ts @@ -94,6 +94,7 @@ export interface Workspace extends WorkspaceProperties { id: number; docs: Document[]; org: Organization; + orgDomain?: string; access: roles.Role; owner?: FullUser; // Set when workspaces are in the "docs" pseudo-organization, // assembled from multiple personal organizations. @@ -270,6 +271,7 @@ export interface UserAPI { getWorkspace(workspaceId: number): Promise; getOrg(orgId: number|string): Promise; getOrgWorkspaces(orgId: number|string): Promise; + getTemplates(onlyFeatured?: boolean): Promise; getDoc(docId: string): Promise; newOrg(props: Partial): Promise; newWorkspace(props: Partial, orgId: number|string): Promise; @@ -402,6 +404,10 @@ export class UserAPIImpl extends BaseAPI implements UserAPI { { method: 'GET' }); } + public async getTemplates(onlyFeatured: boolean = false): Promise { + return this.requestJson(`${this._url}/api/templates?onlyFeatured=${onlyFeatured ? 1 : 0}`, { method: 'GET' }); + } + public async getDoc(docId: string): Promise { return this.requestJson(`${this._url}/api/docs/${docId}`, { method: 'GET' }); } diff --git a/app/common/gristUrls.ts b/app/common/gristUrls.ts index ffbcd5a9..05ae49e8 100644 --- a/app/common/gristUrls.ts +++ b/app/common/gristUrls.ts @@ -11,7 +11,7 @@ export type IDocPage = number | 'new' | 'code' | 'acl'; // What page to show in the user's home area. Defaults to 'workspace' if a workspace is set, and // to 'all' otherwise. -export const HomePage = StringUnion('all', 'workspace', 'trash'); +export const HomePage = StringUnion('all', 'workspace', 'templates', 'trash'); export type IHomePage = typeof HomePage.type; export const WelcomePage = StringUnion('user', 'info', 'teams', 'signup', 'verify'); @@ -174,8 +174,8 @@ export function encodeUrl(gristConfig: Partial, if (state.docPage) { parts.push(`/p/${state.docPage}`); } - } else { - if (state.homePage === 'trash') { parts.push('p/trash'); } + } else if (state.homePage === 'trash' || state.homePage === 'templates') { + parts.push(`p/${state.homePage}`); } if (state.billing) { diff --git a/app/gen-server/ApiServer.ts b/app/gen-server/ApiServer.ts index 394c5f39..4e874c33 100644 --- a/app/gen-server/ApiServer.ts +++ b/app/gen-server/ApiServer.ts @@ -17,6 +17,11 @@ import {Request} from 'express'; import {User} from './entity/User'; import {HomeDBManager} from './lib/HomeDBManager'; +// Special public organization that contains examples and templates. +const TEMPLATES_ORG_DOMAIN = process.env.GRIST_ID_PREFIX ? + `templates-${process.env.GRIST_ID_PREFIX}` : + 'templates'; + // exposed for testing purposes export const Deps = { apiKeyGenerator: () => crypto.randomBytes(20).toString('hex') @@ -222,6 +227,17 @@ export class ApiServer { return sendReply(req, res, query); })); + // GET /api/templates/ + // Get all templates (or only featured templates if `onlyFeatured` is set). + this._app.get('/api/templates/', expressWrap(async (req, res) => { + const onlyFeatured = isParameterOn(req.query.onlyFeatured); + const query = await this._dbManager.getOrgWorkspaces( + {...getScope(req), showOnlyPinned: onlyFeatured}, + TEMPLATES_ORG_DOMAIN + ); + return sendReply(req, res, query); + })); + // PATCH /api/docs/:did // Update the specified doc. this._app.patch('/api/docs/:did', expressWrap(async (req, res) => { diff --git a/app/gen-server/lib/HomeDBManager.ts b/app/gen-server/lib/HomeDBManager.ts index 2ace1d53..55089937 100644 --- a/app/gen-server/lib/HomeDBManager.ts +++ b/app/gen-server/lib/HomeDBManager.ts @@ -114,6 +114,7 @@ export interface Scope { users?: AvailableUsers; // Set if available identities. includeSupport?: boolean; // When set, include sample resources shared by support to scope. showRemoved?: boolean; // When set, query is scoped to removed workspaces/docs. + showOnlyPinned?: boolean; // When set, query is scoped only to pinned docs. showAll?: boolean; // When set, return both removed and regular resources. specialPermit?: Permit; // When set, extra rights are granted on a specific resource. } @@ -764,49 +765,16 @@ export class HomeDBManager extends EventEmitter { */ public async getOrgWorkspaces(scope: Scope, orgKey: string|number, options: QueryOptions = {}): Promise> { - const {userId} = scope; - const supportId = this._specialUserIds[SUPPORT_EMAIL]; - let queryBuilder = this.org(scope, orgKey, options) - .leftJoinAndSelect('orgs.workspaces', 'workspaces') - .leftJoinAndSelect('workspaces.docs', 'docs', this._onDoc(scope)) - .leftJoin('orgs.billingAccount', 'account') - .leftJoin('account.product', 'product') - .addSelect('product.features') - .addSelect('product.id') - .addSelect('account.id') - // order the support org (aka Samples/Examples) after other ones. - .orderBy('coalesce(orgs.owner_id = :supportId, false)') - .setParameter('supportId', supportId) - .addOrderBy('(orgs.owner_id = :userId)', 'DESC') - .setParameter('userId', userId) - // For consistency of results, particularly in tests, order workspaces by name. - .addOrderBy('workspaces.name') - .addOrderBy('docs.created_at') - .leftJoinAndSelect('orgs.owner', 'org_users'); - // If merged org, we need to take some special steps. - if (this.isMergedOrg(orgKey)) { - // Add information about owners of personal orgs. - queryBuilder = queryBuilder - .leftJoinAndSelect('org_users.logins', 'org_logins'); - // Add a direct, efficient filter to remove irrelevant personal orgs from consideration. - queryBuilder = this._filterByOrgGroups(queryBuilder, userId); - // The anonymous user is a special case; include only examples from support user. - if (userId === this.getAnonymousUserId()) { - queryBuilder = queryBuilder.andWhere('orgs.owner_id = :supportId', { supportId }); - } - } - queryBuilder = this._addIsSupportWorkspace(userId, queryBuilder, 'orgs', 'workspaces'); - // Add access information and query limits - // TODO: allow generic org limit once sample/support workspace is done differently - queryBuilder = this._applyLimit(queryBuilder, {...scope, org: undefined}, ['orgs', 'workspaces', 'docs'], 'list'); - - const result = await this._verifyAclPermissions(queryBuilder, { scope }); + const query = this._orgWorkspaces(scope, orgKey, options); + const result = await this._verifyAclPermissions(query, { scope }); // Return the workspaces, not the org(s). if (result.status === 200) { // Place ownership information in workspaces, available for the merged org. for (const o of result.data) { for (const ws of o.workspaces) { ws.owner = o.owner; + // Include the org's domain so that the UI can build doc URLs that include the org. + ws.orgDomain = o.domain; } } // For org-specific requests, we still have the org's workspaces, plus the Samples workspace @@ -816,7 +784,6 @@ export class HomeDBManager extends EventEmitter { return result; } - /** * Returns a QueryResult for the workspace with the given workspace id. The workspace * includes nested Docs. @@ -2409,6 +2376,51 @@ export class HomeDBManager extends EventEmitter { return query; } + /** + * Construct a QueryBuilder for a select query on a specific org's workspaces given by orgId. + * Provides options for running in a transaction and adding permission info. + * See QueryOptions documentation above. + */ + private _orgWorkspaces(scope: Scope, org: string|number|null, + options: QueryOptions = {}): SelectQueryBuilder { + const {userId} = scope; + const supportId = this._specialUserIds[SUPPORT_EMAIL]; + let query = this.org(scope, org, options) + .leftJoinAndSelect('orgs.workspaces', 'workspaces') + .leftJoinAndSelect('workspaces.docs', 'docs', this._onDoc(scope)) + .leftJoin('orgs.billingAccount', 'account') + .leftJoin('account.product', 'product') + .addSelect('product.features') + .addSelect('product.id') + .addSelect('account.id') + // order the support org (aka Samples/Examples) after other ones. + .orderBy('coalesce(orgs.owner_id = :supportId, false)') + .setParameter('supportId', supportId) + .addOrderBy('(orgs.owner_id = :userId)', 'DESC') + .setParameter('userId', userId) + // For consistency of results, particularly in tests, order workspaces by name. + .addOrderBy('workspaces.name') + .addOrderBy('docs.created_at') + .leftJoinAndSelect('orgs.owner', 'org_users'); + + // If merged org, we need to take some special steps. + if (this.isMergedOrg(org)) { + // Add information about owners of personal orgs. + query = query.leftJoinAndSelect('org_users.logins', 'org_logins'); + // Add a direct, efficient filter to remove irrelevant personal orgs from consideration. + query = this._filterByOrgGroups(query, userId); + // The anonymous user is a special case; include only examples from support user. + if (userId === this.getAnonymousUserId()) { + query = query.andWhere('orgs.owner_id = :supportId', { supportId }); + } + } + query = this._addIsSupportWorkspace(userId, query, 'orgs', 'workspaces'); + // Add access information and query limits + // TODO: allow generic org limit once sample/support workspace is done differently + query = this._applyLimit(query, {...scope, org: undefined}, ['orgs', 'workspaces', 'docs'], 'list'); + return query; + } + /** * Check if urlId is already in use in the given org, and throw an error if so. * If the org is a personal org, we check for use of the urlId in any personal org. @@ -2924,6 +2936,8 @@ export class HomeDBManager extends EventEmitter { const onDefault = 'docs.workspace_id = workspaces.id'; if (scope.showAll) { return onDefault; + } else if (scope.showOnlyPinned) { + return `${onDefault} AND docs.is_pinned = TRUE AND (workspaces.removed_at IS NULL AND docs.removed_at IS NULL)`; } else if (scope.showRemoved) { return `${onDefault} AND (workspaces.removed_at IS NOT NULL OR docs.removed_at IS NOT NULL)`; } else { diff --git a/test/nbrowser/gristUtils.ts b/test/nbrowser/gristUtils.ts index b7ad1dcc..68b82a51 100644 --- a/test/nbrowser/gristUtils.ts +++ b/test/nbrowser/gristUtils.ts @@ -1078,16 +1078,20 @@ export function openRowMenu(rowNum: number) { * either the `Copy As Template` or `Save Copy` (when on a forked document) button. Accept optional * `destName` and `destWorkspace` to change the default destination. */ -export async function completeCopy(destName?: string, destWorkspace?: string) { +export async function completeCopy(options: {destName?: string, destWorkspace?: string, destOrg?: string} = {}) { await driver.findWait('.test-modal-dialog', 1000); - if (destName !== undefined) { + if (options.destName !== undefined) { const nameElem = await driver.find('.test-copy-dest-name').doClick(); await setValue(nameElem, ''); - await nameElem.sendKeys(destName); + await nameElem.sendKeys(options.destName); } - if (destWorkspace !== undefined) { - await driver.find('.test-copy-dest-workspace .test-select-open').click(); - await driver.findContent('.test-select-menu li', destWorkspace).click(); + if (options.destOrg !== undefined) { + await driver.find('.test-copy-dest-org .test-select-open').click(); + await driver.findContent('.test-select-menu li', options.destOrg).click(); + } + if (options.destWorkspace !== undefined) { + await driver.findWait('.test-copy-dest-workspace .test-select-open', 1000).click(); + await driver.findContent('.test-select-menu li', options.destWorkspace).click(); } // save the urlId @@ -1130,6 +1134,8 @@ export async function wipeToasts(): Promise { /** * Call this at suite level, to share the "Examples & Templates" workspace in before() and restore * it in after(). + * + * TODO: Should remove once support workspaces are removed from backend. */ export function shareSupportWorkspaceForSuite() { let api: UserAPIImpl|undefined; @@ -1146,6 +1152,7 @@ export function shareSupportWorkspaceForSuite() { 'everyone@getgrist.com': 'viewers', 'anon@getgrist.com': 'viewers', }}); + await server.removeLogin(); }); after(async function() { @@ -1501,6 +1508,97 @@ export function bigScreen() { }); } +/** + * Adds samples to the Examples & Templates page. + */ +async function addSamples() { + const homeApi = createHomeApi('support', 'docs'); + + // Create the Grist Templates org. + await homeApi.newOrg({name: 'Grist Templates', domain: 'templates'}); + + // Add 2 template workspaces. + const templatesApi = createHomeApi('support', 'templates'); + await templatesApi.newWorkspace({name: 'CRM'}, 'current'); + await templatesApi.newWorkspace({name: 'Other'}, 'current'); + + // Add a featured template to the CRM workspace. + const exampleDocId = (await importFixturesDoc('support', 'templates', 'CRM', + 'video/Lightweight CRM.grist', {load: false, newName: 'Lightweight CRM.grist'})).id; + await templatesApi.updateDoc( + exampleDocId, + { + isPinned: true, + options: { + description: 'CRM template and example for linking data, and creating productive layouts.', + icon: 'https://grist-static.com/icons/lightweight-crm.png', + openMode: 'fork' + }, + urlId: 'lightweight-crm' + } + ); + + // Add additional templates to the Other workspace. + const investmentDocId = (await importFixturesDoc('support', 'templates', 'Other', + 'Investment Research.grist', {load: false, newName: 'Investment Research.grist'})).id; + await templatesApi.updateDoc( + investmentDocId, + { + isPinned: true, + options: { + description: 'Example for analyzing and visualizing with summary tables and linked charts.', + icon: 'https://grist-static.com/icons/data-visualization.png', + openMode: 'fork' + }, + urlId: 'investment-research' + }, + ); + const afterschoolDocId = (await importFixturesDoc('support', 'templates', 'Other', + 'video/Afterschool Program.grist', {load: false, newName: 'Afterschool Program.grist'})).id; + await templatesApi.updateDoc( + afterschoolDocId, + { + isPinned: true, + options: { + description: 'Example for how to model business data, use formulas, and manage complexity.', + icon: 'https://grist-static.com/icons/business-management.png', + openMode: 'fork' + }, + urlId: 'afterschool-program' + }, + ); + + for (const id of [exampleDocId, investmentDocId, afterschoolDocId]) { + await homeApi.updateDocPermissions(id, {users: { + 'everyone@getgrist.com': 'viewers', + 'anon@getgrist.com': 'viewers', + }}); + } +} + +/** + * Removes the Grist Templates org. + */ +function removeTemplatesOrg() { + const homeApi = createHomeApi('support', 'docs'); + return homeApi.deleteOrg('templates'); +} + +/** + * Call this at suite level to add sample documents to the + * "Examples & Templates" page in before(), and remove added samples + * in after(). + */ +export function addSamplesForSuite() { + before(async function() { + await addSamples(); + }); + + after(async function() { + await removeTemplatesOrg(); + }); +} + } // end of namespace gristUtils diff --git a/test/nbrowser/testUtils.ts b/test/nbrowser/testUtils.ts index c310e7b3..3aa38097 100644 --- a/test/nbrowser/testUtils.ts +++ b/test/nbrowser/testUtils.ts @@ -203,7 +203,8 @@ export function setupRequirement(options: TestSuiteOptions) { const cleanup = setupCleanup(); if (options.samples) { if (!server.isExternalServer()) { - gu.shareSupportWorkspaceForSuite(); + gu.shareSupportWorkspaceForSuite(); // TODO: Remove after the support workspace is removed from the backend. + gu.addSamplesForSuite(); } } @@ -215,29 +216,6 @@ export function setupRequirement(options: TestSuiteOptions) { return; } - // Optionally ensure that at least one example document is present. - if (options.samples) { - const homeApi = gu.createHomeApi('support', 'docs'); - const wss = await homeApi.getOrgWorkspaces('current'); - const exampleWs = wss.find(ws => ws.name === 'Examples & Templates'); - if (!exampleWs) { - throw new Error('missing example workspace'); - } - // Only add the example if one isn't already there. - if (!exampleWs.docs.some((doc) => (doc.name === 'My Lightweight CRM'))) { - - const exampleDocId = (await gu.importFixturesDoc('support', 'docs', 'Examples & Templates', - 'video/Lightweight CRM.grist', {load: false, newName: 'My Lightweight CRM.grist'})).id; - - // Remove it after the suite. - cleanup.addAfterAll(() => homeApi.deleteDoc(exampleDocId)); - } - await homeApi.updateWorkspacePermissions(exampleWs.id, {users: { - 'everyone@getgrist.com': 'viewers', - 'anon@getgrist.com': 'viewers', - }}); - } - // Optionally ensure that a team site is available for tests. if (options.team) { const api = gu.createHomeApi('support', 'docs');