mirror of
https://github.com/outline/outline.git
synced 2025-12-21 10:39:41 -06:00
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:
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
3
app/components/CommandBar/index.ts
Normal file
3
app/components/CommandBar/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import CommandBar from "./CommandBar";
|
||||
|
||||
export default CommandBar;
|
||||
35
app/components/CommandBar/useRecentDocumentActions.tsx
Normal file
35
app/components/CommandBar/useRecentDocumentActions.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
11
app/types.ts
11
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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user