(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
pull/69/head
George Gevoian 3 years ago
parent bb8cb2593d
commit 24fc3a2d00

@ -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<boolean>;
userOverride: Observable<UserOverride|null>;
isBareFork: Observable<boolean>;
isSample: Observable<boolean>;
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<string, string> | undefined): Promise<void> {
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,

@ -49,6 +49,7 @@ export interface HomeModel {
showIntro: Observable<boolean>; // set if no docs and we should show intro.
singleWorkspace: Observable<boolean>; // set if workspace name should be hidden.
trashWorkspaces: Observable<Workspace[]>; // only set when viewing trash
templateWorkspaces: Observable<Workspace[]>; // Only set when viewing templates or all documents.
// currentWS is undefined when currentPage is not "workspace" or if currentWSId doesn't exist.
currentWS: Observable<Workspace|undefined>;
@ -56,6 +57,9 @@ export interface HomeModel {
// List of pinned docs to show for currentWS.
currentWSPinnedDocs: Observable<Document[]>;
// List of featured templates from templateWorkspaces.
featuredTemplates: Observable<Document[]>;
currentSort: Observable<SortPref>;
currentView: Observable<ViewPref>;
@ -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<Workspace[]>(this, []);
public readonly templateWorkspaces = Observable.create<Workspace[]>(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<SortPref>;
public readonly currentView: Observable<ViewPref>;
@ -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<K extends keyof UserOrgPrefs>(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`);

@ -37,9 +37,23 @@ export function urlState(): UrlState<IGristUrlState> {
}
let _urlState: UrlState<IGristUrlState>|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.

@ -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`;

@ -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<Workspace[]>,
showIntro: boolean, flashDocId: Observable<string|null>, 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<Workspace[]>, 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;
}

@ -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%;

@ -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, ' +

@ -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<HTMLDivElement> =

@ -19,13 +19,10 @@ import {computed, dom, DomElementArg, Observable, observable, styled} from 'grai
export function createHomeLeftPane(leftPanelOpen: Observable<boolean>, home: HomeModel) {
const creating = observable<boolean>(false);
const renaming = observable<Workspace|null>(null);
const samplesWorkspace = computed<Workspace|undefined>((use) =>
use(home.workspaces).find((ws) => Boolean(ws.isSupportWorkspace)));
return cssContent(
dom.autoDispose(creating),
dom.autoDispose(renaming),
dom.autoDispose(samplesWorkspace),
addNewButton(leftPanelOpen,
menu(() => addMenu(home, creating), {
placement: 'bottom-start',
@ -96,13 +93,11 @@ export function createHomeLeftPane(leftPanelOpen: Observable<boolean>, 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(

@ -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<Document[]>, 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<Document|null>(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, `

@ -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;
`);

@ -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'),

@ -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)),
})

@ -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<Workspace>;
getOrg(orgId: number|string): Promise<Organization>;
getOrgWorkspaces(orgId: number|string): Promise<Workspace[]>;
getTemplates(onlyFeatured?: boolean): Promise<Workspace[]>;
getDoc(docId: string): Promise<Document>;
newOrg(props: Partial<OrganizationProperties>): Promise<number>;
newWorkspace(props: Partial<WorkspaceProperties>, orgId: number|string): Promise<number>;
@ -402,6 +404,10 @@ export class UserAPIImpl extends BaseAPI implements UserAPI {
{ method: 'GET' });
}
public async getTemplates(onlyFeatured: boolean = false): Promise<Workspace[]> {
return this.requestJson(`${this._url}/api/templates?onlyFeatured=${onlyFeatured ? 1 : 0}`, { method: 'GET' });
}
public async getDoc(docId: string): Promise<Document> {
return this.requestJson(`${this._url}/api/docs/${docId}`, { method: 'GET' });
}

@ -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<GristLoadConfig>,
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) {

@ -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) => {

@ -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<QueryResult<Workspace[]>> {
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<Organization> {
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 {

@ -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<void> {
/**
* 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

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

Loading…
Cancel
Save