Add recently viewed documents to top of command menu (#7685)

* Add recent documents to command menu
Add priority key to actions and sections

* refactor

* Rename section

* refactor, remove more unused code
This commit is contained in:
Tom Moor
2024-09-28 20:30:40 -04:00
committed by GitHub
parent c58aafeb32
commit faaf0a6733
17 changed files with 131 additions and 150 deletions

View File

@@ -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: <DocumentIcon />,
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 ? (
<StarredIcon />
) : 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 ? (
<Icon value={item.icon} color={item.color ?? undefined} />
) : (
<DocumentIcon />
),
section: DocumentSection,
perform: () => history.push(item.url),
}));
},
});
@@ -722,14 +728,14 @@ export const openRandomDocument = createAction({
section: DocumentSection,
icon: <ShuffleIcon />,
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);
}
},
});

View File

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

View File

@@ -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");

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
import CommandBar from "./CommandBar";
export default CommandBar;

View File

@@ -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 ? (
<Icon value={item.icon} color={item.color ?? undefined} />
) : (
<DocumentIcon />
),
perform: () => history.push(documentPath(item)),
})
),
[count, ui.activeDocumentId, documents.recentlyViewed]
);
};
export default useRecentDocumentActions;

View File

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

View File

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

View File

@@ -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) {

View File

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

View File

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

View File

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

View File

@@ -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<Collection> {
constructor(rootStore: RootStore) {
super(rootStore, Collection);
@@ -95,55 +72,6 @@ export default class CollectionsStore extends Store<Collection> {
);
}
/**
* 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<Collection> {
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<Collection> {
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 {

View File

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

View File

@@ -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.",
"Youll be able to add people to the group next.": "Youll be able to add people to the group next.",
"Continue": "Continue",
"Recently viewed": "Recently viewed",
"Created by me": "Created by me",
"Weird, this shouldnt ever be empty": "Weird, this shouldnt ever be empty",
"You havent created any documents yet": "You havent created any documents yet",