From 66530a89912a4cd0295738b2daae35b68280dcfa Mon Sep 17 00:00:00 2001 From: Florentina Petcu Date: Wed, 12 Jun 2024 10:04:45 +0300 Subject: [PATCH 1/2] #1030 add 'Duplicate document' action in menu --- app/client/ui/AppUI.ts | 9 +++--- app/client/ui/DocMenu.ts | 54 +++++++++++++++++++++-------------- app/client/ui/PinnedDocs.ts | 11 +++---- app/client/ui/TemplateDocs.ts | 9 +++--- 4 files changed, 49 insertions(+), 34 deletions(-) diff --git a/app/client/ui/AppUI.ts b/app/client/ui/AppUI.ts index 10e7b8406b..5ebad16203 100644 --- a/app/client/ui/AppUI.ts +++ b/app/client/ui/AppUI.ts @@ -90,11 +90,12 @@ function createMainPage(appModel: AppModel, appObj: App) { } function pagePanelsHome(owner: IDisposableOwner, appModel: AppModel, app: App) { - const pageModel = HomeModelImpl.create(owner, appModel, app.clientScope); + const homeModel = HomeModelImpl.create(owner, appModel, app.clientScope); + const pageModel = DocPageModelImpl.create(owner, app, appModel); const leftPanelOpen = Observable.create(owner, true); // Set document title to strings like "Home - Grist" or "Org Name - Grist". - owner.autoDispose(subscribe(pageModel.currentPage, pageModel.currentWS, (use, page, ws) => { + owner.autoDispose(subscribe(homeModel.currentPage, homeModel.currentWS, (use, page, ws) => { const name = ( page === 'trash' ? 'Trash' : page === 'templates' ? 'Examples & Templates' : @@ -109,10 +110,10 @@ function pagePanelsHome(owner: IDisposableOwner, appModel: AppModel, app: App) { panelOpen: leftPanelOpen, hideOpener: true, header: dom.create(AppHeader, appModel), - content: createHomeLeftPane(leftPanelOpen, pageModel), + content: createHomeLeftPane(leftPanelOpen, homeModel), }, headerMain: createTopBarHome(appModel), - contentMain: createDocMenu(pageModel), + contentMain: createDocMenu(homeModel, pageModel), contentTop: buildHomeBanners(appModel), testId, }); diff --git a/app/client/ui/DocMenu.ts b/app/client/ui/DocMenu.ts index e2e149f07b..a7ce201651 100644 --- a/app/client/ui/DocMenu.ts +++ b/app/client/ui/DocMenu.ts @@ -6,12 +6,14 @@ import {loadUserManager} from 'app/client/lib/imports'; import {getTimeFromNow} from 'app/client/lib/timeUtils'; import {reportError} from 'app/client/models/AppModel'; -import {docUrl, urlState} from 'app/client/models/gristUrlState'; +import {docUrl, getLoginOrSignupUrl, urlState} from 'app/client/models/gristUrlState'; +import {DocPageModel} from 'app/client/models/DocPageModel'; import {HomeModel, makeLocalViewSettings, ViewSettings} from 'app/client/models/HomeModel'; import {getWorkspaceInfo, workspaceName} from 'app/client/models/WorkspaceInfo'; import {attachAddNewTip} from 'app/client/ui/AddNewTip'; import * as css from 'app/client/ui/DocMenuCss'; import {buildHomeIntro, buildWorkspaceIntro} from 'app/client/ui/HomeIntro'; +import {makeCopy} from 'app/client/ui/MakeCopyMenu'; import {buildUpgradeButton} from 'app/client/ui/ProductUpgrades'; import {buildTutorialCard} from 'app/client/ui/TutorialCard'; import {buildPinnedDoc, createPinnedDocs} from 'app/client/ui/PinnedDocs'; @@ -49,13 +51,13 @@ const testId = makeTestId('test-dm-'); * Usage: * dom('div', createDocMenu(homeModel)) */ -export function createDocMenu(home: HomeModel): DomElementArg[] { +export function createDocMenu(home: HomeModel, page: DocPageModel): DomElementArg[] { return [ attachWelcomePopups(home), dom.domComputed(home.loading, loading => ( loading === 'slow' ? css.spinner(loadingSpinner()) : loading ? null : - dom.create(createLoadedDocMenu, home) + dom.create(createLoadedDocMenu, home, page) )) ]; } @@ -71,7 +73,7 @@ function attachWelcomePopups(home: HomeModel): (el: Element) => void { }; } -function createLoadedDocMenu(owner: IDisposableOwner, home: HomeModel) { +function createLoadedDocMenu(owner: IDisposableOwner, home: HomeModel, pageModel: DocPageModel) { const flashDocId = observable(null); const upgradeButton = buildUpgradeButton(owner, home.app); return css.docList( /* vbox */ @@ -112,7 +114,7 @@ function createLoadedDocMenu(owner: IDisposableOwner, home: HomeModel) { // removes all pinned docs when on trash page. dom.maybe((use) => use(home.currentWSPinnedDocs).length > 0, () => [ css.docListHeader(css.pinnedDocsIcon('PinBig'), t("Pinned Documents")), - createPinnedDocs(home, home.currentWSPinnedDocs), + createPinnedDocs(home, pageModel, home.currentWSPinnedDocs), ]), // Build the featured templates dom if on the Examples & Templates page. @@ -122,7 +124,7 @@ function createLoadedDocMenu(owner: IDisposableOwner, home: HomeModel) { t("Featured"), testId('featured-templates-header') ), - createPinnedDocs(home, home.featuredTemplates, true), + createPinnedDocs(home, pageModel, home.featuredTemplates, true), ]), dom.maybe(home.available, () => [ @@ -146,8 +148,8 @@ function createLoadedDocMenu(owner: IDisposableOwner, home: HomeModel) { (page === 'all') ? dom('div', showIntro ? buildHomeIntro(home) : null, - buildAllDocsBlock(home, home.workspaces, showIntro, flashDocId, viewSettings), - shouldShowTemplates(home, showIntro) ? buildAllDocsTemplates(home, viewSettings) : null, + buildAllDocsBlock(home, pageModel, home.workspaces, showIntro, flashDocId, viewSettings), + shouldShowTemplates(home, showIntro) ? buildAllDocsTemplates(home, pageModel, viewSettings) : null, ) : (page === 'trash') ? dom('div', @@ -155,15 +157,15 @@ function createLoadedDocMenu(owner: IDisposableOwner, home: HomeModel) { dom.maybe((use) => use(home.trashWorkspaces).length === 0, () => css.docBlock(t("Trash is empty.")) ), - buildAllDocsBlock(home, home.trashWorkspaces, false, flashDocId, viewSettings), + buildAllDocsBlock(home, pageModel, home.trashWorkspaces, false, flashDocId, viewSettings), ) : (page === 'templates') ? dom('div', - buildAllTemplates(home, home.templateWorkspaces, viewSettings) + buildAllTemplates(home, pageModel, home.templateWorkspaces, viewSettings) ) : workspace && !workspace.isSupportWorkspace && workspace.docs?.length ? css.docBlock( - buildWorkspaceDocBlock(home, workspace, flashDocId, viewSettings), + buildWorkspaceDocBlock(home, pageModel, workspace, flashDocId, viewSettings), testId('doc-block') ) : workspace && !workspace.isSupportWorkspace && workspace.docs?.length === 0 ? @@ -188,7 +190,7 @@ function createLoadedDocMenu(owner: IDisposableOwner, home: HomeModel) { } function buildAllDocsBlock( - home: HomeModel, workspaces: Observable, + home: HomeModel, page: DocPageModel, workspaces: Observable, showIntro: boolean, flashDocId: Observable, viewSettings: ViewSettings, ) { return dom.forEach(workspaces, (ws) => { @@ -220,7 +222,7 @@ function buildAllDocsBlock( testId('ws-header'), ), - buildWorkspaceDocBlock(home, ws, flashDocId, viewSettings), + buildWorkspaceDocBlock(home, page, ws, flashDocId, viewSettings), testId('doc-block') ); }); @@ -232,7 +234,7 @@ function buildAllDocsBlock( * * If there are no featured templates, builds nothing. */ -function buildAllDocsTemplates(home: HomeModel, viewSettings: ViewSettings) { +function buildAllDocsTemplates(home: HomeModel, page: DocPageModel, viewSettings: ViewSettings) { return dom.domComputed(home.featuredTemplates, templates => { if (templates.length === 0) { return null; } @@ -251,7 +253,7 @@ function buildAllDocsTemplates(home: HomeModel, viewSettings: ViewSettings) { createVideoTourTextButton(), ), dom.maybe((use) => !use(hideTemplatesObs), () => [ - buildTemplateDocs(home, templates, viewSettings), + buildTemplateDocs(home, page, templates, viewSettings), bigBasicButton( t("Discover More Templates"), urlState().setLinkUrl({homePage: 'templates'}), @@ -273,7 +275,7 @@ function buildAllDocsTemplates(home: HomeModel, viewSettings: ViewSettings) { * * Used on the Examples & Templates below the featured templates. */ -function buildAllTemplates(home: HomeModel, templateWorkspaces: Observable, viewSettings: ViewSettings) { +function buildAllTemplates(home: HomeModel, page: DocPageModel, templateWorkspaces: Observable, viewSettings: ViewSettings) { return dom.forEach(templateWorkspaces, workspace => { return css.templatesDocBlock( css.templateBlockHeader( @@ -283,7 +285,7 @@ function buildAllTemplates(home: HomeModel, templateWorkspaces: Observable '-' + use(viewSettings.currentView)), testId('templates'), ); @@ -371,7 +373,7 @@ function buildPrefs( } -function buildWorkspaceDocBlock(home: HomeModel, workspace: Workspace, flashDocId: Observable, +function buildWorkspaceDocBlock(home: HomeModel, page: DocPageModel, workspace: Workspace, flashDocId: Observable, viewSettings: ViewSettings) { const renaming = observable(null); @@ -385,7 +387,7 @@ function buildWorkspaceDocBlock(home: HomeModel, workspace: Workspace, flashDocI return dom.forEach(docs, doc => { if (view === 'icons') { return dom.update( - buildPinnedDoc(home, doc, workspace), + buildPinnedDoc(home, page, doc, workspace), testId('doc'), ); } @@ -420,7 +422,7 @@ function buildWorkspaceDocBlock(home: HomeModel, workspace: Workspace, flashDocI css.docMenuTrigger(icon('Dots'), testId('doc-options')), ] : css.docMenuTrigger(icon('Dots'), - menu(() => makeDocOptionsMenu(home, doc, renaming), + menu(() => makeDocOptionsMenu(home, page, doc, renaming), {placement: 'bottom-start', parentSelectorToMark: '.' + css.docRowWrapper.className}), // Clicks on the menu trigger shouldn't follow the link that it's contained in. dom.on('click', (ev) => { ev.stopPropagation(); ev.preventDefault(); }), @@ -478,7 +480,7 @@ async function doRename(home: HomeModel, doc: Document, val: string, flashDocId: // losing the doc that was e.g. just renamed. // Exported because also used by the PinnedDocs component. -export function makeDocOptionsMenu(home: HomeModel, doc: Document, renaming: Observable) { +export function makeDocOptionsMenu(home: HomeModel, page: DocPageModel, doc: Document, renaming: Observable) { const org = home.app.currentOrg; const orgAccess: roles.Role|null = org ? org.access : null; @@ -529,6 +531,16 @@ export function makeDocOptionsMenu(home: HomeModel, doc: Document, renaming: Obs dom.cls('disabled', !roles.canEdit(orgAccess)), testId('pin-doc') ), + menuItem(() => { + const {appModel} = page; + if (!appModel.currentValidUser) { + page.clearUnsavedChanges(); + window.location.href = getLoginOrSignupUrl({srcDocId: urlState().state.get().doc}); + return; + } + return makeCopy({pageModel: page, doc, modalTitle: t("Duplicate Document")}); + }, t("Duplicate Document") + ), menuItem(manageUsers, roles.canEditAccess(doc.access) ? t("Manage Users"): t("Access Details"), testId('doc-access') ) diff --git a/app/client/ui/PinnedDocs.ts b/app/client/ui/PinnedDocs.ts index 7077749c6b..a0f61eb5d5 100644 --- a/app/client/ui/PinnedDocs.ts +++ b/app/client/ui/PinnedDocs.ts @@ -1,5 +1,6 @@ import {getTimeFromNow} from 'app/client/lib/timeUtils'; import {docUrl, urlState} from 'app/client/models/gristUrlState'; +import {DocPageModel} from 'app/client/models/DocPageModel'; import {HomeModel} from 'app/client/models/HomeModel'; import {makeDocOptionsMenu, makeRemovedDocOptionsMenu} from 'app/client/ui/DocMenu'; import {transientInput} from 'app/client/ui/transientInput'; @@ -18,18 +19,18 @@ const testId = makeTestId('test-dm-'); * * Used only by DocMenu. */ -export function createPinnedDocs(home: HomeModel, docs: Observable, isExample = false) { +export function createPinnedDocs(home: HomeModel, page: DocPageModel, docs: Observable, isExample = false) { return pinnedDocList( - dom.forEach(docs, doc => buildPinnedDoc(home, doc, doc.workspace, isExample)), + dom.forEach(docs, doc => buildPinnedDoc(home, page, doc, doc.workspace, isExample)), testId('pinned-doc-list'), ); } /** * 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. + * pinned docs, but also for the thumbnails (aka "icons") view mode. */ -export function buildPinnedDoc(home: HomeModel, doc: Document, workspace: Workspace, isExample = false): HTMLElement { +export function buildPinnedDoc(home: HomeModel, page: DocPageModel, doc: Document, workspace: Workspace, isExample = false): HTMLElement { const renaming = observable(null); const isRenamingDoc = computed((use) => use(renaming) === doc); return pinnedDocWrapper( @@ -82,7 +83,7 @@ export function buildPinnedDoc(home: HomeModel, doc: Document, workspace: Worksp pinnedDocOptions(icon('Dots'), testId('pinned-doc-options')), ] : pinnedDocOptions(icon('Dots'), - menu(() => makeDocOptionsMenu(home, doc, renaming), + menu(() => makeDocOptionsMenu(home, page, doc, renaming), {placement: 'bottom-start'}), // Clicks on the menu trigger shouldn't follow the link that it's contained in. dom.on('click', (ev) => { ev.stopPropagation(); ev.preventDefault(); }), diff --git a/app/client/ui/TemplateDocs.ts b/app/client/ui/TemplateDocs.ts index 6e3fc6bb5a..3e35f18e75 100644 --- a/app/client/ui/TemplateDocs.ts +++ b/app/client/ui/TemplateDocs.ts @@ -2,6 +2,7 @@ import {docUrl, urlState} from 'app/client/models/gristUrlState'; import {theme} from 'app/client/ui2018/cssVars'; import {Document, Workspace} from 'app/common/UserAPI'; import {dom, makeTestId, styled} from 'grainjs'; +import {DocPageModel} from 'app/client/models/DocPageModel'; import {HomeModel, ViewSettings} from 'app/client/models/HomeModel'; import * as css from 'app/client/ui/DocMenuCss'; import {buildPinnedDoc} from 'app/client/ui/PinnedDocs'; @@ -12,7 +13,7 @@ const testId = makeTestId('test-dm-'); /** * Builds all `templateDocs` according to the specified `viewSettings`. */ - export function buildTemplateDocs(home: HomeModel, templateDocs: Document[], viewSettings: ViewSettings) { +export function buildTemplateDocs(home: HomeModel, page: DocPageModel, templateDocs: Document[], viewSettings: ViewSettings) { const {currentView, currentSort} = viewSettings; return dom.domComputed((use) => [use(currentView), use(currentSort)] as const, (opts) => { const [view, sort] = opts; @@ -21,7 +22,7 @@ const testId = makeTestId('test-dm-'); if (sort === 'date') { sortedDocs = sortBy(templateDocs, (d) => d.removedAt || d.updatedAt).reverse(); } - return cssTemplateDocs(dom.forEach(sortedDocs, d => buildTemplateDoc(home, d, d.workspace, view))); + return cssTemplateDocs(dom.forEach(sortedDocs, d => buildTemplateDoc(home, page, d, d.workspace, view))); }); } @@ -34,9 +35,9 @@ const testId = makeTestId('test-dm-'); * 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') { +function buildTemplateDoc(home: HomeModel, page: DocPageModel, doc: Document, workspace: Workspace, view: 'list'|'icons') { if (view === 'icons') { - return buildPinnedDoc(home, doc, workspace, true); + return buildPinnedDoc(home, page, doc, workspace, true); } else { return css.docRowWrapper( cssDocRowLink( From 83723f4eb01d6fe659e70e839c9ed5c7f0879a99 Mon Sep 17 00:00:00 2001 From: Florentina Petcu Date: Thu, 13 Jun 2024 08:44:51 +0300 Subject: [PATCH 2/2] #1030 fix lint warnings --- app/client/ui/DocMenu.ts | 10 ++++++---- app/client/ui/PinnedDocs.ts | 3 ++- app/client/ui/TemplateDocs.ts | 6 ++++-- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/app/client/ui/DocMenu.ts b/app/client/ui/DocMenu.ts index a7ce201651..80f9c5da19 100644 --- a/app/client/ui/DocMenu.ts +++ b/app/client/ui/DocMenu.ts @@ -275,7 +275,8 @@ function buildAllDocsTemplates(home: HomeModel, page: DocPageModel, viewSettings * * Used on the Examples & Templates below the featured templates. */ -function buildAllTemplates(home: HomeModel, page: DocPageModel, templateWorkspaces: Observable, viewSettings: ViewSettings) { +function buildAllTemplates(home: HomeModel, page: DocPageModel, templateWorkspaces: Observable, + viewSettings: ViewSettings) { return dom.forEach(templateWorkspaces, workspace => { return css.templatesDocBlock( css.templateBlockHeader( @@ -373,8 +374,8 @@ function buildPrefs( } -function buildWorkspaceDocBlock(home: HomeModel, page: DocPageModel, workspace: Workspace, flashDocId: Observable, - viewSettings: ViewSettings) { +function buildWorkspaceDocBlock(home: HomeModel, page: DocPageModel, workspace: Workspace, + flashDocId: Observable, viewSettings: ViewSettings) { const renaming = observable(null); function renderDocs(sort: 'date'|'name', view: "list"|"icons") { @@ -480,7 +481,8 @@ async function doRename(home: HomeModel, doc: Document, val: string, flashDocId: // losing the doc that was e.g. just renamed. // Exported because also used by the PinnedDocs component. -export function makeDocOptionsMenu(home: HomeModel, page: DocPageModel, doc: Document, renaming: Observable) { +export function makeDocOptionsMenu(home: HomeModel, page: DocPageModel, doc: Document, + renaming: Observable) { const org = home.app.currentOrg; const orgAccess: roles.Role|null = org ? org.access : null; diff --git a/app/client/ui/PinnedDocs.ts b/app/client/ui/PinnedDocs.ts index a0f61eb5d5..e5673266ce 100644 --- a/app/client/ui/PinnedDocs.ts +++ b/app/client/ui/PinnedDocs.ts @@ -30,7 +30,8 @@ export function createPinnedDocs(home: HomeModel, page: DocPageModel, docs: Obse * 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 thumbnails (aka "icons") view mode. */ -export function buildPinnedDoc(home: HomeModel, page: DocPageModel, doc: Document, workspace: Workspace, isExample = false): HTMLElement { +export function buildPinnedDoc(home: HomeModel, page: DocPageModel, doc: Document, workspace: Workspace, + isExample = false): HTMLElement { const renaming = observable(null); const isRenamingDoc = computed((use) => use(renaming) === doc); return pinnedDocWrapper( diff --git a/app/client/ui/TemplateDocs.ts b/app/client/ui/TemplateDocs.ts index 3e35f18e75..9452b8c42e 100644 --- a/app/client/ui/TemplateDocs.ts +++ b/app/client/ui/TemplateDocs.ts @@ -13,7 +13,8 @@ const testId = makeTestId('test-dm-'); /** * Builds all `templateDocs` according to the specified `viewSettings`. */ -export function buildTemplateDocs(home: HomeModel, page: DocPageModel, templateDocs: Document[], viewSettings: ViewSettings) { +export function buildTemplateDocs(home: HomeModel, page: DocPageModel, templateDocs: Document[], + viewSettings: ViewSettings) { const {currentView, currentSort} = viewSettings; return dom.domComputed((use) => [use(currentView), use(currentSort)] as const, (opts) => { const [view, sort] = opts; @@ -35,7 +36,8 @@ export function buildTemplateDocs(home: HomeModel, page: DocPageModel, templateD * 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, page: DocPageModel, doc: Document, workspace: Workspace, view: 'list'|'icons') { +function buildTemplateDoc(home: HomeModel, page: DocPageModel, doc: Document, workspace: Workspace, + view: 'list'|'icons') { if (view === 'icons') { return buildPinnedDoc(home, page, doc, workspace, true); } else {