(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:
George Gevoian
2021-07-28 12:02:06 -07:00
parent bb8cb2593d
commit 24fc3a2d00
19 changed files with 594 additions and 311 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(

View File

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

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

View File

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

View File

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