chore: Audit and move inline actions to definitions (#13133)

* chore: Audit and move inline actions to definitions

* fix: Reuse existing smart-quote translation key in emoji delete dialog

Co-Authored-By: Claude <noreply@anthropic.com>

* Remove two usages of 'Link copied' translation

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Tom Moor
2026-07-25 16:13:12 +00:00
committed by GitHub
co-authored by Claude
parent 60195ae0ee
commit 8192bc0514
45 changed files with 814 additions and 867 deletions
+8 -13
View File
@@ -7,28 +7,23 @@ import type ApiKey from "~/models/ApiKey";
import ApiKeyNew from "~/scenes/ApiKeyNew";
import ApiKeyRevokeDialog from "~/scenes/Settings/components/ApiKeyRevokeDialog";
import { createAction } from "..";
import { dialogActionFactory } from "./common";
import { SettingsSection } from "../sections";
export const createApiKey = createAction({
name: ({ t }) => t("New API key"),
export const createApiKey = dialogActionFactory({
analyticsName: "New API key",
section: SettingsSection,
name: (t) => t("New API key"),
title: (t) => t("New API key"),
content: (onSubmit) => <ApiKeyNew onSubmit={onSubmit} />,
icon: <PlusIcon />,
keywords: "create",
stopEvent: true,
visible: () =>
stores.policies.abilities(stores.auth.team?.id || "").createApiKey,
perform: ({ t, event }) => {
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
title: t("New API key"),
content: <ApiKeyNew onSubmit={stores.dialogs.closeAllModals} />,
});
},
});
export const copyApiKeyFactory = ({ apiKey }: { apiKey: ApiKey }) =>
export const copyApiKeyActionFactory = ({ apiKey }: { apiKey: ApiKey }) =>
createAction({
name: ({ t }) => t("Copy"),
analyticsName: "Copy API key",
@@ -44,7 +39,7 @@ export const copyApiKeyFactory = ({ apiKey }: { apiKey: ApiKey }) =>
},
});
export const revokeApiKeyFactory = ({ apiKey }: { apiKey: ApiKey }) =>
export const revokeApiKeyActionFactory = ({ apiKey }: { apiKey: ApiKey }) =>
createAction({
name: ({ t, isMenu }) =>
isMenu
+6 -10
View File
@@ -34,6 +34,7 @@ import {
createInternalLinkAction,
createActionWithChildren,
} from "~/actions";
import { dialogActionFactory } from "~/actions/definitions/common";
import { ActiveCollectionSection, CollectionSection } from "~/actions/sections";
import { setPersistedState } from "~/hooks/usePersistedState";
import {
@@ -77,22 +78,17 @@ export const openCollection = createActionWithChildren({
},
});
export const createCollection = createAction({
name: ({ t }) => t("New collection"),
export const createCollection = dialogActionFactory({
analyticsName: "New collection",
section: CollectionSection,
name: (t) => t("New collection"),
title: (t) => t("Create a collection"),
content: (onSubmit) => <CollectionNew onSubmit={onSubmit} />,
icon: <PlusIcon />,
keywords: "create",
stopEvent: true,
visible: ({ stores }) =>
stores.policies.abilities(stores.auth.team?.id || "").createCollection,
perform: ({ t, event, stores }) => {
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
title: t("Create a collection"),
content: <CollectionNew onSubmit={stores.dialogs.closeAllModals} />,
});
},
});
export const editCollection = createAction({
+41 -26
View File
@@ -1,38 +1,37 @@
import { DoneIcon, SmileyIcon, TrashIcon } from "outline-icons";
import copy from "copy-to-clipboard";
import { CopyIcon, DoneIcon, SmileyIcon, TrashIcon } from "outline-icons";
import { toast } from "sonner";
import type Comment from "~/models/Comment";
import CommentDeleteDialog from "~/components/CommentDeleteDialog";
import ViewReactionsDialog from "~/components/Reactions/ViewReactionsDialog";
import { createAction } from "..";
import { dialogActionFactory } from "./common";
import { ActiveDocumentSection } from "../sections";
import { commentPath, urlify } from "~/utils/routeHelpers";
export const deleteCommentFactory = ({
export const deleteCommentActionFactory = ({
comment,
onDelete,
}: {
comment: Comment;
onDelete: () => void;
}) =>
createAction({
name: ({ t }) => `${t("Delete")}`,
dialogActionFactory({
analyticsName: "Delete comment",
section: ActiveDocumentSection,
name: (t) => `${t("Delete")}`,
title: (t) => t("Delete comment"),
content: () => (
<CommentDeleteDialog comment={comment} onSubmit={onDelete} />
),
icon: <TrashIcon />,
keywords: "trash",
dangerous: true,
stopEvent: true,
visible: ({ stores }) => stores.policies.abilities(comment.id).delete,
perform: ({ t, stores, event }) => {
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
title: t("Delete comment"),
content: <CommentDeleteDialog comment={comment} onSubmit={onDelete} />,
});
},
});
export const resolveCommentFactory = ({
export const resolveCommentActionFactory = ({
comment,
onResolve,
}: {
@@ -54,7 +53,7 @@ export const resolveCommentFactory = ({
},
});
export const unresolveCommentFactory = ({
export const unresolveCommentActionFactory = ({
comment,
onUnresolve,
}: {
@@ -75,26 +74,42 @@ export const unresolveCommentFactory = ({
},
});
export const viewCommentReactionsFactory = ({
export const copyCommentLinkActionFactory = ({
comment,
}: {
comment: Comment;
}) =>
createAction({
name: ({ t }) => `${t("View reactions")}`,
name: ({ t }) => t("Copy link"),
analyticsName: "Copy comment link",
section: ActiveDocumentSection,
icon: <CopyIcon />,
keywords: "clipboard",
perform: ({ stores, t }) => {
const document = stores.documents.get(comment.documentId);
if (!document) {
return;
}
copy(urlify(commentPath(document, comment)));
toast.message(t("Link copied to clipboard"));
},
});
export const viewCommentReactionsActionFactory = ({
comment,
}: {
comment: Comment;
}) =>
dialogActionFactory({
analyticsName: "View comment reactions",
section: ActiveDocumentSection,
name: (t) => t("View reactions"),
title: (t) => t("Reactions"),
content: () => <ViewReactionsDialog model={comment} />,
icon: <SmileyIcon />,
stopEvent: true,
visible: ({ stores }) =>
stores.policies.abilities(comment.id).read &&
comment.reactions.length > 0,
perform: ({ t, stores, event }) => {
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
title: t("Reactions"),
content: <ViewReactionsDialog model={comment} />,
});
},
});
+99
View File
@@ -0,0 +1,99 @@
import type { TFunction } from "i18next";
import { InputIcon } from "outline-icons";
import stores from "~/stores";
import type { Action } from "~/types";
import { createAction } from "..";
/**
* Creates an action that opens a dialog, taking care of wiring the dialog's
* submit handler to close it again.
*
* @param analyticsName - untranslated name for analytics.
* @param section - the section the action belongs to.
* @param title - the dialog title.
* @param content - renders the dialog, given a handler to close it.
* @param name - the menu item label, defaults to the title with an ellipsis.
* @param icon - optional icon for the menu item.
* @param keywords - optional additional search terms for the command bar.
* @param visible - optional visibility predicate.
* @param dangerous - whether the action is destructive.
* @param width - optional dialog width.
* @param stopEvent - whether to suppress the triggering event before opening.
* @returns an action for use in menus.
*/
export const dialogActionFactory = ({
analyticsName,
section,
title,
content,
name,
icon,
keywords,
visible,
dangerous,
width,
stopEvent,
}: {
analyticsName: string;
section: Action["section"];
title: (t: TFunction) => string;
content: (onSubmit: () => void) => React.ReactNode;
name?: (t: TFunction) => string;
icon?: React.ReactNode;
keywords?: string;
visible?: Action["visible"];
dangerous?: boolean;
width?: string | number;
stopEvent?: boolean;
}) =>
createAction({
name: ({ t }) => (name ? name(t) : `${title(t)}`),
analyticsName,
section,
icon,
keywords,
visible,
dangerous,
perform: ({ t, event }) => {
if (stopEvent) {
event?.preventDefault();
event?.stopPropagation();
}
stores.dialogs.openModal({
title: title(t),
content: content(stores.dialogs.closeAllModals),
width,
});
},
});
/**
* Creates an action that begins renaming a model, either by switching an inline
* title into edit mode or by opening a rename dialog.
*
* @param section - the section the action belongs to.
* @param modelId - optional model to check the update ability against.
* @param onRename - invoked when the action is performed.
* @returns an action for use in menus.
*/
export const renameActionFactory = ({
section,
modelId,
onRename,
}: {
section: Action["section"];
modelId?: string;
onRename?: () => void;
}) =>
createAction({
name: ({ t }) => `${t("Rename")}`,
analyticsName: "Rename",
section,
icon: <InputIcon />,
visible: ({ stores: rootStore }) =>
!!onRename &&
(modelId ? rootStore.policies.abilities(modelId).update : true),
// Deferred a frame so the menu has closed before focus moves to the input.
perform: () => requestAnimationFrame(() => onRename?.()),
});
+7 -5
View File
@@ -9,7 +9,11 @@ import {
UserIcon,
} from "outline-icons";
import { toast } from "sonner";
import { createAction, createActionWithChildren } from "~/actions";
import {
createAction,
createActionWithChildren,
createInternalLinkAction,
} from "~/actions";
import { DeveloperSection } from "~/actions/sections";
import env from "~/env";
import { client } from "~/utils/ApiClient";
@@ -19,14 +23,12 @@ import { deleteAllDatabases } from "~/utils/developer";
import history from "~/utils/history";
import { homePath, debugPath } from "~/utils/routeHelpers";
export const goToDebug = createAction({
export const goToDebug = createInternalLinkAction({
name: "Go to debug screen",
icon: <BeakerIcon />,
section: DeveloperSection,
visible: () => env.ENVIRONMENT === "development",
perform: () => {
history.push(debugPath());
},
to: debugPath(),
});
export const copyId = createActionWithChildren({
+2 -2
View File
@@ -1208,7 +1208,7 @@ export const openRandomDocument = createAction({
},
});
export const searchDocumentsForQuery = (query: string) =>
export const searchDocumentsForQueryActionFactory = (query: string) =>
createInternalLinkAction({
id: "search",
name: ({ t }) =>
@@ -1608,7 +1608,7 @@ export const leaveDocument = createAction({
},
});
export const applyTemplateFactory = ({
export const applyTemplateActionFactory = ({
actions,
}: {
actions: (Action | ActionGroup | ActionSeparator)[];
+38 -12
View File
@@ -1,21 +1,47 @@
import { PlusIcon } from "outline-icons";
import { createAction } from "~/actions";
import { TeamSection } from "../sections";
import { PlusIcon, ReplaceIcon, TrashIcon } from "outline-icons";
import { dialogActionFactory } from "~/actions/definitions/common";
import { EmojiSecion, TeamSection } from "../sections";
import stores from "~/stores";
import type Emoji from "~/models/Emoji";
import { EmojiCreateDialog } from "~/components/EmojiDialog/EmojiCreateDialog";
import { EmojiDeleteDialog } from "~/components/EmojiDialog/EmojiDeleteDialog";
import { EmojiReplaceDialog } from "~/components/EmojiDialog/EmojiReplaceDialog";
export const createEmoji = createAction({
name: ({ t }) => `${t("New emoji")}`,
export const createEmoji = dialogActionFactory({
analyticsName: "Create emoji",
section: TeamSection,
name: (t) => `${t("New emoji")}`,
title: (t) => t("Upload emoji"),
content: (onSubmit) => <EmojiCreateDialog onSubmit={onSubmit} />,
icon: <PlusIcon />,
keywords: "emoji custom upload image",
section: TeamSection,
visible: () =>
stores.policies.abilities(stores.auth.team?.id || "").createEmoji,
perform: ({ t }) => {
stores.dialogs.openModal({
title: t("Upload emoji"),
content: <EmojiCreateDialog onSubmit={stores.dialogs.closeAllModals} />,
});
},
});
export const replaceEmojiActionFactory = (emoji: Emoji) =>
dialogActionFactory({
analyticsName: "Replace emoji",
section: EmojiSecion,
name: (t) => `${t("Replace")}`,
title: (t) => t("Replace image"),
content: (onSubmit) => (
<EmojiReplaceDialog emoji={emoji} onSubmit={onSubmit} />
),
icon: <ReplaceIcon />,
visible: () => stores.policies.abilities(emoji.id).update,
});
export const deleteEmojiActionFactory = (emoji: Emoji) =>
dialogActionFactory({
analyticsName: "Delete emoji",
section: EmojiSecion,
name: (t) => `${t("Delete")}`,
title: (t) => t("Delete Emoji"),
content: (onSubmit) => (
<EmojiDeleteDialog emoji={emoji} onSubmit={onSubmit} />
),
icon: <TrashIcon />,
dangerous: true,
visible: () => stores.policies.abilities(emoji.id).delete,
});
+48
View File
@@ -0,0 +1,48 @@
import { EditIcon, GroupIcon, TrashIcon } from "outline-icons";
import stores from "~/stores";
import type Group from "~/models/Group";
import {
DeleteGroupDialog,
EditGroupDialog,
} from "~/scenes/Settings/components/GroupDialogs";
import { createInternalLinkAction } from "~/actions";
import { dialogActionFactory } from "~/actions/definitions/common";
import { GroupSection } from "~/actions/sections";
import { settingsPath } from "~/utils/routeHelpers";
export const groupMembersActionFactory = (group: Group) =>
createInternalLinkAction({
name: ({ t }) => t("Members"),
analyticsName: "Group members",
section: GroupSection,
icon: <GroupIcon />,
visible: () => stores.policies.abilities(group.id).read,
to: settingsPath("groups", group.id, "members"),
});
export const editGroupActionFactory = (group: Group) =>
dialogActionFactory({
analyticsName: "Edit group",
section: GroupSection,
name: (t) => `${t("Edit")}`,
title: (t) => t("Edit group"),
content: (onSubmit) => (
<EditGroupDialog group={group} onSubmit={onSubmit} />
),
icon: <EditIcon />,
visible: () => stores.policies.abilities(group.id).update,
});
export const deleteGroupActionFactory = (group: Group) =>
dialogActionFactory({
analyticsName: "Delete group",
section: GroupSection,
name: (t) => `${t("Delete")}`,
title: (t) => t("Delete group"),
content: (onSubmit) => (
<DeleteGroupDialog group={group} onSubmit={onSubmit} />
),
icon: <TrashIcon />,
dangerous: true,
visible: () => stores.policies.abilities(group.id).delete,
});
+11 -14
View File
@@ -1,6 +1,6 @@
import { TrashIcon } from "outline-icons";
import stores from "~/stores";
import { createAction } from "..";
import { dialogActionFactory } from "./common";
import { SettingsSection } from "../sections";
import type Integration from "~/models/Integration";
import { DisconnectAnalyticsDialog } from "~/scenes/Settings/components/DisconnectAnalyticsDialog";
@@ -8,7 +8,7 @@ import type { IntegrationType } from "@shared/types";
import { settingsPath } from "@shared/utils/routeHelpers";
import history from "~/utils/history";
export const disconnectIntegrationFactory = (integration?: Integration) =>
export const disconnectIntegrationActionFactory = (integration?: Integration) =>
createAction({
name: ({ t }) => t("Disconnect"),
analyticsName: "Disconnect integration",
@@ -25,23 +25,20 @@ export const disconnectIntegrationFactory = (integration?: Integration) =>
},
});
export const disconnectAnalyticsIntegrationFactory = (
export const disconnectAnalyticsIntegrationActionFactory = (
integration?: Integration<IntegrationType.Analytics>
) =>
createAction({
name: ({ t }) => t("Disconnect analytics"),
dialogActionFactory({
analyticsName: "Disconnect analytics",
section: SettingsSection,
name: (t) => t("Disconnect analytics"),
title: (t) => t("Disconnect analytics"),
content: () =>
integration ? (
<DisconnectAnalyticsDialog integration={integration} />
) : null,
icon: <TrashIcon />,
keywords: "disconnect",
stopEvent: true,
visible: () => !!integration,
perform: ({ t, event }) => {
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
title: t("Disconnect analytics"),
content: <DisconnectAnalyticsDialog integration={integration!} />,
});
},
});
+3 -1
View File
@@ -46,7 +46,9 @@ export const navigateToHome = createInternalLinkAction({
visible: ({ location }) => location.pathname !== homePath(),
});
export const navigateToRecentSearchQuery = (searchQuery: SearchQuery) =>
export const navigateToRecentSearchQueryActionFactory = (
searchQuery: SearchQuery
) =>
createInternalLinkAction({
section: RecentSearchesSection,
name: searchQuery.query,
+5 -3
View File
@@ -23,7 +23,7 @@ export const markNotificationsAsArchived = createAction({
visible: ({ stores }) => stores.notifications.orderedData.length > 0,
});
export const notificationMarkRead = (notification: Notification) =>
export const notificationMarkReadActionFactory = (notification: Notification) =>
createAction({
name: ({ t }) => t("Mark as read"),
analyticsName: "Mark notification read",
@@ -33,7 +33,9 @@ export const notificationMarkRead = (notification: Notification) =>
visible: () => !notification.viewedAt,
});
export const notificationMarkUnread = (notification: Notification) =>
export const notificationMarkUnreadActionFactory = (
notification: Notification
) =>
createAction({
name: ({ t }) => t("Mark as unread"),
analyticsName: "Mark notification unread",
@@ -43,7 +45,7 @@ export const notificationMarkUnread = (notification: Notification) =>
visible: () => !!notification.viewedAt,
});
export const notificationArchive = (notification: Notification) =>
export const notificationArchiveActionFactory = (notification: Notification) =>
createAction({
name: ({ t }) => t("Archive"),
analyticsName: "Mark notification as archived",
@@ -0,0 +1,26 @@
import type OAuthAuthentication from "~/models/oauth/OAuthAuthentication";
import OAuthAuthenticationRevokeDialog from "~/scenes/Settings/components/OAuthAuthenticationRevokeDialog";
import { dialogActionFactory } from "./common";
import { SettingsSection } from "../sections";
export const revokeOAuthAuthenticationActionFactory = ({
oauthAuthentication,
}: {
oauthAuthentication: OAuthAuthentication;
}) =>
dialogActionFactory({
analyticsName: "Revoke App",
section: SettingsSection,
name: (t) => t("Revoke"),
title: (t) =>
t("Revoke {{ appName }}", {
appName: oauthAuthentication.oauthClient.name,
}),
content: (onSubmit) => (
<OAuthAuthenticationRevokeDialog
oauthAuthentication={oauthAuthentication}
onSubmit={onSubmit}
/>
),
dangerous: true,
});
+41 -12
View File
@@ -1,24 +1,53 @@
import { PlusIcon } from "outline-icons";
import stores from "~/stores";
import type OAuthClient from "~/models/oauth/OAuthClient";
import { OAuthClientNew } from "~/components/OAuthClient/OAuthClientNew";
import { createAction } from "..";
import OAuthClientDeleteDialog from "~/scenes/Settings/components/OAuthClientDeleteDialog";
import { createInternalLinkAction } from "..";
import { dialogActionFactory } from "./common";
import { SettingsSection } from "../sections";
import { settingsPath } from "~/utils/routeHelpers";
export const createOAuthClient = createAction({
name: ({ t }) => t("New App"),
export const createOAuthClient = dialogActionFactory({
analyticsName: "New App",
section: SettingsSection,
name: (t) => t("New App"),
title: (t) => t("New Application"),
content: (onSubmit) => <OAuthClientNew onSubmit={onSubmit} />,
icon: <PlusIcon />,
keywords: "create",
stopEvent: true,
visible: () =>
stores.policies.abilities(stores.auth.team?.id || "").createOAuthClient,
perform: ({ t, event }) => {
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
title: t("New Application"),
content: <OAuthClientNew onSubmit={stores.dialogs.closeAllModals} />,
});
},
});
export const editOAuthClientActionFactory = ({
oauthClient,
visible,
}: {
oauthClient: OAuthClient;
visible?: boolean;
}) =>
createInternalLinkAction({
name: ({ t }) => `${t("Edit")}`,
analyticsName: "Edit App",
section: SettingsSection,
visible,
to: settingsPath("applications", oauthClient.id),
});
export const deleteOAuthClientActionFactory = ({
oauthClient,
}: {
oauthClient: OAuthClient;
}) =>
dialogActionFactory({
analyticsName: "Delete App",
section: SettingsSection,
name: (t) => `${t("Delete")}`,
title: (t) => t("Delete app"),
content: (onSubmit) => (
<OAuthClientDeleteDialog oauthClient={oauthClient} onSubmit={onSubmit} />
),
dangerous: true,
});
+39 -34
View File
@@ -5,7 +5,12 @@ import { toast } from "sonner";
import { ExportContentType } from "@shared/types";
import Revision from "~/models/Revision";
import stores from "~/stores";
import { createAction, createActionWithChildren } from "~/actions";
import type { ActionContext } from "~/types";
import {
createAction,
createActionWithChildren,
createInternalLinkAction,
} from "~/actions";
import { RevisionSection } from "~/actions/sections";
import env from "~/env";
import history from "~/utils/history";
@@ -15,36 +20,36 @@ import {
urlify,
} from "~/utils/routeHelpers";
export const restoreRevision = createAction({
function getActiveRevisionId({ location, getActiveModel }: ActionContext) {
const match = matchPath<{ revisionId: string }>(location.pathname, {
path: matchDocumentHistory,
});
return getActiveModel(Revision)?.id ?? match?.params.revisionId;
}
export const restoreRevision = createInternalLinkAction({
name: ({ t }) => t("Restore"),
analyticsName: "Restore revision",
icon: <RestoreIcon />,
section: RevisionSection,
visible: ({ activeDocumentId }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).update,
perform: async ({ event, location, activeDocumentId, getActiveModel }) => {
event?.preventDefault();
if (!activeDocumentId) {
return;
visible: (context) =>
!!context.activeDocumentId &&
stores.policies.abilities(context.activeDocumentId).update &&
!!getActiveRevisionId(context),
to: (context) => {
const revisionId = getActiveRevisionId(context);
const document = context.activeDocumentId
? stores.documents.get(context.activeDocumentId)
: undefined;
if (!document || !revisionId) {
return context.location;
}
const match = matchPath<{ revisionId: string }>(location.pathname, {
path: matchDocumentHistory,
});
const revisionId = getActiveModel(Revision)?.id ?? match?.params.revisionId;
if (!revisionId) {
return;
}
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
history.push(document.url, {
restore: true,
revisionId,
});
return {
pathname: document.url,
state: { restore: true, revisionId },
};
},
});
@@ -80,7 +85,7 @@ export const deleteRevision = createAction({
},
});
export const copyLinkToRevision = (revisionId: string) =>
export const copyLinkToRevisionActionFactory = (revisionId: string) =>
createAction({
name: ({ t }) => t("Copy link"),
analyticsName: "Copy link to revision",
@@ -101,13 +106,13 @@ export const copyLinkToRevision = (revisionId: string) =>
copy(url, {
format: "text/plain",
onCopy: () => {
toast.message(t("Link copied"));
toast.message(t("Link copied to clipboard"));
},
});
},
});
export const downloadRevisionAsHTML = (revisionId: string) =>
export const downloadRevisionAsHTMLActionFactory = (revisionId: string) =>
createAction({
name: ({ t }) => t("HTML"),
analyticsName: "Download revision as HTML",
@@ -124,7 +129,7 @@ export const downloadRevisionAsHTML = (revisionId: string) =>
},
});
export const downloadRevisionAsPDF = (revisionId: string) =>
export const downloadRevisionAsPDFActionFactory = (revisionId: string) =>
createAction({
name: ({ t }) => t("PDF"),
analyticsName: "Download revision as PDF",
@@ -147,7 +152,7 @@ export const downloadRevisionAsPDF = (revisionId: string) =>
},
});
export const downloadRevisionAsMarkdown = (revisionId: string) =>
export const downloadRevisionAsMarkdownActionFactory = (revisionId: string) =>
createAction({
name: ({ t }) => t("Markdown"),
analyticsName: "Download revision as Markdown",
@@ -164,7 +169,7 @@ export const downloadRevisionAsMarkdown = (revisionId: string) =>
},
});
export const downloadRevision = (revisionId: string) =>
export const downloadRevisionActionFactory = (revisionId: string) =>
createActionWithChildren({
name: ({ t, isMenu }) => (isMenu ? t("Download") : t("Download revision")),
analyticsName: "Download revision",
@@ -175,9 +180,9 @@ export const downloadRevision = (revisionId: string) =>
!!activeDocumentId &&
stores.policies.abilities(activeDocumentId).download,
children: [
downloadRevisionAsHTML(revisionId),
downloadRevisionAsPDF(revisionId),
downloadRevisionAsMarkdown(revisionId),
downloadRevisionAsHTMLActionFactory(revisionId),
downloadRevisionAsPDFActionFactory(revisionId),
downloadRevisionAsMarkdownActionFactory(revisionId),
],
});
+3 -3
View File
@@ -7,7 +7,7 @@ import { ShareSection } from "../sections";
import env from "~/env";
import { toast } from "sonner";
export const copyShareUrlFactory = ({ share }: { share: Share }) =>
export const copyShareUrlActionFactory = ({ share }: { share: Share }) =>
createAction({
name: ({ t }) => t("Copy link"),
analyticsName: "Copy share link",
@@ -22,7 +22,7 @@ export const copyShareUrlFactory = ({ share }: { share: Share }) =>
},
});
export const goToShareSourceFactory = ({ share }: { share: Share }) =>
export const goToShareSourceActionFactory = ({ share }: { share: Share }) =>
createInternalLinkAction({
name: ({ t }) =>
share.collectionId ? t("Go to collection") : t("Go to document"),
@@ -35,7 +35,7 @@ export const goToShareSourceFactory = ({ share }: { share: Share }) =>
},
});
export const revokeShareFactory = ({
export const revokeShareActionFactory = ({
share,
can,
}: {
+7 -12
View File
@@ -12,6 +12,7 @@ import {
} from "~/actions";
import type { ActionContext, ExternalLinkAction } from "~/types";
import Desktop from "~/utils/Desktop";
import { dialogActionFactory } from "./common";
import { TeamSection } from "../sections";
export const switchTeamsList = ({ stores }: { stores: RootStore }) =>
@@ -74,22 +75,16 @@ export const createTeam = createAction({
},
});
export const desktopLoginTeam = createAction({
name: ({ t }) => t("Login to workspace"),
export const desktopLoginTeam = dialogActionFactory({
analyticsName: "Login to workspace",
keywords: "change switch workspace organization team",
section: TeamSection,
name: (t) => t("Login to workspace"),
title: (t) => t("Login to workspace"),
content: () => <LoginDialog />,
icon: <ArrowIcon />,
keywords: "change switch workspace organization team",
stopEvent: true,
visible: () => Desktop.isElectron(),
perform: ({ t, event, stores }) => {
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
title: t("Login to workspace"),
content: <LoginDialog />,
});
},
});
const StyledTeamLogo = styled(TeamLogo)`
+19
View File
@@ -3,6 +3,7 @@ import {
CaseSensitiveIcon,
CollectionIcon,
CopyIcon,
DuplicateIcon,
MoveIcon,
NewDocumentIcon,
PlusIcon,
@@ -177,6 +178,24 @@ export const createDocumentFromTemplate = createInternalLinkAction({
},
});
export const duplicateTemplate = createAction({
name: ({ t }) => t("Duplicate"),
analyticsName: "Duplicate template",
section: ActiveTemplateSection,
icon: <DuplicateIcon />,
keywords: "copy",
visible: ({ getActivePolicies }) =>
getActivePolicies(Template).some((policy) => policy.abilities.duplicate),
perform: async ({ getActiveModel, stores }) => {
const template = getActiveModel(Template);
if (!template) {
return;
}
await stores.templates.duplicate(template);
},
});
export const copyTemplateLink = createAction({
name: ({ t }) => t("Copy link"),
analyticsName: "Copy template link",
+119 -28
View File
@@ -1,45 +1,52 @@
import { PlusIcon } from "outline-icons";
import type { UserRole } from "@shared/types";
import { toast } from "sonner";
import { UserRole } from "@shared/types";
import { errToString } from "@shared/utils/error";
import { UserRoleHelper } from "@shared/utils/UserRoleHelper";
import stores from "~/stores";
import type User from "~/models/User";
import Invite from "~/scenes/Invite";
import {
UserChangeAvatarDialog,
UserChangeEmailDialog,
UserChangeNameDialog,
UserChangeRoleDialog,
UserDeleteDialog,
UserSuspendDialog,
} from "~/components/UserDialogs";
import { createAction } from "~/actions";
import { createAction, createActionWithChildren } from "~/actions";
import { dialogActionFactory } from "~/actions/definitions/common";
import { UserSection } from "~/actions/sections";
export const inviteUser = createAction({
name: ({ t }) => `${t("Invite people")}`,
export const inviteUser = dialogActionFactory({
analyticsName: "Invite people",
section: UserSection,
name: (t) => `${t("Invite people")}`,
title: (t) => t("Invite to workspace"),
content: (onSubmit) => <Invite onSubmit={onSubmit} />,
icon: <PlusIcon />,
keywords: "team member workspace user",
section: UserSection,
width: "500px",
visible: () =>
stores.policies.abilities(stores.auth.team?.id || "").inviteUser,
perform: ({ t }) => {
stores.dialogs.openModal({
title: t("Invite to workspace"),
width: "500px",
content: <Invite onSubmit={stores.dialogs.closeAllModals} />,
});
},
});
export const updateUserRoleActionFactory = (user: User, role: UserRole) =>
createAction({
name: ({ t }) =>
UserRoleHelper.isRoleHigher(role, user!.role)
dialogActionFactory({
analyticsName: "Update user role",
section: UserSection,
name: (t) =>
UserRoleHelper.isRoleHigher(role, user.role)
? `${t("Promote to {{ role }}", {
role: UserRoleHelper.displayName(role, t),
})}…`
: `${t("Demote to {{ role }}", {
role: UserRoleHelper.displayName(role, t),
})}…`,
analyticsName: "Update user role",
section: UserSection,
title: (t) => t("Update role"),
content: (onSubmit) => (
<UserChangeRoleDialog user={user} role={role} onSubmit={onSubmit} />
),
visible: () => {
const can = stores.policies.abilities(user.id);
@@ -49,18 +56,102 @@ export const updateUserRoleActionFactory = (user: User, role: UserRole) =>
? can.demote
: false;
},
perform: ({ t }) => {
stores.dialogs.openModal({
title: t("Update role"),
content: (
<UserChangeRoleDialog
user={user}
role={role}
onSubmit={stores.dialogs.closeAllModals}
/>
),
});
});
export const changeUserRoleActionFactory = (user: User) =>
createActionWithChildren({
name: ({ t }) => t("Change role"),
analyticsName: "Change user role",
section: UserSection,
visible: () => {
const can = stores.policies.abilities(user.id);
return can.demote || can.promote;
},
children: [UserRole.Admin, UserRole.Member, UserRole.Viewer].map((role) =>
updateUserRoleActionFactory(user, role)
),
});
export const changeUserAvatarActionFactory = (user: User) =>
dialogActionFactory({
analyticsName: "Change user avatar",
section: UserSection,
title: (t) => t("Change profile picture"),
content: (onSubmit) => (
<UserChangeAvatarDialog user={user} onSubmit={onSubmit} />
),
visible: () => stores.policies.abilities(user.id).update,
});
export const changeUserNameActionFactory = (user: User) =>
dialogActionFactory({
analyticsName: "Change user name",
section: UserSection,
title: (t) => t("Change name"),
content: (onSubmit) => (
<UserChangeNameDialog user={user} onSubmit={onSubmit} />
),
visible: () => stores.policies.abilities(user.id).update,
});
export const changeUserEmailActionFactory = (user: User) =>
dialogActionFactory({
analyticsName: "Change user email",
section: UserSection,
title: (t) => t("Change email"),
content: (onSubmit) => (
<UserChangeEmailDialog user={user} onSubmit={onSubmit} />
),
visible: () => stores.policies.abilities(user.id).update,
});
export const suspendUserActionFactory = (user: User) =>
dialogActionFactory({
analyticsName: "Suspend user",
section: UserSection,
title: (t) => t("Suspend user"),
content: (onSubmit) => (
<UserSuspendDialog user={user} onSubmit={onSubmit} />
),
dangerous: true,
visible: () => !user.isInvited && !user.isSuspended,
});
export const resendInviteActionFactory = (user: User) =>
createAction({
name: ({ t }) => t("Resend invite"),
analyticsName: "Resend invite",
section: UserSection,
visible: () => stores.policies.abilities(user.id).resendInvite,
perform: async ({ t }) => {
try {
await stores.users.resendInvite(user);
toast.success(
t("Invite was resent to {{ userName }}", { userName: user.name })
);
} catch (err) {
toast.error(errToString(err));
}
},
});
export const revokeInviteActionFactory = (user: User) =>
createAction({
name: ({ t }) => `${t("Revoke invite")}`,
analyticsName: "Revoke invite",
section: UserSection,
dangerous: true,
visible: () => user.isInvited,
perform: () => stores.users.delete(user),
});
export const activateUserActionFactory = (user: User) =>
createAction({
name: ({ t }) => t("Activate user"),
analyticsName: "Activate user",
section: UserSection,
visible: () => !user.isInvited && user.isSuspended,
perform: () => stores.users.activate(user),
});
export const deleteUserActionFactory = (userId: string) =>
@@ -0,0 +1,43 @@
import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import type Emoji from "~/models/Emoji";
interface Props {
/** The emoji being deleted. */
emoji: Emoji;
/** Callback invoked after a successful deletion. */
onSubmit: () => void;
}
/**
* Dialog confirming deletion of an existing custom emoji.
*/
export function EmojiDeleteDialog({ emoji, onSubmit }: Props) {
const { t } = useTranslation();
const handleSubmit = async () => {
await emoji.delete();
onSubmit();
toast.success(t("Emoji deleted"));
};
return (
<ConfirmationDialog
onSubmit={handleSubmit}
submitText={t("Im sure Delete")}
savingText={`${t("Deleting")}`}
danger
>
<Trans
defaults="Are you sure you want to delete the <em>{{emojiName}}</em> emoji? You will no longer be able to use it in your documents or collections."
values={{
emojiName: emoji.name,
}}
components={{
em: <strong />,
}}
/>
</ConfirmationDialog>
);
}
@@ -15,13 +15,12 @@ import Time from "../Time";
import { UnreadBadge } from "../UnreadBadge";
import lazyWithRetry from "~/utils/lazyWithRetry";
import { ContextMenu } from "../Menu/ContextMenu";
import { createActionWithChildren } from "~/actions";
import {
notificationMarkRead,
notificationMarkUnread,
notificationArchive,
notificationMarkReadActionFactory,
notificationMarkUnreadActionFactory,
notificationArchiveActionFactory,
} from "~/actions/definitions/notifications";
import { NotificationSection } from "~/actions/sections";
import { useMenuAction } from "~/hooks/useMenuAction";
import AccessRequestActions from "./AccessRequestActions";
const CommentEditor = lazyWithRetry(
@@ -56,19 +55,15 @@ function NotificationListItem({ notification, onNavigate }: Props) {
onNavigate();
};
const menuAction = React.useMemo(
() =>
createActionWithChildren({
name: ({ t }) => t("Notification options"),
section: NotificationSection,
children: [
notificationMarkRead(notification),
notificationMarkUnread(notification),
notificationArchive(notification),
],
}),
const actions = React.useMemo(
() => [
notificationMarkReadActionFactory(notification),
notificationMarkUnreadActionFactory(notification),
notificationArchiveActionFactory(notification),
],
[notification]
);
const menuAction = useMenuAction(actions);
return (
<ContextMenu action={menuAction} ariaLabel={t("Notification options")}>
+6 -4
View File
@@ -2,8 +2,8 @@ import { useKBar } from "kbar";
import { observer } from "mobx-react";
import { useEffect, useRef } from "react";
import { Minute } from "@shared/utils/time";
import { searchDocumentsForQuery } from "~/actions/definitions/documents";
import { navigateToRecentSearchQuery } from "~/actions/definitions/navigation";
import { searchDocumentsForQueryActionFactory } from "~/actions/definitions/documents";
import { navigateToRecentSearchQueryActionFactory } from "~/actions/definitions/navigation";
import useCommandBarActions from "~/hooks/useCommandBarActions";
import useStores from "~/hooks/useStores";
@@ -51,11 +51,13 @@ function SearchActions() {
}, [documents, searchQuery]);
useCommandBarActions(
searchQuery ? [searchDocumentsForQuery(searchQuery)] : [],
searchQuery ? [searchDocumentsForQueryActionFactory(searchQuery)] : [],
[searchQuery]
);
useCommandBarActions(searches.recent.map(navigateToRecentSearchQuery));
useCommandBarActions(
searches.recent.map(navigateToRecentSearchQueryActionFactory)
);
return null;
}
+6 -3
View File
@@ -1,7 +1,7 @@
import { useMemo } from "react";
import {
copyApiKeyFactory,
revokeApiKeyFactory,
copyApiKeyActionFactory,
revokeApiKeyActionFactory,
} from "~/actions/definitions/apiKeys";
import type ApiKey from "~/models/ApiKey";
import { useMenuAction } from "~/hooks/useMenuAction";
@@ -14,7 +14,10 @@ import { useMenuAction } from "~/hooks/useMenuAction";
*/
export function useApiKeyMenuActions(apiKey: ApiKey) {
const actions = useMemo(
() => [copyApiKeyFactory({ apiKey }), revokeApiKeyFactory({ apiKey })],
() => [
copyApiKeyActionFactory({ apiKey }),
revokeApiKeyActionFactory({ apiKey }),
],
[apiKey]
);
return useMenuAction(actions);
+6 -16
View File
@@ -1,6 +1,6 @@
import { useMemo } from "react";
import { useMenuAction } from "./useMenuAction";
import { ActionSeparator, createAction } from "~/actions";
import { ActionSeparator } from "~/actions";
import {
deleteCollection,
editCollection,
@@ -19,11 +19,8 @@ import {
openCollectionInSplit,
sortCollection,
} from "~/actions/definitions/collections";
import { renameActionFactory } from "~/actions/definitions/common";
import { ActiveCollectionSection } from "~/actions/sections";
import { InputIcon } from "outline-icons";
import usePolicy from "./usePolicy";
import useStores from "./useStores";
import { useTranslation } from "react-i18next";
type Props = {
/** Collection ID for which the actions are generated */
@@ -33,11 +30,6 @@ type Props = {
};
export function useCollectionMenuAction({ collectionId, onRename }: Props) {
const { collections } = useStores();
const { t } = useTranslation();
const collection = collections.get(collectionId);
const can = usePolicy(collection);
const actions = useMemo(
() => [
restoreCollection,
@@ -49,12 +41,10 @@ export function useCollectionMenuAction({ collectionId, onRename }: Props) {
createDocument,
importDocument,
ActionSeparator,
createAction({
name: `${t("Rename")}`,
renameActionFactory({
section: ActiveCollectionSection,
icon: <InputIcon />,
visible: !!can.update && !!onRename,
perform: () => requestAnimationFrame(() => onRename?.()),
modelId: collectionId,
onRename,
}),
editCollection,
editCollectionPermissions,
@@ -67,7 +57,7 @@ export function useCollectionMenuAction({ collectionId, onRename }: Props) {
ActionSeparator,
deleteCollection,
],
[t, can.update, onRename]
[collectionId, onRename]
);
return useMenuAction(actions);
+8 -11
View File
@@ -1,6 +1,6 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { InputIcon, SearchIcon } from "outline-icons";
import { SearchIcon } from "outline-icons";
import { ActionSeparator, createAction, createRootMenuAction } from "~/actions";
import {
restoreDocument,
@@ -20,7 +20,7 @@ import {
unpublishDocument,
archiveDocument,
moveDocument,
applyTemplateFactory,
applyTemplateActionFactory,
pinDocument,
openDocumentComments,
openDocumentHistory,
@@ -36,10 +36,10 @@ import {
leaveDocument,
permanentlyDeleteDocument,
} from "~/actions/definitions/documents";
import { renameActionFactory } from "~/actions/definitions/common";
import { ActiveDocumentSection } from "~/actions/sections";
import useMobile from "./useMobile";
import type Template from "~/models/Template";
import usePolicy from "./usePolicy";
import { useTemplateMenuActions } from "./useTemplateMenuActions";
type Props = {
@@ -61,7 +61,6 @@ export function useDocumentMenuAction({
}: Props) {
const { t } = useTranslation();
const isMobile = useMobile();
const can = usePolicy(documentId);
const templateMenuActions = useTemplateMenuActions({
documentId,
@@ -86,12 +85,10 @@ export function useDocumentMenuAction({
}),
ActionSeparator,
editDocument,
createAction({
name: `${t("Rename")}`,
renameActionFactory({
section: ActiveDocumentSection,
icon: <InputIcon />,
visible: !!can.update && !!onRename,
perform: () => requestAnimationFrame(() => onRename?.()),
modelId: documentId,
onRename,
}),
shareDocument,
createTemplateFromDocument,
@@ -100,7 +97,7 @@ export function useDocumentMenuAction({
unpublishDocument,
archiveDocument,
moveDocument,
applyTemplateFactory({ actions: templateMenuActions }),
applyTemplateActionFactory({ actions: templateMenuActions }),
importDocument,
createNewDocument,
createNewDocumentInAlphabeticalCollection,
@@ -121,6 +118,6 @@ export function useDocumentMenuAction({
permanentlyDeleteDocument,
leaveDocument,
]),
[t, isMobile, templateMenuActions, can.update, onFindAndReplace, onRename]
[t, isMobile, templateMenuActions, documentId, onFindAndReplace, onRename]
);
}
+18 -125
View File
@@ -1,135 +1,28 @@
import * as React from "react";
import { ReplaceIcon, TrashIcon } from "outline-icons";
import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useMemo } from "react";
import type Emoji from "~/models/Emoji";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import { EmojiReplaceDialog } from "~/components/EmojiDialog/EmojiReplaceDialog";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { createAction } from "~/actions";
import { EmojiSecion } from "~/actions/sections";
import {
deleteEmojiActionFactory,
replaceEmojiActionFactory,
} from "~/actions/definitions/emojis";
import { useMenuAction } from "~/hooks/useMenuAction";
/**
* Hook that constructs the action menu for emoji management operations.
*
* @param targetEmoji - the emoji to build actions for, or null to skip.
* @returns action with children for use in menus, or undefined if emoji is null.
* @returns action with children for use in menus.
*/
export function useEmojiMenuActions(targetEmoji: Emoji | null) {
const { t } = useTranslation();
const { dialogs } = useStores();
const can = usePolicy(targetEmoji ?? ({} as Emoji));
const openReplaceDialog = React.useCallback(() => {
if (!targetEmoji) {
return;
}
dialogs.openModal({
title: t("Replace image"),
content: (
<EmojiReplaceDialog
emoji={targetEmoji}
onSubmit={dialogs.closeAllModals}
/>
),
});
}, [t, targetEmoji, dialogs]);
const openDeleteDialog = React.useCallback(() => {
if (!targetEmoji) {
return;
}
dialogs.openModal({
title: t("Delete Emoji"),
content: (
<DeleteEmojiDialog
emoji={targetEmoji}
onSubmit={dialogs.closeAllModals}
/>
),
});
}, [t, targetEmoji, dialogs]);
const actionList = React.useMemo(() => {
if (!targetEmoji) {
return [];
}
const actions = [];
if (can.update) {
actions.push(
createAction({
name: `${t("Replace")}`,
icon: <ReplaceIcon />,
section: EmojiSecion,
visible: true,
perform: openReplaceDialog,
})
);
}
if (can.delete) {
actions.push(
createAction({
name: `${t("Delete")}`,
icon: <TrashIcon />,
section: EmojiSecion,
visible: true,
dangerous: true,
perform: openDeleteDialog,
})
);
}
return actions;
}, [
t,
targetEmoji,
can.update,
can.delete,
openReplaceDialog,
openDeleteDialog,
]);
return useMenuAction(actionList);
}
const DeleteEmojiDialog = ({
emoji,
onSubmit,
}: {
emoji: Emoji;
onSubmit: () => void;
}) => {
const { t } = useTranslation();
const handleSubmit = async () => {
if (emoji) {
await emoji.delete();
onSubmit();
toast.success(t("Emoji deleted"));
}
};
return (
<ConfirmationDialog
onSubmit={handleSubmit}
submitText={t("I'm sure Delete")}
savingText={`${t("Deleting")}`}
danger
>
<Trans
defaults="Are you sure you want to delete the <em>{{emojiName}}</em> emoji? You will no longer be able to use it in your documents or collections."
values={{
emojiName: emoji.name,
}}
components={{
em: <strong />,
}}
/>
</ConfirmationDialog>
const actions = useMemo(
() =>
targetEmoji
? [
replaceEmojiActionFactory(targetEmoji),
deleteEmojiActionFactory(targetEmoji),
]
: [],
[targetEmoji]
);
};
return useMenuAction(actions);
}
+14 -96
View File
@@ -1,22 +1,13 @@
import * as React from "react";
import { EditIcon, GroupIcon, TrashIcon } from "outline-icons";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import { useMemo } from "react";
import type Group from "~/models/Group";
import { ActionSeparator, createExternalLinkAction } from "~/actions";
import {
DeleteGroupDialog,
EditGroupDialog,
} from "~/scenes/Settings/components/GroupDialogs";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import {
ActionSeparator,
createAction,
createExternalLinkAction,
} from "~/actions";
deleteGroupActionFactory,
editGroupActionFactory,
groupMembersActionFactory,
} from "~/actions/definitions/groups";
import { GroupSection } from "~/actions/sections";
import { useMenuAction } from "~/hooks/useMenuAction";
import { settingsPath } from "~/utils/routeHelpers";
interface Options {
/** Whether to hide the "Members" navigation action. */
@@ -28,87 +19,24 @@ interface Options {
*
* @param targetGroup - the group to build actions for, or null to skip.
* @param options - optional configuration for the menu.
* @returns action with children for use in menus, or undefined if group is null.
* @returns action with children for use in menus.
*/
export function useGroupMenuActions(
targetGroup: Group | null,
options?: Options
) {
const { t } = useTranslation();
const { dialogs } = useStores();
const history = useHistory();
const can = usePolicy(targetGroup ?? ({} as Group));
const navigateToMembers = React.useCallback(() => {
if (!targetGroup) {
return;
}
history.push(settingsPath("groups", targetGroup.id, "members"));
}, [targetGroup, history]);
const openEditDialog = React.useCallback(() => {
if (!targetGroup) {
return;
}
dialogs.openModal({
title: t("Edit group"),
content: (
<EditGroupDialog
group={targetGroup}
onSubmit={dialogs.closeAllModals}
/>
),
});
}, [t, targetGroup, dialogs]);
const openDeleteDialog = React.useCallback(() => {
if (!targetGroup) {
return;
}
dialogs.openModal({
title: t("Delete group"),
content: (
<DeleteGroupDialog
group={targetGroup}
onSubmit={dialogs.closeAllModals}
/>
),
});
}, [t, targetGroup, dialogs]);
const actionList = React.useMemo(
const actions = useMemo(
() =>
!targetGroup
? []
: [
...(options?.hideMembers
? []
: [
createAction({
name: t("Members"),
icon: <GroupIcon />,
section: GroupSection,
visible: can.read,
perform: navigateToMembers,
}),
ActionSeparator,
]),
createAction({
name: `${t("Edit")}`,
icon: <EditIcon />,
section: GroupSection,
visible: can.update,
perform: openEditDialog,
}),
createAction({
name: `${t("Delete")}`,
icon: <TrashIcon />,
section: GroupSection,
visible: can.delete,
dangerous: true,
perform: openDeleteDialog,
}),
: [groupMembersActionFactory(targetGroup), ActionSeparator]),
editGroupActionFactory(targetGroup),
deleteGroupActionFactory(targetGroup),
ActionSeparator,
// Read-only rows surfacing the group's external identifiers.
createExternalLinkAction({
name: targetGroup.externalId ?? "",
section: GroupSection,
@@ -124,18 +52,8 @@ export function useGroupMenuActions(
url: "",
}),
],
[
t,
targetGroup,
can.read,
can.update,
can.delete,
options?.hideMembers,
navigateToMembers,
openEditDialog,
openDeleteDialog,
]
[targetGroup, options?.hideMembers]
);
return useMenuAction(actionList);
return useMenuAction(actions);
}
+6 -6
View File
@@ -3,9 +3,9 @@ import type Share from "~/models/Share";
import usePolicy from "~/hooks/usePolicy";
import { ActionSeparator } from "~/actions";
import {
copyShareUrlFactory,
goToShareSourceFactory,
revokeShareFactory,
copyShareUrlActionFactory,
goToShareSourceActionFactory,
revokeShareActionFactory,
} from "~/actions/definitions/shares";
import { useMenuAction } from "~/hooks/useMenuAction";
@@ -23,10 +23,10 @@ export function useShareMenuActions(targetShare: Share | null) {
!targetShare
? []
: [
copyShareUrlFactory({ share: targetShare }),
goToShareSourceFactory({ share: targetShare }),
copyShareUrlActionFactory({ share: targetShare }),
goToShareSourceActionFactory({ share: targetShare }),
ActionSeparator,
revokeShareFactory({ share: targetShare, can }),
revokeShareActionFactory({ share: targetShare, can }),
],
[targetShare, can]
);
+7 -14
View File
@@ -1,5 +1,5 @@
import * as React from "react";
import { DuplicateIcon, EditIcon } from "outline-icons";
import { EditIcon } from "outline-icons";
import { useTranslation } from "react-i18next";
import type Template from "~/models/Template";
import { ActionSeparator, createAction } from "~/actions";
@@ -7,10 +7,11 @@ import {
copyTemplate,
createDocumentFromTemplate,
deleteTemplate,
duplicateTemplate,
moveTemplate,
} from "~/actions/definitions/templates";
import { ActiveTemplateSection } from "~/actions/sections";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { useMenuAction } from "~/hooks/useMenuAction";
/**
@@ -25,10 +26,8 @@ export function useTemplateSettingsActions(
onEdit?: () => void
) {
const { t } = useTranslation();
const { templates } = useStores();
const can = usePolicy(template ?? ({} as Template));
const can = usePolicy(template);
const section = "Template";
const actions = React.useMemo(
() =>
!template
@@ -38,16 +37,10 @@ export function useTemplateSettingsActions(
name: `${t("Edit")}`,
visible: !!can.update && !!onEdit,
icon: <EditIcon />,
section,
section: ActiveTemplateSection,
perform: () => onEdit?.(),
}),
createAction({
name: t("Duplicate"),
visible: !!can.duplicate,
icon: <DuplicateIcon />,
section,
perform: () => templates.duplicate(template),
}),
duplicateTemplate,
moveTemplate,
ActionSeparator,
createDocumentFromTemplate,
@@ -55,7 +48,7 @@ export function useTemplateSettingsActions(
ActionSeparator,
deleteTemplate,
],
[can.update, can.duplicate, onEdit, t, template, templates]
[can.update, onEdit, t, template]
);
return useMenuAction(actions);
+26 -196
View File
@@ -1,215 +1,45 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { UserRole } from "@shared/types";
import { useMemo } from "react";
import type User from "~/models/User";
import { ActionSeparator } from "~/actions";
import {
ActionSeparator,
createAction,
createActionWithChildren,
} from "~/actions";
import {
activateUserActionFactory,
changeUserAvatarActionFactory,
changeUserEmailActionFactory,
changeUserNameActionFactory,
changeUserRoleActionFactory,
deleteUserActionFactory,
updateUserRoleActionFactory,
resendInviteActionFactory,
revokeInviteActionFactory,
suspendUserActionFactory,
} from "~/actions/definitions/users";
import { UserSection } from "~/actions/sections";
import { useMenuAction } from "~/hooks/useMenuAction";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import {
UserSuspendDialog,
UserChangeNameDialog,
UserChangeEmailDialog,
UserChangeAvatarDialog,
} from "~/components/UserDialogs";
/**
* Hook that constructs the action menu for user management operations.
*
* @param targetUser - the user to build actions for, or null to skip.
* @returns action with children for use in menus, or undefined if user is null.
* @returns action with children for use in menus.
*/
export function useUserMenuActions(targetUser: User | null) {
const { users, dialogs } = useStores();
const { t } = useTranslation();
const can = usePolicy(targetUser ?? ({} as User));
const openAvatarDialog = React.useCallback(() => {
if (!targetUser) {
return;
}
dialogs.openModal({
title: t("Change profile picture"),
content: (
<UserChangeAvatarDialog
user={targetUser}
onSubmit={dialogs.closeAllModals}
/>
),
});
}, [dialogs, t, targetUser]);
const openNameDialog = React.useCallback(() => {
if (!targetUser) {
return;
}
dialogs.openModal({
title: t("Change name"),
content: (
<UserChangeNameDialog
user={targetUser}
onSubmit={dialogs.closeAllModals}
/>
),
});
}, [dialogs, t, targetUser]);
const openEmailDialog = React.useCallback(() => {
if (!targetUser) {
return;
}
dialogs.openModal({
title: t("Change email"),
content: (
<UserChangeEmailDialog
user={targetUser}
onSubmit={dialogs.closeAllModals}
/>
),
});
}, [dialogs, t, targetUser]);
const openSuspendDialog = React.useCallback(() => {
if (!targetUser) {
return;
}
dialogs.openModal({
title: t("Suspend user"),
content: (
<UserSuspendDialog
user={targetUser}
onSubmit={dialogs.closeAllModals}
/>
),
});
}, [dialogs, t, targetUser]);
const revokeInvitation = React.useCallback(async () => {
if (!targetUser) {
return;
}
await users.delete(targetUser);
}, [users, targetUser]);
const resendInvitation = React.useCallback(async () => {
if (!targetUser) {
return;
}
try {
await users.resendInvite(targetUser);
toast.success(t(`Invite was resent to ${targetUser.name}`));
} catch (err) {
toast.error(
err instanceof Error
? err.message
: t(`An error occurred while sending the invite`)
);
}
}, [users, targetUser, t]);
const activateUser = React.useCallback(async () => {
if (!targetUser) {
return;
}
await users.activate(targetUser);
}, [users, targetUser]);
const roleChangeActions = React.useMemo(
const actions = useMemo(
() =>
targetUser
? [UserRole.Admin, UserRole.Member, UserRole.Viewer].map((role) =>
updateUserRoleActionFactory(targetUser, role)
)
? [
changeUserRoleActionFactory(targetUser),
changeUserAvatarActionFactory(targetUser),
changeUserNameActionFactory(targetUser),
changeUserEmailActionFactory(targetUser),
resendInviteActionFactory(targetUser),
ActionSeparator,
revokeInviteActionFactory(targetUser),
activateUserActionFactory(targetUser),
suspendUserActionFactory(targetUser),
ActionSeparator,
deleteUserActionFactory(targetUser.id),
]
: [],
[targetUser]
);
const actionList = React.useMemo(
() =>
!targetUser
? []
: [
createActionWithChildren({
name: t("Change role"),
section: UserSection,
visible: can.demote || can.promote,
children: roleChangeActions,
}),
createAction({
name: `${t("Change profile picture")}`,
section: UserSection,
visible: can.update,
perform: openAvatarDialog,
}),
createAction({
name: `${t("Change name")}`,
section: UserSection,
visible: can.update,
perform: openNameDialog,
}),
createAction({
name: `${t("Change email")}`,
section: UserSection,
visible: can.update,
perform: openEmailDialog,
}),
createAction({
name: t("Resend invite"),
section: UserSection,
visible: can.resendInvite,
perform: resendInvitation,
}),
ActionSeparator,
createAction({
name: `${t("Revoke invite")}`,
section: UserSection,
visible: targetUser.isInvited,
dangerous: true,
perform: revokeInvitation,
}),
createAction({
name: t("Activate user"),
section: UserSection,
visible: !targetUser.isInvited && targetUser.isSuspended,
perform: activateUser,
}),
createAction({
name: `${t("Suspend user")}`,
section: UserSection,
visible: !targetUser.isInvited && !targetUser.isSuspended,
dangerous: true,
perform: openSuspendDialog,
}),
ActionSeparator,
deleteUserActionFactory(targetUser.id),
],
[
t,
targetUser,
can.demote,
can.promote,
can.update,
can.resendInvite,
roleChangeActions,
openAvatarDialog,
openNameDialog,
openEmailDialog,
resendInvitation,
revokeInvitation,
activateUser,
openSuspendDialog,
]
);
return useMenuAction(actionList);
return useMenuAction(actions);
}
+13 -30
View File
@@ -1,21 +1,18 @@
import copy from "copy-to-clipboard";
import { observer } from "mobx-react";
import { CopyIcon, EditIcon } from "outline-icons";
import { useCallback, useMemo } from "react";
import { EditIcon } from "outline-icons";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import type Comment from "~/models/Comment";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
import {
deleteCommentFactory,
resolveCommentFactory,
unresolveCommentFactory,
viewCommentReactionsFactory,
copyCommentLinkActionFactory,
deleteCommentActionFactory,
resolveCommentActionFactory,
unresolveCommentActionFactory,
viewCommentReactionsActionFactory,
} from "~/actions/definitions/comments";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { commentPath, urlify } from "~/utils/routeHelpers";
import { ActionSeparator, createAction } from "~/actions";
import { ActiveDocumentSection } from "~/actions/sections";
import { useMenuAction } from "~/hooks/useMenuAction";
@@ -40,17 +37,8 @@ function CommentMenu({
onUpdate,
className,
}: Props) {
const { documents } = useStores();
const { t } = useTranslation();
const can = usePolicy(comment);
const document = documents.get(comment.documentId);
const handleCopyLink = useCallback(() => {
if (document) {
copy(urlify(commentPath(document, comment)));
toast.message(t("Link copied"));
}
}, [t, document, comment]);
const actions = useMemo(
() => [
@@ -61,27 +49,22 @@ function CommentMenu({
visible: can.update && !comment.isResolved,
perform: onEdit,
}),
resolveCommentFactory({
resolveCommentActionFactory({
comment,
onResolve: () => onUpdate({ resolved: true }),
}),
unresolveCommentFactory({
unresolveCommentActionFactory({
comment,
onUnresolve: () => onUpdate({ resolved: false }),
}),
viewCommentReactionsFactory({
viewCommentReactionsActionFactory({
comment,
}),
createAction({
name: t("Copy link"),
icon: <CopyIcon />,
section: ActiveDocumentSection,
perform: handleCopyLink,
}),
copyCommentLinkActionFactory({ comment }),
ActionSeparator,
deleteCommentFactory({ comment, onDelete }),
deleteCommentActionFactory({ comment, onDelete }),
],
[t, comment, can.update, onEdit, onUpdate, onDelete, handleCopyLink]
[t, comment, can.update, onEdit, onUpdate, onDelete]
);
const rootAction = useMenuAction(actions);
+4 -35
View File
@@ -1,12 +1,10 @@
import { observer } from "mobx-react";
import { useCallback, useMemo } from "react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import type OAuthAuthentication from "~/models/oauth/OAuthAuthentication";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
import useStores from "~/hooks/useStores";
import { createAction } from "~/actions";
import { revokeOAuthAuthenticationActionFactory } from "~/actions/definitions/oauthAuthentications";
import { useMenuAction } from "~/hooks/useMenuAction";
type Props = {
@@ -15,40 +13,11 @@ type Props = {
};
function OAuthAuthenticationMenu({ oauthAuthentication }: Props) {
const { dialogs } = useStores();
const { t } = useTranslation();
const handleRevoke = useCallback(() => {
dialogs.openModal({
title: t("Revoke {{ appName }}", {
appName: oauthAuthentication.oauthClient.name,
}),
content: (
<ConfirmationDialog
onSubmit={async () => {
await oauthAuthentication.deleteAll();
dialogs.closeAllModals();
}}
submitText={t("Revoke")}
savingText={`${t("Revoking")}`}
danger
>
{t("Are you sure you want to revoke access?")}
</ConfirmationDialog>
),
});
}, [t, dialogs, oauthAuthentication]);
const actions = useMemo(
() => [
createAction({
name: t("Revoke"),
section: "OAuth",
dangerous: true,
perform: handleRevoke,
}),
],
[t, handleRevoke]
() => [revokeOAuthAuthenticationActionFactory({ oauthAuthentication })],
[oauthAuthentication]
);
const rootAction = useMenuAction(actions);
+8 -36
View File
@@ -1,21 +1,16 @@
import { observer } from "mobx-react";
import { useCallback, useMemo } from "react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import type OAuthClient from "~/models/oauth/OAuthClient";
import OAuthClientDeleteDialog from "~/scenes/Settings/components/OAuthClientDeleteDialog";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
import useStores from "~/hooks/useStores";
import { settingsPath } from "~/utils/routeHelpers";
import { ActionSeparator } from "~/actions";
import {
ActionSeparator,
createAction,
createInternalLinkAction,
} from "~/actions";
deleteOAuthClientActionFactory,
editOAuthClientActionFactory,
} from "~/actions/definitions/oauthClients";
import { useMenuAction } from "~/hooks/useMenuAction";
const Section = "OAuth";
type Props = {
/** The oauthClient to associate with the menu */
oauthClient: OAuthClient;
@@ -24,38 +19,15 @@ type Props = {
};
function OAuthClientMenu({ oauthClient, showEdit }: Props) {
const { dialogs } = useStores();
const { t } = useTranslation();
const handleDelete = useCallback(() => {
dialogs.openModal({
title: t("Delete app"),
content: (
<OAuthClientDeleteDialog
onSubmit={dialogs.closeAllModals}
oauthClient={oauthClient}
/>
),
});
}, [t, dialogs, oauthClient]);
const actions = useMemo(
() => [
createInternalLinkAction({
name: `${t("Edit")}`,
section: Section,
visible: showEdit,
to: settingsPath("applications", oauthClient.id),
}),
editOAuthClientActionFactory({ oauthClient, visible: showEdit }),
ActionSeparator,
createAction({
name: `${t("Delete")}`,
section: Section,
dangerous: true,
perform: handleDelete,
}),
deleteOAuthClientActionFactory({ oauthClient }),
],
[t, showEdit, oauthClient.id, handleDelete]
[oauthClient, showEdit]
);
const rootAction = useMenuAction(actions);
+4 -4
View File
@@ -5,8 +5,8 @@ import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
import { ActionSeparator } from "~/actions";
import {
copyLinkToRevision,
downloadRevision,
copyLinkToRevisionActionFactory,
downloadRevisionActionFactory,
restoreRevision,
} from "~/actions/definitions/revisions";
import { useMemo } from "react";
@@ -24,8 +24,8 @@ function RevisionMenu({ document, revisionId }: Props) {
() => [
restoreRevision,
ActionSeparator,
copyLinkToRevision(revisionId),
downloadRevision(revisionId),
copyLinkToRevisionActionFactory(revisionId),
downloadRevisionActionFactory(revisionId),
],
[revisionId]
);
@@ -24,7 +24,7 @@ import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
import Text from "~/components/Text";
import Time from "~/components/Time";
import Tooltip from "~/components/Tooltip";
import { resolveCommentFactory } from "~/actions/definitions/comments";
import { resolveCommentActionFactory } from "~/actions/definitions/comments";
import useBoolean from "~/hooks/useBoolean";
import useCurrentUser from "~/hooks/useCurrentUser";
import CommentMenu from "~/menus/CommentMenu";
@@ -320,7 +320,7 @@ const ResolveButton = ({
<Tooltip content={t("Mark as resolved")} placement="top">
<Action
as={NudeButton}
action={resolveCommentFactory({
action={resolveCommentActionFactory({
comment,
onResolve: () => onUpdate({ resolved: true }),
})}
@@ -12,8 +12,8 @@ import type Document from "~/models/Document";
import type Revision from "~/models/Revision";
import { ActionSeparator } from "~/actions";
import {
copyLinkToRevision,
downloadRevision,
copyLinkToRevisionActionFactory,
downloadRevisionActionFactory,
restoreRevision,
} from "~/actions/definitions/revisions";
import { Avatar, AvatarSize } from "~/components/Avatar";
@@ -50,8 +50,8 @@ const RevisionListItem = ({ item, document, ...rest }: Props) => {
() => [
restoreRevision,
ActionSeparator,
copyLinkToRevision(item.id),
downloadRevision(item.id),
copyLinkToRevisionActionFactory(item.id),
downloadRevisionActionFactory(item.id),
],
[item.id]
);
@@ -0,0 +1,31 @@
import { useTranslation } from "react-i18next";
import type OAuthAuthentication from "~/models/oauth/OAuthAuthentication";
import ConfirmationDialog from "~/components/ConfirmationDialog";
type Props = {
oauthAuthentication: OAuthAuthentication;
onSubmit: () => void;
};
export default function OAuthAuthenticationRevokeDialog({
oauthAuthentication,
onSubmit,
}: Props) {
const { t } = useTranslation();
const handleSubmit = async () => {
await oauthAuthentication.deleteAll();
onSubmit();
};
return (
<ConfirmationDialog
onSubmit={handleSubmit}
submitText={t("Revoke")}
savingText={`${t("Revoking")}`}
danger
>
{t("Are you sure you want to revoke access?")}
</ConfirmationDialog>
);
}
@@ -1,6 +1,8 @@
import { useTranslation } from "react-i18next";
import { useHistory, useLocation } from "react-router-dom";
import type OAuthClient from "~/models/oauth/OAuthClient";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import { settingsPath } from "~/utils/routeHelpers";
type Props = {
oauthClient: OAuthClient;
@@ -12,8 +14,15 @@ export default function OAuthClientDeleteDialog({
onSubmit,
}: Props) {
const { t } = useTranslation();
const history = useHistory();
const location = useLocation();
const handleSubmit = async () => {
// Navigate back to the list if we're viewing the app being deleted.
if (location.pathname === settingsPath("applications", oauthClient.id)) {
history.push(settingsPath("applications"));
}
await oauthClient.delete();
onSubmit();
};
+2 -2
View File
@@ -17,7 +17,7 @@ import useStores from "~/hooks/useStores";
import Icon from "./Icon";
import Flex from "~/components/Flex";
import styled from "styled-components";
import { disconnectIntegrationFactory } from "~/actions/definitions/integrations";
import { disconnectIntegrationActionFactory } from "~/actions/definitions/integrations";
type FormData = {
url: string;
@@ -110,7 +110,7 @@ function DiagramsNet() {
</StyledSubmit>
<Button
action={disconnectIntegrationFactory(integration)}
action={disconnectIntegrationActionFactory(integration)}
disabled={formState.isSubmitting}
neutral
hideIcon
+2 -2
View File
@@ -15,7 +15,7 @@ import GoogleIcon from "~/components/Icons/GoogleIcon";
import Input from "~/components/Input";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import { disconnectAnalyticsIntegrationFactory } from "~/actions/definitions/integrations";
import { disconnectAnalyticsIntegrationActionFactory } from "~/actions/definitions/integrations";
import Flex from "~/components/Flex";
import styled from "styled-components";
@@ -106,7 +106,7 @@ function GoogleAnalytics() {
</StyledSubmit>
<Button
action={disconnectAnalyticsIntegrationFactory(integration)}
action={disconnectAnalyticsIntegrationActionFactory(integration)}
disabled={formState.isSubmitting}
neutral
hideIcon
+2 -2
View File
@@ -15,7 +15,7 @@ import Input from "~/components/Input";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import Icon from "./Icon";
import { disconnectAnalyticsIntegrationFactory } from "~/actions/definitions/integrations";
import { disconnectAnalyticsIntegrationActionFactory } from "~/actions/definitions/integrations";
import Flex from "~/components/Flex";
import styled from "styled-components";
@@ -127,7 +127,7 @@ function Matomo() {
</StyledSubmit>
<Button
action={disconnectAnalyticsIntegrationFactory(integration)}
action={disconnectAnalyticsIntegrationActionFactory(integration)}
disabled={formState.isSubmitting}
neutral
hideIcon
@@ -1,11 +1,13 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { EditIcon, TrashIcon } from "outline-icons";
import { TrashIcon } from "outline-icons";
import ListItem from "~/components/List/Item";
import Text from "~/components/Text";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
import { ActionSeparator, createAction } from "~/actions";
import { renameActionFactory } from "~/actions/definitions/common";
import { SettingsSection } from "~/actions/sections";
import { useMenuAction } from "~/hooks/useMenuAction";
import PasskeyIcon from "./PasskeyIcon";
import { dateLocale, dateToRelative } from "@shared/utils/date";
@@ -36,17 +38,12 @@ function PasskeyMenu({ onRename, onDelete }: Props) {
const actions = React.useMemo(
() => [
createAction({
name: `${t("Rename")}`,
icon: <EditIcon />,
section: "Passkey",
perform: onRename,
}),
renameActionFactory({ section: SettingsSection, onRename }),
ActionSeparator,
createAction({
name: `${t("Delete")}`,
icon: <TrashIcon />,
section: "Passkey",
section: SettingsSection,
dangerous: true,
perform: onDelete,
}),
+2 -2
View File
@@ -16,7 +16,7 @@ import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import Icon from "./Icon";
import Flex from "~/components/Flex";
import { disconnectAnalyticsIntegrationFactory } from "~/actions/definitions/integrations";
import { disconnectAnalyticsIntegrationActionFactory } from "~/actions/definitions/integrations";
import styled from "styled-components";
type FormData = {
@@ -147,7 +147,7 @@ function Umami() {
</StyledSubmit>
<Button
action={disconnectAnalyticsIntegrationFactory(integration)}
action={disconnectAnalyticsIntegrationActionFactory(integration)}
disabled={formState.isSubmitting}
neutral
hideIcon
+25 -48
View File
@@ -1,84 +1,61 @@
import { EditIcon, PlusIcon, TrashIcon } from "outline-icons";
import stores from "~/stores";
import type WebhookSubscription from "~/models/WebhookSubscription";
import { createAction } from "~/actions";
import { dialogActionFactory } from "~/actions/definitions/common";
import { SettingsSection } from "~/actions/sections";
import WebhookSubscriptionDeleteDialog from "./components/WebhookSubscriptionDeleteDialog";
import WebhookSubscriptionEdit from "./components/WebhookSubscriptionEdit";
import WebhookSubscriptionNew from "./components/WebhookSubscriptionNew";
export const createWebhookSubscription = createAction({
name: ({ t }) => t("New webhook"),
export const createWebhookSubscription = dialogActionFactory({
analyticsName: "New webhook",
section: SettingsSection,
name: (t) => t("New webhook"),
title: (t) => t("New webhook"),
content: (onSubmit) => <WebhookSubscriptionNew onSubmit={onSubmit} />,
icon: <PlusIcon />,
keywords: "create",
stopEvent: true,
visible: () =>
stores.policies.abilities(stores.auth.team?.id || "")
.createWebhookSubscription,
perform: ({ t, event }) => {
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
title: t("New webhook"),
content: (
<WebhookSubscriptionNew onSubmit={stores.dialogs.closeAllModals} />
),
});
},
});
export const editWebhookSubscriptionFactory = ({
export const editWebhookSubscriptionActionFactory = ({
webhook,
}: {
webhook: WebhookSubscription;
}) =>
createAction({
name: ({ t }) => `${t("Edit")}`,
dialogActionFactory({
analyticsName: "Edit webhook",
section: SettingsSection,
name: (t) => `${t("Edit")}`,
title: (t) => t("Edit webhook"),
content: (onSubmit) => (
<WebhookSubscriptionEdit
onSubmit={onSubmit}
webhookSubscription={webhook}
/>
),
icon: <EditIcon />,
perform: ({ t, event }) => {
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
title: t("Edit webhook"),
content: (
<WebhookSubscriptionEdit
onSubmit={stores.dialogs.closeAllModals}
webhookSubscription={webhook}
/>
),
});
},
stopEvent: true,
});
export const deleteWebhookSubscriptionFactory = ({
export const deleteWebhookSubscriptionActionFactory = ({
webhook,
}: {
webhook: WebhookSubscription;
}) =>
createAction({
name: ({ t }) => `${t("Delete")}`,
dialogActionFactory({
analyticsName: "Delete webhook",
section: SettingsSection,
name: (t) => `${t("Delete")}`,
title: (t) => t("Delete webhook"),
content: (onSubmit) => (
<WebhookSubscriptionDeleteDialog onSubmit={onSubmit} webhook={webhook} />
),
icon: <TrashIcon />,
keywords: "delete remove",
dangerous: true,
perform: ({ t, event }) => {
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
title: t("Delete webhook"),
content: (
<WebhookSubscriptionDeleteDialog
onSubmit={stores.dialogs.closeAllModals}
webhook={webhook}
/>
),
});
},
stopEvent: true,
});
@@ -2,8 +2,8 @@ import { useMemo } from "react";
import type WebhookSubscription from "~/models/WebhookSubscription";
import { useMenuAction } from "~/hooks/useMenuAction";
import {
deleteWebhookSubscriptionFactory,
editWebhookSubscriptionFactory,
deleteWebhookSubscriptionActionFactory,
editWebhookSubscriptionActionFactory,
} from "../actions";
/**
@@ -17,8 +17,8 @@ export function useWebhookSubscriptionMenuActions(
) {
const actions = useMemo(
() => [
editWebhookSubscriptionFactory({ webhook }),
deleteWebhookSubscriptionFactory({ webhook }),
editWebhookSubscriptionActionFactory({ webhook }),
deleteWebhookSubscriptionActionFactory({ webhook }),
],
[webhook]
);
+24 -26
View File
@@ -44,8 +44,11 @@
"Mark as resolved": "Mark as resolved",
"Thread resolved": "Thread resolved",
"Mark as unresolved": "Mark as unresolved",
"Copy link": "Copy link",
"Link copied to clipboard": "Link copied to clipboard",
"View reactions": "View reactions",
"Reactions": "Reactions",
"Rename": "Rename",
"Copy ID": "Copy ID",
"Clear IndexedDB cache": "Clear IndexedDB cache",
"IndexedDB cache cleared": "IndexedDB cache cleared",
@@ -77,8 +80,6 @@
"Copy as text": "Copy as text",
"Text copied to clipboard": "Text copied to clipboard",
"Copy public link": "Copy public link",
"Link copied to clipboard": "Link copied to clipboard",
"Copy link": "Copy link",
"Duplicate": "Duplicate",
"Duplicate document": "Duplicate document",
"Copy document": "Copy document",
@@ -119,6 +120,12 @@
"Apply template": "Apply template",
"New emoji": "New emoji",
"Upload emoji": "Upload emoji",
"Replace": "Replace",
"Replace image": "Replace image",
"Delete Emoji": "Delete Emoji",
"Members": "Members",
"Edit group": "Edit group",
"Delete group": "Delete group",
"Disconnect": "Disconnect",
"Disconnect analytics": "Disconnect analytics",
"Home": "Home",
@@ -144,10 +151,11 @@
"Archive all notifications": "Archive all notifications",
"Mark as read": "Mark as read",
"Mark as unread": "Mark as unread",
"Revoke {{ appName }}": "Revoke {{ appName }}",
"New App": "New App",
"New Application": "New Application",
"Delete app": "Delete app",
"This version of the document was deleted": "This version of the document was deleted",
"Link copied": "Link copied",
"HTML": "HTML",
"PDF": "PDF",
"Exporting": "Exporting",
@@ -185,6 +193,15 @@
"Promote to {{ role }}": "Promote to {{ role }}",
"Demote to {{ role }}": "Demote to {{ role }}",
"Update role": "Update role",
"Change role": "Change role",
"Change profile picture": "Change profile picture",
"Change name": "Change name",
"Change email": "Change email",
"Suspend user": "Suspend user",
"Resend invite": "Resend invite",
"Invite was resent to {{ userName }}": "Invite was resent to {{ userName }}",
"Revoke invite": "Revoke invite",
"Activate user": "Activate user",
"Delete user": "Delete user",
"Collection": "Collection",
"Collections": "Collections",
@@ -210,7 +227,6 @@
"Avatar of {{ name }}": "Avatar of {{ name }}",
"Viewers": "Viewers",
"Managers": "Managers",
"Members": "Members",
"Manage templates": "Manage templates",
"Choose who can create and edit templates in this collection.": "Choose who can create and edit templates in this collection.",
"Public document sharing": "Public document sharing",
@@ -320,6 +336,8 @@
"Choose a name": "Choose a name",
"name can only contain lowercase letters, numbers, and underscores.": "name can only contain lowercase letters, numbers, and underscores.",
"This emoji will be available as": "This emoji will be available as",
"Emoji deleted": "Emoji deleted",
"Are you sure you want to delete the <em>{{emojiName}}</em> emoji? You will no longer be able to use it in your documents or collections.": "Are you sure you want to delete the <em>{{emojiName}}</em> emoji? You will no longer be able to use it in your documents or collections.",
"Emoji replaced": "Emoji replaced",
"Upload a new image to replace the current one for <em>{{emojiName}}</em>. All existing uses of this emoji will be updated automatically.": "Upload a new image to replace the current one for <em>{{emojiName}}</em>. All existing uses of this emoji will be updated automatically.",
"Module failed to load": "Module failed to load",
@@ -584,7 +602,6 @@
"Enable regex": "Enable regex",
"Replace options": "Replace options",
"Replacement": "Replacement",
"Replace": "Replace",
"Replace all": "Replace all",
"Options": "Options",
"Go to link": "Go to link",
@@ -666,7 +683,6 @@
"Align right": "Align right",
"Full width": "Full width",
"Download image": "Download image",
"Replace image": "Replace image",
"Edit image URL": "Edit image URL",
"Default width": "Default width",
"Distribute columns": "Distribute columns",
@@ -684,13 +700,6 @@
"Move right": "Move right",
"Move up": "Move up",
"Move down": "Move down",
"Rename": "Rename",
"Delete Emoji": "Delete Emoji",
"Emoji deleted": "Emoji deleted",
"I'm sure Delete": "I'm sure Delete",
"Are you sure you want to delete the <em>{{emojiName}}</em> emoji? You will no longer be able to use it in your documents or collections.": "Are you sure you want to delete the <em>{{emojiName}}</em> emoji? You will no longer be able to use it in your documents or collections.",
"Edit group": "Edit group",
"Delete group": "Delete group",
"Could not import file": "Could not import file",
"Unsubscribed from document": "Unsubscribed from document",
"Unsubscribed from collection": "Unsubscribed from collection",
@@ -709,15 +718,6 @@
"Embeds": "Embeds",
"Configure which embed providers are available in the editor.": "Configure which embed providers are available in the editor.",
"Install": "Install",
"Change profile picture": "Change profile picture",
"Change name": "Change name",
"Change email": "Change email",
"Suspend user": "Suspend user",
"An error occurred while sending the invite": "An error occurred while sending the invite",
"Change role": "Change role",
"Resend invite": "Resend invite",
"Revoke invite": "Revoke invite",
"Activate user": "Activate user",
"API key": "API key",
"Show path to document": "Show path to document",
"Comment options": "Comment options",
@@ -731,10 +731,6 @@
"New document in <em>{{ collectionName }}</em>": "New document in <em>{{ collectionName }}</em>",
"New child document": "New child document",
"Save in workspace": "Save in workspace",
"Revoke {{ appName }}": "Revoke {{ appName }}",
"Revoking": "Revoking",
"Are you sure you want to revoke access?": "Are you sure you want to revoke access?",
"Delete app": "Delete app",
"Revision options": "Revision options",
"Share options": "Share options",
"Headings you add to the document will appear here": "Headings you add to the document will appear here",
@@ -1188,6 +1184,7 @@
"Restricted scope": "Restricted scope",
"API key copied to clipboard": "API key copied to clipboard",
"Copied": "Copied",
"Revoking": "Revoking",
"Are you sure you want to revoke the {{ tokenName }} token?": "Are you sure you want to revoke the {{ tokenName }} token?",
"Key": "Key",
"Created by": "Created by",
@@ -1275,6 +1272,7 @@
"Role": "Role",
"Guest": "Guest",
"Never used": "Never used",
"Are you sure you want to revoke access?": "Are you sure you want to revoke access?",
"Are you sure you want to delete the {{ appName }} application? This cannot be undone.": "Are you sure you want to delete the {{ appName }} application? This cannot be undone.",
"Title": "Title",
"Shared by": "Shared by",