diff --git a/app/actions/definitions/documents.tsx b/app/actions/definitions/documents.tsx index a6a1a0732e..748180a880 100644 --- a/app/actions/definitions/documents.tsx +++ b/app/actions/definitions/documents.tsx @@ -30,7 +30,11 @@ import { } from "outline-icons"; import * as React from "react"; import { toast } from "sonner"; -import { ExportContentType, TeamPreference } from "@shared/types"; +import { + ExportContentType, + TeamPreference, + NavigationNode, +} from "@shared/types"; import { getEventFiles } from "@shared/utils/files"; import DocumentDelete from "~/scenes/DocumentDelete"; import DocumentMove from "~/scenes/DocumentMove"; @@ -39,6 +43,7 @@ import DocumentPublish from "~/scenes/DocumentPublish"; import DeleteDocumentsInTrash from "~/scenes/Trash/components/DeleteDocumentsInTrash"; import ConfirmationDialog from "~/components/ConfirmationDialog"; import DuplicateDialog from "~/components/DuplicateDialog"; +import Icon from "~/components/Icon"; import SharePopover from "~/components/Sharing/Document"; import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header"; import DocumentTemplatizeDialog from "~/components/TemplatizeDialog"; @@ -67,23 +72,24 @@ export const openDocument = createAction({ keywords: "go to", icon: , children: ({ stores }) => { - const paths = stores.collections.pathsToDocuments; + const nodes = stores.collections.navigationNodes.reduce( + (acc, node) => [...acc, ...node.children], + [] as NavigationNode[] + ); - return paths - .filter((path) => path.type === "document") - .map((path) => ({ - // Note: using url which includes the slug rather than id here to bust - // cache if the document is renamed - id: path.url, - name: path.title, - icon: function _Icon() { - return stores.documents.get(path.id)?.isStarred ? ( - - ) : null; - }, - section: DocumentSection, - perform: () => history.push(path.url), - })); + return nodes.map((item) => ({ + // Note: using url which includes the slug rather than id here to bust + // cache if the document is renamed + id: item.url, + name: item.title, + icon: item.icon ? ( + + ) : ( + + ), + section: DocumentSection, + perform: () => history.push(item.url), + })); }, }); @@ -722,14 +728,14 @@ export const openRandomDocument = createAction({ section: DocumentSection, icon: , perform: ({ stores, activeDocumentId }) => { - const documentPaths = stores.collections.pathsToDocuments.filter( - (path) => path.type === "document" && path.id !== activeDocumentId - ); - const randomPath = - documentPaths[Math.round(Math.random() * documentPaths.length)]; + const nodes = stores.collections.navigationNodes + .reduce((acc, node) => [...acc, ...node.children], [] as NavigationNode[]) + .filter((node) => node.id !== activeDocumentId); - if (randomPath) { - history.push(randomPath.url); + const random = nodes[Math.round(Math.random() * nodes.length)]; + + if (random) { + history.push(random.url); } }, }); diff --git a/app/actions/index.ts b/app/actions/index.ts index 7ad340b005..3bf3cac9bf 100644 --- a/app/actions/index.ts +++ b/app/actions/index.ts @@ -98,6 +98,11 @@ export function actionToKBar( ) : []; + const sectionPriority = + typeof action.section !== "string" && "priority" in action.section + ? (action.section.priority as number) ?? 0 + : 0; + return [ { id: action.id, @@ -108,6 +113,7 @@ export function actionToKBar( keywords: action.keywords ?? "", shortcut: action.shortcut || [], icon: resolvedIcon, + priority: (action.priority ?? 0) * (1 + (sectionPriority ?? 0) * 0.5), perform: action.perform ? () => performAction(action, context) : undefined, diff --git a/app/actions/sections.ts b/app/actions/sections.ts index 0224757c66..16424b67ac 100644 --- a/app/actions/sections.ts +++ b/app/actions/sections.ts @@ -6,6 +6,10 @@ export const DeveloperSection = ({ t }: ActionContext) => t("Debug"); export const DocumentSection = ({ t }: ActionContext) => t("Document"); +export const RecentSection = ({ t }: ActionContext) => t("Recently viewed"); + +RecentSection.priority = 1; + export const RevisionSection = ({ t }: ActionContext) => t("Revision"); export const SettingsSection = ({ t }: ActionContext) => t("Settings"); @@ -21,4 +25,6 @@ export const TeamSection = ({ t }: ActionContext) => t("Workspace"); export const RecentSearchesSection = ({ t }: ActionContext) => t("Recent searches"); +RecentSearchesSection.priority = 0.9; + export const TrashSection = ({ t }: ActionContext) => t("Trash"); diff --git a/app/components/CommandBar.tsx b/app/components/CommandBar/CommandBar.tsx similarity index 78% rename from app/components/CommandBar.tsx rename to app/components/CommandBar/CommandBar.tsx index 236f38f2f9..4f8a38136f 100644 --- a/app/components/CommandBar.tsx +++ b/app/components/CommandBar/CommandBar.tsx @@ -6,20 +6,27 @@ import { Portal } from "react-portal"; import styled from "styled-components"; import breakpoint from "styled-components-breakpoint"; import { depths, s } from "@shared/styles"; -import CommandBarResults from "~/components/CommandBarResults"; import SearchActions from "~/components/SearchActions"; import rootActions from "~/actions/root"; import useCommandBarActions from "~/hooks/useCommandBarActions"; -import useSettingsActions from "~/hooks/useSettingsActions"; -import useTemplateActions from "~/hooks/useTemplateActions"; +import CommandBarResults from "./CommandBarResults"; +import useRecentDocumentActions from "./useRecentDocumentActions"; +import useSettingsAction from "./useSettingsAction"; +import useTemplatesAction from "./useTemplatesAction"; function CommandBar() { const { t } = useTranslation(); - const settingsActions = useSettingsActions(); - const templateActions = useTemplateActions(); + const recentDocumentActions = useRecentDocumentActions(); + const settingsAction = useSettingsAction(); + const templatesAction = useTemplatesAction(); const commandBarActions = React.useMemo( - () => [...rootActions, templateActions, settingsActions], - [settingsActions, templateActions] + () => [ + ...recentDocumentActions, + ...rootActions, + templatesAction, + settingsAction, + ], + [recentDocumentActions, settingsAction, templatesAction] ); useCommandBarActions(commandBarActions); diff --git a/app/components/CommandBarItem.tsx b/app/components/CommandBar/CommandBarItem.tsx similarity index 98% rename from app/components/CommandBarItem.tsx rename to app/components/CommandBar/CommandBarItem.tsx index 4762012a41..6f34115c54 100644 --- a/app/components/CommandBarItem.tsx +++ b/app/components/CommandBar/CommandBarItem.tsx @@ -5,7 +5,7 @@ import styled, { css, useTheme } from "styled-components"; import { s, ellipsis } from "@shared/styles"; import Flex from "~/components/Flex"; import Key from "~/components/Key"; -import Text from "./Text"; +import Text from "~/components/Text"; type Props = { action: ActionImpl; diff --git a/app/components/CommandBarResults.tsx b/app/components/CommandBar/CommandBarResults.tsx similarity index 94% rename from app/components/CommandBarResults.tsx rename to app/components/CommandBar/CommandBarResults.tsx index c69e53d94c..16e726a3e3 100644 --- a/app/components/CommandBarResults.tsx +++ b/app/components/CommandBar/CommandBarResults.tsx @@ -2,7 +2,7 @@ import { useMatches, KBarResults } from "kbar"; import * as React from "react"; import styled from "styled-components"; import { s } from "@shared/styles"; -import CommandBarItem from "~/components/CommandBarItem"; +import CommandBarItem from "./CommandBarItem"; export default function CommandBarResults() { const { results, rootActionId } = useMatches(); diff --git a/app/components/CommandBar/index.ts b/app/components/CommandBar/index.ts new file mode 100644 index 0000000000..631b96b1b8 --- /dev/null +++ b/app/components/CommandBar/index.ts @@ -0,0 +1,3 @@ +import CommandBar from "./CommandBar"; + +export default CommandBar; diff --git a/app/components/CommandBar/useRecentDocumentActions.tsx b/app/components/CommandBar/useRecentDocumentActions.tsx new file mode 100644 index 0000000000..7a84f63720 --- /dev/null +++ b/app/components/CommandBar/useRecentDocumentActions.tsx @@ -0,0 +1,35 @@ +import { DocumentIcon } from "outline-icons"; +import * as React from "react"; +import Icon from "~/components/Icon"; +import { createAction } from "~/actions"; +import { RecentSection } from "~/actions/sections"; +import useStores from "~/hooks/useStores"; +import history from "~/utils/history"; +import { documentPath } from "~/utils/routeHelpers"; + +const useRecentDocumentActions = (count = 6) => { + const { documents, ui } = useStores(); + + return React.useMemo( + () => + documents.recentlyViewed + .filter((document) => document.id !== ui.activeDocumentId) + .slice(0, count) + .map((item) => + createAction({ + name: item.titleWithDefault, + analyticsName: "Recently viewed document", + section: RecentSection, + icon: item.icon ? ( + + ) : ( + + ), + perform: () => history.push(documentPath(item)), + }) + ), + [count, ui.activeDocumentId, documents.recentlyViewed] + ); +}; + +export default useRecentDocumentActions; diff --git a/app/hooks/useSettingsActions.tsx b/app/components/CommandBar/useSettingsAction.tsx similarity index 87% rename from app/hooks/useSettingsActions.tsx rename to app/components/CommandBar/useSettingsAction.tsx index 2cd7773380..1902daf44b 100644 --- a/app/hooks/useSettingsActions.tsx +++ b/app/components/CommandBar/useSettingsAction.tsx @@ -2,10 +2,10 @@ import { SettingsIcon } from "outline-icons"; import * as React from "react"; import { createAction } from "~/actions"; import { NavigationSection } from "~/actions/sections"; +import useSettingsConfig from "~/hooks/useSettingsConfig"; import history from "~/utils/history"; -import useSettingsConfig from "./useSettingsConfig"; -const useSettingsActions = () => { +const useSettingsAction = () => { const config = useSettingsConfig(); const actions = React.useMemo( () => @@ -38,4 +38,4 @@ const useSettingsActions = () => { return navigateToSettings; }; -export default useSettingsActions; +export default useSettingsAction; diff --git a/app/hooks/useTemplateActions.tsx b/app/components/CommandBar/useTemplatesAction.tsx similarity index 93% rename from app/hooks/useTemplateActions.tsx rename to app/components/CommandBar/useTemplatesAction.tsx index 2bad996d83..32296531db 100644 --- a/app/hooks/useTemplateActions.tsx +++ b/app/components/CommandBar/useTemplatesAction.tsx @@ -3,11 +3,11 @@ import * as React from "react"; import Icon from "~/components/Icon"; import { createAction } from "~/actions"; import { DocumentSection } from "~/actions/sections"; +import useStores from "~/hooks/useStores"; import history from "~/utils/history"; import { newDocumentPath } from "~/utils/routeHelpers"; -import useStores from "./useStores"; -const useTemplatesActions = () => { +const useTemplatesAction = () => { const { documents } = useStores(); React.useEffect(() => { @@ -60,4 +60,4 @@ const useTemplatesActions = () => { return newFromTemplate; }; -export default useTemplatesActions; +export default useTemplatesAction; diff --git a/app/components/DefaultCollectionInputSelect.tsx b/app/components/DefaultCollectionInputSelect.tsx index a20c044b32..9dd7af5b37 100644 --- a/app/components/DefaultCollectionInputSelect.tsx +++ b/app/components/DefaultCollectionInputSelect.tsx @@ -49,7 +49,7 @@ const DefaultCollectionInputSelect = ({ const options = React.useMemo( () => - collections.publicCollections.reduce( + collections.nonPrivate.reduce( (acc, collection) => [ ...acc, { @@ -78,7 +78,7 @@ const DefaultCollectionInputSelect = ({ }, ] ), - [collections.publicCollections, t] + [collections.nonPrivate, t] ); if (fetching) { diff --git a/app/components/SearchActions.ts b/app/components/SearchActions.ts index 81f1f8dd9a..9501e7b30f 100644 --- a/app/components/SearchActions.ts +++ b/app/components/SearchActions.ts @@ -10,7 +10,7 @@ export default function SearchActions() { const { searches } = useStores(); React.useEffect(() => { - if (!searches.isLoaded) { + if (!searches.isLoaded && !searches.isFetching) { void searches.fetchPage({ source: "app", }); diff --git a/app/models/Collection.ts b/app/models/Collection.ts index 9a87cc2039..c469fcca2e 100644 --- a/app/models/Collection.ts +++ b/app/models/Collection.ts @@ -3,7 +3,8 @@ import { action, computed, observable, runInAction } from "mobx"; import { CollectionPermission, FileOperationFormat, - NavigationNode, + type NavigationNode, + NavigationNodeType, type ProsemirrorData, } from "@shared/types"; import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper"; @@ -256,6 +257,19 @@ export default class Collection extends ParanoidModel { return result; } + @computed + get asNavigationNode(): NavigationNode { + return { + type: NavigationNodeType.Collection, + id: this.id, + title: this.name, + color: this.color ?? undefined, + icon: this.icon ?? undefined, + children: this.documents ?? [], + url: this.url, + }; + } + pathToDocument(documentId: string) { let path: NavigationNode[] | undefined = []; const document = this.store.rootStore.documents.get(documentId); diff --git a/app/models/Document.ts b/app/models/Document.ts index 05ecbbf89e..6e2b2d3323 100644 --- a/app/models/Document.ts +++ b/app/models/Document.ts @@ -14,6 +14,7 @@ import type { import { ExportContentType, FileOperationFormat, + NavigationNodeType, NotificationEventType, } from "@shared/types"; import Storage from "@shared/utils/Storage"; @@ -619,6 +620,7 @@ export default class Document extends ParanoidModel { @computed get asNavigationNode(): NavigationNode { return { + type: NavigationNodeType.Document, id: this.id, title: this.title, color: this.color ?? undefined, diff --git a/app/stores/CollectionsStore.ts b/app/stores/CollectionsStore.ts index 8ccf4dc80a..e077d60812 100644 --- a/app/stores/CollectionsStore.ts +++ b/app/stores/CollectionsStore.ts @@ -1,38 +1,15 @@ import invariant from "invariant"; -import concat from "lodash/concat"; import find from "lodash/find"; import isEmpty from "lodash/isEmpty"; -import last from "lodash/last"; import sortBy from "lodash/sortBy"; import { computed, action } from "mobx"; -import { - CollectionPermission, - FileOperationFormat, - NavigationNode, -} from "@shared/types"; +import { CollectionPermission, FileOperationFormat } from "@shared/types"; import Collection from "~/models/Collection"; import { Properties } from "~/types"; import { client } from "~/utils/ApiClient"; import RootStore from "./RootStore"; import Store from "./base/Store"; -enum DocumentPathItemType { - Collection = "collection", - Document = "document", -} - -export type DocumentPathItem = { - type: DocumentPathItemType; - id: string; - collectionId: string; - title: string; - url: string; -}; - -export type DocumentPath = DocumentPathItem & { - path: DocumentPathItem[]; -}; - export default class CollectionsStore extends Store { constructor(rootStore: RootStore) { super(rootStore, Collection); @@ -95,55 +72,6 @@ export default class CollectionsStore extends Store { ); } - /** - * List of paths to each of the documents, where paths are composed of id and title/name pairs - */ - @computed - get pathsToDocuments(): DocumentPath[] { - const results: DocumentPathItem[][] = []; - - const travelDocuments = ( - documentList: NavigationNode[], - collectionId: string, - path: DocumentPathItem[] - ) => - documentList.forEach((document: NavigationNode) => { - const { id, title, url } = document; - const node = { - type: DocumentPathItemType.Document, - id, - collectionId, - title, - url, - }; - results.push(concat(path, node)); - travelDocuments(document.children, collectionId, concat(path, [node])); - }); - - if (this.isLoaded) { - this.data.forEach((collection) => { - const { id, name, path } = collection; - const node = { - type: DocumentPathItemType.Collection, - id, - collectionId: id, - title: name, - url: path, - }; - results.push([node]); - - if (collection.documents) { - travelDocuments(collection.documents, id, [node]); - } - }); - } - - return results.map((result) => { - const tail = last(result) as DocumentPathItem; - return { ...tail, path: result }; - }); - } - @action import = async ( attachmentId: string, @@ -191,15 +119,6 @@ export default class CollectionsStore extends Store { return model; } - @computed - get publicCollections() { - return this.orderedData.filter( - (collection) => - collection.permission && - Object.values(CollectionPermission).includes(collection.permission) - ); - } - star = async (collection: Collection, index?: string) => { await this.rootStore.stars.create({ collectionId: collection.id, @@ -209,24 +128,14 @@ export default class CollectionsStore extends Store { unstar = async (collection: Collection) => { const star = this.rootStore.stars.orderedData.find( - (star) => star.collectionId === collection.id + (s) => s.collectionId === collection.id ); await star?.delete(); }; - getPathForDocument(documentId: string): DocumentPath | undefined { - return this.pathsToDocuments.find((path) => path.id === documentId); - } - - titleForDocument(documentPath: string): string | undefined { - const path = this.pathsToDocuments.find( - (path) => path.url === documentPath - ); - if (path) { - return path.title; - } - - return; + @computed + get navigationNodes() { + return this.orderedData.map((collection) => collection.asNavigationNode); } getByUrl(url: string): Collection | null | undefined { diff --git a/app/types.ts b/app/types.ts index 216bdcce5b..55f250bd7d 100644 --- a/app/types.ts +++ b/app/types.ts @@ -103,6 +103,8 @@ export type Action = { shortcut?: string[]; keywords?: string; dangerous?: boolean; + /** Higher number is higher in results, default is 0. */ + priority?: number; iconInContextMenu?: boolean; icon?: React.ReactElement | React.FC; placeholder?: ((context: ActionContext) => string) | string; @@ -140,15 +142,6 @@ export type FetchOptions = { force?: boolean; }; -export type NavigationNode = { - id: string; - title: string; - emoji?: string | null; - url: string; - children: NavigationNode[]; - isDraft?: boolean; -}; - export type CollectionSort = { field: string; direction: "asc" | "desc"; diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index 52927fbed7..179cc2ce82 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -127,6 +127,7 @@ "Collection": "Collection", "Debug": "Debug", "Document": "Document", + "Recently viewed": "Recently viewed", "Revision": "Revision", "Navigation": "Navigation", "Notification": "Notification", @@ -157,6 +158,7 @@ "Collapse": "Collapse", "Expand": "Expand", "Type a command or search": "Type a command or search", + "Choose a template": "Choose a template", "Are you sure you want to permanently delete this entire comment thread?": "Are you sure you want to permanently delete this entire comment thread?", "Are you sure you want to permanently delete this comment?": "Are you sure you want to permanently delete this comment?", "Confirm": "Confirm", @@ -474,7 +476,6 @@ "Import": "Import", "Self Hosted": "Self Hosted", "Integrations": "Integrations", - "Choose a template": "Choose a template", "Revoke token": "Revoke token", "Revoke": "Revoke", "Show path to document": "Show path to document", @@ -681,7 +682,6 @@ "Groups are for organizing your team. They work best when centered around a function or a responsibility — Support or Engineering for example.": "Groups are for organizing your team. They work best when centered around a function or a responsibility — Support or Engineering for example.", "You’ll be able to add people to the group next.": "You’ll be able to add people to the group next.", "Continue": "Continue", - "Recently viewed": "Recently viewed", "Created by me": "Created by me", "Weird, this shouldn’t ever be empty": "Weird, this shouldn’t ever be empty", "You haven’t created any documents yet": "You haven’t created any documents yet",