mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(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
This commit is contained in:
@@ -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),
|
||||
),
|
||||
|
||||
(ws.removedAt ?
|
||||
[
|
||||
css.docRowUpdatedAt(`Deleted ${getTimeFromNow(ws.removedAt)}`),
|
||||
css.docMenuTrigger(icon('Dots')),
|
||||
menu(() => makeRemovedWsOptionsMenu(home, ws),
|
||||
{placement: 'bottom-end', parentSelectorToMark: '.' + css.docRowWrapper.className}),
|
||||
] :
|
||||
urlState().setLinkUrl({ws: ws.id})
|
||||
),
|
||||
|
||||
dom.hide((use) => Boolean(getWorkspaceInfo(home.app, ws).isDefault &&
|
||||
use(home.singleWorkspace))),
|
||||
|
||||
testId('ws-header'),
|
||||
// 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),
|
||||
),
|
||||
buildWorkspaceDocBlock(home, ws, flashDocId, viewSettings),
|
||||
testId('doc-block')
|
||||
);
|
||||
}
|
||||
|
||||
(ws.removedAt ?
|
||||
[
|
||||
css.docRowUpdatedAt(`Deleted ${getTimeFromNow(ws.removedAt)}`),
|
||||
css.docMenuTrigger(icon('Dots')),
|
||||
menu(() => makeRemovedWsOptionsMenu(home, ws),
|
||||
{placement: 'bottom-end', parentSelectorToMark: '.' + css.docRowWrapper.className}),
|
||||
] :
|
||||
urlState().setLinkUrl({ws: ws.id})
|
||||
),
|
||||
|
||||
dom.hide((use) => Boolean(getWorkspaceInfo(home.app, ws).isDefault &&
|
||||
use(home.singleWorkspace))),
|
||||
|
||||
testId('ws-header'),
|
||||
),
|
||||
buildWorkspaceDocBlock(home, ws, flashDocId, viewSettings),
|
||||
testId('doc-block')
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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')
|
||||
),
|
||||
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, `
|
||||
|
||||
71
app/client/ui/TemplateDocs.ts
Normal file
71
app/client/ui/TemplateDocs.ts
Normal file
@@ -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)),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user