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');