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",