mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f06c1d95fa | |||
| c9c5e86b72 | |||
| 84f46e0f96 | |||
| 3219cf7dbe | |||
| f00bec87d7 | |||
| 41c8d664b2 |
@@ -0,0 +1,192 @@
|
||||
import { IssueSource } from "@shared/schema";
|
||||
import { ellipsis } from "@shared/styles";
|
||||
import { observer } from "mobx-react";
|
||||
import React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Button from "~/components/Button";
|
||||
import Flex from "~/components/Flex";
|
||||
import Modal from "~/components/Modal";
|
||||
import PluginIcon from "~/components/PluginIcon";
|
||||
import Text from "~/components/Text";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
|
||||
type Props = {
|
||||
issueTitle: string;
|
||||
open: boolean;
|
||||
onCreate: (source: IssueSource) => Promise<void>;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const CreateIssueDialog = observer(
|
||||
({ issueTitle, open, onCreate, onClose }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const [isCreating, setCreating, unsetCreating] = useBoolean();
|
||||
const [selectedSource, selectSource] = React.useState<IssueSource>();
|
||||
|
||||
const {
|
||||
data: sources,
|
||||
loading,
|
||||
request,
|
||||
} = useRequest<IssueSource[]>(
|
||||
React.useCallback(async () => {
|
||||
try {
|
||||
const res = await client.post("/issues.list_sources");
|
||||
return res.data;
|
||||
} catch (err) {
|
||||
toast.error(t("Couldn't load issue sources, try again?"));
|
||||
throw err;
|
||||
}
|
||||
}, [t])
|
||||
);
|
||||
|
||||
const handleCreateIssue = React.useCallback(async () => {
|
||||
setCreating();
|
||||
await onCreate(selectedSource!);
|
||||
unsetCreating();
|
||||
}, [selectedSource, onCreate, setCreating, unsetCreating]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
void request();
|
||||
} else {
|
||||
selectSource(undefined);
|
||||
}
|
||||
}, [open, request]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t("Create issue")}
|
||||
isOpen={open}
|
||||
onRequestClose={onClose}
|
||||
fullscreen={false}
|
||||
>
|
||||
<FlexContainer column>
|
||||
<ListContainer>
|
||||
{loading ? (
|
||||
"Loading..."
|
||||
) : !sources?.length ? (
|
||||
"No source available"
|
||||
) : (
|
||||
<Flex column gap={6}>
|
||||
{sources.map((source) => (
|
||||
<SourceItem
|
||||
key={source.id}
|
||||
source={source}
|
||||
selected={source === selectedSource}
|
||||
onSelect={selectSource}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
)}
|
||||
</ListContainer>
|
||||
<Footer justify="space-between" align="center" gap={8}>
|
||||
<StyledText type="secondary">
|
||||
{selectedSource ? (
|
||||
<Trans
|
||||
defaults="Create issue in <em>{{ location }}</em>"
|
||||
values={{
|
||||
location: `${selectedSource.owner.name}/${selectedSource.name} `,
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
t("Select a source to create issue")
|
||||
)}
|
||||
</StyledText>
|
||||
<Button
|
||||
disabled={!selectedSource || isCreating}
|
||||
onClick={handleCreateIssue}
|
||||
>
|
||||
{isCreating ? `${t("Creating")}…` : t("Create")}
|
||||
</Button>
|
||||
</Footer>
|
||||
</FlexContainer>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const SourceItem = ({
|
||||
source,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
source: IssueSource;
|
||||
selected: boolean;
|
||||
onSelect: (source: IssueSource) => void;
|
||||
}) => (
|
||||
<SourceItemWrapper
|
||||
justify="space-between"
|
||||
onClick={() => onSelect(source)}
|
||||
$selected={selected}
|
||||
>
|
||||
<Text>{source.name}</Text>
|
||||
<Flex align="center" gap={2}>
|
||||
<PluginIcon id={source.service} size={20} />
|
||||
<SourceAccount type="tertiary" size="xsmall">
|
||||
{source.owner.name}
|
||||
</SourceAccount>
|
||||
</Flex>
|
||||
</SourceItemWrapper>
|
||||
);
|
||||
|
||||
export const FlexContainer = styled(Flex)`
|
||||
margin-left: -24px;
|
||||
margin-right: -24px;
|
||||
margin-bottom: -24px;
|
||||
outline: none;
|
||||
`;
|
||||
|
||||
const ListContainer = styled.div`
|
||||
height: 65vh;
|
||||
padding: 0 24px 12px;
|
||||
overflow: scroll;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
height: 40vh;
|
||||
`}
|
||||
`;
|
||||
|
||||
const SourceAccount = styled(Text)``;
|
||||
|
||||
const SourceItemWrapper = styled(Flex)<{ $selected: boolean }>`
|
||||
font-size: 16px;
|
||||
cursor: var(--pointer);
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
|
||||
${(props) =>
|
||||
props.$selected &&
|
||||
`
|
||||
background: ${props.theme.accent};
|
||||
color: ${props.theme.white};
|
||||
|
||||
${SourceAccount} {
|
||||
color: ${props.theme.white};
|
||||
}
|
||||
|
||||
`}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
padding: 4px 6px;
|
||||
font-size: 15px;
|
||||
`}
|
||||
`;
|
||||
|
||||
const Footer = styled(Flex)`
|
||||
height: 64px;
|
||||
border-top: 1px solid ${(props) => props.theme.horizontalRule};
|
||||
padding: 0 24px;
|
||||
`;
|
||||
|
||||
const StyledText = styled(Text)`
|
||||
${ellipsis()}
|
||||
margin-bottom: 0;
|
||||
`;
|
||||
@@ -9,6 +9,7 @@ import { isMarkActive } from "@shared/editor/queries/isMarkActive";
|
||||
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
|
||||
import { getColumnIndex, getRowIndex } from "@shared/editor/queries/table";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { useRecentIssueSources } from "~/editor/hooks/useRecentIssueSources";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useDictionary from "~/hooks/useDictionary";
|
||||
import useEventListener from "~/hooks/useEventListener";
|
||||
@@ -106,6 +107,7 @@ export default function SelectionToolbar(props: Props) {
|
||||
const isActive = useIsActive(view.state) || isMobile;
|
||||
const isDragging = useIsDragging();
|
||||
const previousIsActive = usePrevious(isActive);
|
||||
const { issueSources: recentIssueSources } = useRecentIssueSources();
|
||||
|
||||
React.useEffect(() => {
|
||||
// Trigger callbacks when the toolbar is opened or closed
|
||||
@@ -213,7 +215,13 @@ export default function SelectionToolbar(props: Props) {
|
||||
} else if (isNoticeSelection && selection.empty) {
|
||||
items = getNoticeMenuItems(state, readOnly, dictionary);
|
||||
} else {
|
||||
items = getFormattingMenuItems(state, isTemplate, isMobile, dictionary);
|
||||
items = getFormattingMenuItems(
|
||||
state,
|
||||
isTemplate,
|
||||
isMobile,
|
||||
recentIssueSources,
|
||||
dictionary
|
||||
);
|
||||
}
|
||||
|
||||
// Some extensions may be disabled, remove corresponding items
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
import Extension from "@shared/editor/lib/Extension";
|
||||
import { isRemoteTransaction } from "@shared/editor/lib/multiplayer";
|
||||
import { recreateTransform } from "@shared/editor/lib/prosemirror-recreate-transform";
|
||||
import { isInCode } from "@shared/editor/queries/isInCode";
|
||||
import { IssueSource } from "@shared/schema";
|
||||
import {
|
||||
MentionPlaceholder,
|
||||
MentionType,
|
||||
UnfurlResourceType,
|
||||
UnfurlResponse,
|
||||
} from "@shared/types";
|
||||
import { t } from "i18next";
|
||||
import { action, observable } from "mobx";
|
||||
import {
|
||||
Command,
|
||||
EditorState,
|
||||
Plugin,
|
||||
PluginKey,
|
||||
TextSelection,
|
||||
} from "prosemirror-state";
|
||||
import { Decoration, DecorationSet } from "prosemirror-view";
|
||||
import React from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Primitive } from "utility-types";
|
||||
import { v4 } from "uuid";
|
||||
import stores from "~/stores";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import { CreateIssueDialog } from "../components/CreateIssueDialog";
|
||||
import { addRecentIssueSource } from "../hooks/useRecentIssueSources";
|
||||
|
||||
export default class CreateIssue extends Extension {
|
||||
private state: {
|
||||
open: boolean;
|
||||
title: string;
|
||||
} = observable({
|
||||
open: false,
|
||||
title: "",
|
||||
});
|
||||
|
||||
private key = new PluginKey(this.name);
|
||||
|
||||
get name() {
|
||||
return "issue";
|
||||
}
|
||||
|
||||
get plugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
key: this.key,
|
||||
state: {
|
||||
init: () => DecorationSet.empty,
|
||||
apply: (tr, set) => {
|
||||
// See if the transaction adds, replaces, or removes any placeholders.
|
||||
const meta = tr.getMeta(this.key);
|
||||
|
||||
// We only want a single paste placeholder at a time, so if we're adding a new
|
||||
// placeholder we can just return a new DecorationSet and avoid mapping logic.
|
||||
if (meta?.add) {
|
||||
const { from, to, id } = meta.add;
|
||||
const decorations = [
|
||||
Decoration.inline(
|
||||
from,
|
||||
to,
|
||||
{},
|
||||
{
|
||||
id,
|
||||
}
|
||||
),
|
||||
];
|
||||
return DecorationSet.create(tr.doc, decorations);
|
||||
}
|
||||
|
||||
let mapping = tr.mapping;
|
||||
const hasDecorations = set.find().length;
|
||||
|
||||
if (hasDecorations && (isRemoteTransaction(tr) || meta)) {
|
||||
try {
|
||||
mapping = recreateTransform(tr.before, tr.doc, {
|
||||
complexSteps: true,
|
||||
wordDiffs: false,
|
||||
simplifyDiff: true,
|
||||
}).mapping;
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("Failed to recreate transform: ", err);
|
||||
}
|
||||
}
|
||||
|
||||
set = set.map(mapping, tr.doc);
|
||||
|
||||
if (meta?.replace) {
|
||||
const { id } = meta.replace;
|
||||
const decorations = set.find(
|
||||
undefined,
|
||||
undefined,
|
||||
(spec) => spec.id === id
|
||||
);
|
||||
return DecorationSet.create(tr.doc, decorations);
|
||||
}
|
||||
|
||||
if (meta?.remove) {
|
||||
const { id } = meta.remove;
|
||||
const decorations = set.find(
|
||||
undefined,
|
||||
undefined,
|
||||
(spec) => spec.id === id
|
||||
);
|
||||
return set.remove(decorations);
|
||||
}
|
||||
|
||||
return set;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
keys(): Record<string, Command> {
|
||||
return {
|
||||
"Mod-Alt-i": (state, dispatch) => {
|
||||
const isCode = isInCode(state);
|
||||
const isEmpty = state.selection.empty;
|
||||
|
||||
if (isCode || isEmpty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const title = state.doc.cut(
|
||||
state.selection.from,
|
||||
state.selection.to
|
||||
).textContent;
|
||||
const { from } = state.selection;
|
||||
const to = from + title.length;
|
||||
|
||||
const tr = state.tr
|
||||
.setSelection(TextSelection.near(state.doc.resolve(from)))
|
||||
.setMeta(this.key, { add: { from, to, id: title } });
|
||||
dispatch?.(tr);
|
||||
|
||||
this.openDialog(title);
|
||||
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
commands() {
|
||||
return (attrs: Record<string, Primitive>): Command =>
|
||||
action((state, dispatch) => {
|
||||
const title = attrs.title as string;
|
||||
const source = attrs.source
|
||||
? (JSON.parse(attrs.source as string) as IssueSource)
|
||||
: undefined;
|
||||
|
||||
this.state.title = title;
|
||||
|
||||
const { from } = state.selection;
|
||||
const to = from + title.length;
|
||||
|
||||
const tr = state.tr
|
||||
.setSelection(TextSelection.near(state.doc.resolve(to)))
|
||||
.setMeta(this.key, { add: { from, to, id: title } });
|
||||
|
||||
dispatch?.(tr);
|
||||
|
||||
if (source) {
|
||||
tr.replaceWith(
|
||||
from,
|
||||
to,
|
||||
state.schema.nodes.mention.create({
|
||||
id: v4(),
|
||||
type: MentionPlaceholder,
|
||||
label: title,
|
||||
href: title,
|
||||
modelId: v4(),
|
||||
actorId: stores.auth.currentUserId,
|
||||
})
|
||||
).setMeta(this.key, { replace: { id: title } });
|
||||
}
|
||||
|
||||
dispatch?.(tr);
|
||||
|
||||
if (source) {
|
||||
void this.createIssue(source);
|
||||
} else {
|
||||
this.openDialog(title);
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
widget = () => (
|
||||
<CreateIssueDialog
|
||||
issueTitle={this.state.title}
|
||||
open={this.state.open}
|
||||
onCreate={this.createIssue}
|
||||
onClose={this.closeDialog}
|
||||
/>
|
||||
);
|
||||
|
||||
private createIssue = async (source: IssueSource) => {
|
||||
try {
|
||||
addRecentIssueSource(source);
|
||||
const res = await client.post("/issues.create", {
|
||||
title: this.state.title,
|
||||
source,
|
||||
});
|
||||
this.addMentionNode(res.data);
|
||||
toast.success(t("Issue created"));
|
||||
} catch (err) {
|
||||
this.removeDecorations(source);
|
||||
toast.error(t("Couldn’t create the issue, try again?"));
|
||||
}
|
||||
};
|
||||
|
||||
private addMentionNode = (
|
||||
issue: UnfurlResponse[UnfurlResourceType.Issue]
|
||||
) => {
|
||||
const { view } = this.editor;
|
||||
const { state } = view;
|
||||
|
||||
const result = this.findPlaceholder(state, this.state.title);
|
||||
|
||||
if (result) {
|
||||
const tr = state.tr.deleteRange(result[0], result[1]);
|
||||
view.dispatch(
|
||||
tr
|
||||
.setSelection(TextSelection.near(tr.doc.resolve(result[0])))
|
||||
.setMeta(this.key, {
|
||||
remove: { id: this.state.title },
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
this.editor.commands.mention({
|
||||
id: v4(),
|
||||
type: MentionType.Issue,
|
||||
label: this.state.title,
|
||||
href: issue.url,
|
||||
modelId: v4(),
|
||||
actorId: stores.auth.currentUserId,
|
||||
});
|
||||
};
|
||||
|
||||
private removeDecorations = action((source: IssueSource) => {
|
||||
const { view } = this.editor;
|
||||
const { state } = view;
|
||||
|
||||
const tr = state.tr.setMeta(this.key, {
|
||||
remove: { id: this.state.title },
|
||||
});
|
||||
|
||||
const result = this.findPlaceholder(state, this.state.title);
|
||||
|
||||
// Placeholder node would have been inserted in recent issue menu flow only.
|
||||
// We want to reset it with the selected text.
|
||||
if (source && result) {
|
||||
tr.replaceWith(
|
||||
result[0],
|
||||
result[1],
|
||||
state.schema.nodeFromJSON({ type: "text", text: this.state.title })
|
||||
);
|
||||
}
|
||||
|
||||
view.dispatch(tr);
|
||||
|
||||
this.state.title = "";
|
||||
});
|
||||
|
||||
private openDialog = action((title: string) => {
|
||||
this.state.title = title;
|
||||
this.state.open = true;
|
||||
});
|
||||
|
||||
private closeDialog = action(() => {
|
||||
const { view } = this.editor;
|
||||
const { state } = view;
|
||||
|
||||
const result = this.findPlaceholder(state, this.state.title);
|
||||
|
||||
if (result) {
|
||||
const tr = state.tr
|
||||
.setSelection(TextSelection.near(state.doc.resolve(result[0])))
|
||||
.setMeta(this.key, {
|
||||
remove: { id: this.state.title },
|
||||
});
|
||||
view.dispatch(tr);
|
||||
}
|
||||
|
||||
this.state.title = "";
|
||||
this.state.open = false;
|
||||
});
|
||||
|
||||
private findPlaceholder = (
|
||||
state: EditorState,
|
||||
id: string
|
||||
): [number, number] | null => {
|
||||
const decos = this.key.getState(state) as DecorationSet;
|
||||
const found = decos?.find(undefined, undefined, (spec) => spec.id === id);
|
||||
return found?.length ? [found[0].from, found[0].to] : null;
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import Mark from "@shared/editor/marks/Mark";
|
||||
import Node from "@shared/editor/nodes/Node";
|
||||
import BlockMenuExtension from "~/editor/extensions/BlockMenu";
|
||||
import ClipboardTextSerializer from "~/editor/extensions/ClipboardTextSerializer";
|
||||
import CreateIssueExtension from "~/editor/extensions/CreateIssue";
|
||||
import EmojiMenuExtension from "~/editor/extensions/EmojiMenu";
|
||||
import FindAndReplaceExtension from "~/editor/extensions/FindAndReplace";
|
||||
import HoverPreviewsExtension from "~/editor/extensions/HoverPreviews";
|
||||
@@ -24,6 +25,7 @@ export const withUIExtensions = (nodes: Nodes) => [
|
||||
MentionMenuExtension,
|
||||
FindAndReplaceExtension,
|
||||
HoverPreviewsExtension,
|
||||
CreateIssueExtension,
|
||||
// Order these default key handlers last
|
||||
PreventTab,
|
||||
Keys,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { IssueSource } from "@shared/schema";
|
||||
import Storage from "@shared/utils/Storage";
|
||||
import React from "react";
|
||||
import usePersistedState, {
|
||||
setPersistedState,
|
||||
} from "~/hooks/usePersistedState";
|
||||
|
||||
const StorageKey = "recent-issue-sources";
|
||||
const MaxCount = 5;
|
||||
|
||||
export function useRecentIssueSources() {
|
||||
const [issueSources, setIssueSources] = usePersistedState<IssueSource[]>(
|
||||
StorageKey,
|
||||
[] as IssueSource[]
|
||||
);
|
||||
|
||||
const addIssueSource = React.useCallback(
|
||||
(source: IssueSource) => {
|
||||
const newIssueSources = insertAndTrim(issueSources, source);
|
||||
setIssueSources(newIssueSources);
|
||||
},
|
||||
[issueSources, setIssueSources]
|
||||
);
|
||||
|
||||
return { issueSources, addIssueSource };
|
||||
}
|
||||
|
||||
export function addRecentIssueSource(source: IssueSource) {
|
||||
const issueSources: IssueSource[] = Storage.get(StorageKey) ?? [];
|
||||
const newIssueSources = insertAndTrim(issueSources, source);
|
||||
setPersistedState(StorageKey, newIssueSources);
|
||||
}
|
||||
|
||||
function insertAndTrim(issueSources: IssueSource[], source: IssueSource) {
|
||||
const newIssueSources = issueSources.filter((s) => s.id !== source.id);
|
||||
newIssueSources.unshift(source);
|
||||
|
||||
if (newIssueSources.length > MaxCount) {
|
||||
newIssueSources.pop();
|
||||
}
|
||||
|
||||
return newIssueSources;
|
||||
}
|
||||
|
||||
export type RecentIssueSourcesResponse = ReturnType<
|
||||
typeof useRecentIssueSources
|
||||
>;
|
||||
@@ -1,3 +1,4 @@
|
||||
import { t } from "i18next";
|
||||
import {
|
||||
BoldIcon,
|
||||
CodeIcon,
|
||||
@@ -17,8 +18,9 @@ import {
|
||||
IndentIcon,
|
||||
CopyIcon,
|
||||
Heading3Icon,
|
||||
PlusIcon,
|
||||
} from "outline-icons";
|
||||
import { EditorState } from "prosemirror-state";
|
||||
import { EditorState, TextSelection } from "prosemirror-state";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import Highlight from "@shared/editor/marks/Highlight";
|
||||
@@ -28,14 +30,17 @@ import { isInList } from "@shared/editor/queries/isInList";
|
||||
import { isMarkActive } from "@shared/editor/queries/isMarkActive";
|
||||
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { IssueSource } from "@shared/schema";
|
||||
import { metaDisplay } from "@shared/utils/keyboard";
|
||||
import CircleIcon from "~/components/Icons/CircleIcon";
|
||||
import PluginIcon from "~/components/PluginIcon";
|
||||
import { Dictionary } from "~/hooks/useDictionary";
|
||||
|
||||
export default function formattingMenuItems(
|
||||
state: EditorState,
|
||||
isTemplate: boolean,
|
||||
isMobile: boolean,
|
||||
recentIssueSources: IssueSource[],
|
||||
dictionary: Dictionary
|
||||
): MenuItem[] {
|
||||
const { schema } = state;
|
||||
@@ -43,12 +48,39 @@ export default function formattingMenuItems(
|
||||
const isCodeBlock = isInCode(state, { onlyBlock: true });
|
||||
const isEmpty = state.selection.empty;
|
||||
|
||||
const selectedText =
|
||||
!isEmpty && state.selection instanceof TextSelection
|
||||
? state.doc.cut(state.selection.from, state.selection.to).textContent
|
||||
: undefined;
|
||||
|
||||
const highlight = getMarksBetween(
|
||||
state.selection.from,
|
||||
state.selection.to,
|
||||
state
|
||||
).find(({ mark }) => mark.type.name === "highlight");
|
||||
|
||||
const issueSourcesChildren = recentIssueSources.length
|
||||
? recentIssueSources.map<MenuItem>((source) => ({
|
||||
name: "issue",
|
||||
label: `${source.owner.name}/${source.name}`,
|
||||
icon: <PluginIcon id={source.service} />,
|
||||
attrs: {
|
||||
title: selectedText,
|
||||
source: JSON.stringify(source),
|
||||
},
|
||||
}))
|
||||
: undefined;
|
||||
|
||||
if (issueSourcesChildren) {
|
||||
issueSourcesChildren.push({
|
||||
name: "issue",
|
||||
label: `${t("Other")}…`,
|
||||
attrs: {
|
||||
title: selectedText,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
name: "placeholder",
|
||||
@@ -259,6 +291,21 @@ export default function formattingMenuItems(
|
||||
shortcut: `${metaDisplay}+C`,
|
||||
visible: isCode && !isCodeBlock && (!isMobile || !isEmpty),
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
visible: !isCode && !isEmpty,
|
||||
},
|
||||
{
|
||||
name: "issue",
|
||||
tooltip: dictionary.createIssue,
|
||||
shortcut: `${metaDisplay}+⌥+I`,
|
||||
icon: <PlusIcon />,
|
||||
attrs: {
|
||||
title: selectedText,
|
||||
},
|
||||
visible: !isCode && !!selectedText,
|
||||
children: issueSourcesChildren,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ export default function useDictionary() {
|
||||
codeInline: t("Code"),
|
||||
comment: t("Comment"),
|
||||
copy: t("Copy"),
|
||||
createIssue: t("Create issue from selection"),
|
||||
createLink: t("Create link"),
|
||||
createLinkError: t("Sorry, an error occurred creating the link"),
|
||||
createNewDoc: t("Create a new doc"),
|
||||
|
||||
@@ -13,4 +13,9 @@ PluginManager.add([
|
||||
component: React.lazy(() => import("./Settings")),
|
||||
},
|
||||
},
|
||||
{
|
||||
...config,
|
||||
type: Hook.Icon,
|
||||
value: Icon,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Endpoints } from "@octokit/types";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { Integration, User } from "@server/models";
|
||||
import { CreateIssueResponse } from "@server/types";
|
||||
import { BaseIssueProvider } from "@server/utils/IssueProvider";
|
||||
import { IssueSource } from "@shared/schema";
|
||||
import {
|
||||
IntegrationService,
|
||||
IntegrationType,
|
||||
UnfurlResourceType,
|
||||
} from "@shared/types";
|
||||
import { GitHub } from "./github";
|
||||
|
||||
// This is needed to account for Octokit paginate response type mismatch.
|
||||
type ReposForInstallation =
|
||||
Endpoints["GET /installation/repositories"]["response"]["data"]["repositories"];
|
||||
|
||||
export class GitHubIssueProvider extends BaseIssueProvider {
|
||||
constructor() {
|
||||
super(IntegrationService.GitHub);
|
||||
}
|
||||
|
||||
async fetchSources(
|
||||
integration: Integration<IntegrationType.Embed>
|
||||
): Promise<IssueSource[]> {
|
||||
const client = await GitHub.authenticateAsInstallation(
|
||||
integration.settings.github!.installation.id
|
||||
);
|
||||
|
||||
const repos =
|
||||
(await client.requestRepos()) as unknown as ReposForInstallation;
|
||||
|
||||
const sources = repos.map<IssueSource>((repo) => ({
|
||||
id: String(repo.id),
|
||||
name: repo.name,
|
||||
owner: { id: String(repo.owner.id), name: repo.owner.login },
|
||||
service: IntegrationService.GitHub,
|
||||
}));
|
||||
|
||||
return sources;
|
||||
}
|
||||
|
||||
async createIssue(
|
||||
title: string,
|
||||
source: IssueSource,
|
||||
actor: User
|
||||
): Promise<CreateIssueResponse | undefined> {
|
||||
const integration = (await Integration.findOne({
|
||||
where: {
|
||||
service: IntegrationService.GitHub,
|
||||
teamId: actor.teamId,
|
||||
"settings.github.installation.account.name": source.owner.name,
|
||||
},
|
||||
})) as Integration<IntegrationType.Embed> | undefined;
|
||||
|
||||
if (!integration) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const client = await GitHub.authenticateAsInstallation(
|
||||
integration.settings.github!.installation.id
|
||||
);
|
||||
|
||||
const { data } = await client.createIssue({
|
||||
owner: source.owner.name,
|
||||
repo: source.name,
|
||||
title,
|
||||
});
|
||||
|
||||
return {
|
||||
...data,
|
||||
type: UnfurlResourceType.Issue,
|
||||
cacheKey: data.html_url,
|
||||
};
|
||||
} catch (err) {
|
||||
Logger.warn("Failed to create issue in GitHub", err);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import Router from "koa-router";
|
||||
import find from "lodash/find";
|
||||
import { IntegrationService, IntegrationType } from "@shared/types";
|
||||
import { parseDomain } from "@shared/utils/domains";
|
||||
import { createContext } from "@server/context";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import { transaction } from "@server/middlewares/transaction";
|
||||
@@ -88,30 +89,27 @@ router.get(
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
await Integration.create(
|
||||
{
|
||||
service: IntegrationService.GitHub,
|
||||
type: IntegrationType.Embed,
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
authenticationId: authentication.id,
|
||||
settings: {
|
||||
github: {
|
||||
installation: {
|
||||
id: installationId!,
|
||||
account: {
|
||||
id: installation.account?.id,
|
||||
name:
|
||||
// @ts-expect-error Property 'login' does not exist on type
|
||||
installation.account?.login,
|
||||
avatarUrl: installation.account?.avatar_url,
|
||||
},
|
||||
await Integration.createWithCtx(createContext({ user, transaction }), {
|
||||
service: IntegrationService.GitHub,
|
||||
type: IntegrationType.Embed,
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
authenticationId: authentication.id,
|
||||
settings: {
|
||||
github: {
|
||||
installation: {
|
||||
id: installationId!,
|
||||
account: {
|
||||
id: installation.account?.id,
|
||||
name:
|
||||
// @ts-expect-error Property 'login' does not exist on type
|
||||
installation.account?.login,
|
||||
avatarUrl: installation.account?.avatar_url,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
});
|
||||
ctx.redirect(GitHubUtils.url);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -40,6 +40,32 @@ const requestPlugin = (octokit: Octokit) => ({
|
||||
},
|
||||
}),
|
||||
|
||||
requestRepos: () =>
|
||||
octokit.paginate(octokit.rest.apps.listReposAccessibleToInstallation, {
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
}),
|
||||
|
||||
createIssue: async ({
|
||||
owner,
|
||||
repo,
|
||||
title,
|
||||
}: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
title: string;
|
||||
}) =>
|
||||
octokit.request(`POST /repos/{owner}/{repo}/issues`, {
|
||||
owner,
|
||||
repo,
|
||||
title,
|
||||
headers: {
|
||||
Accept: "application/vnd.github.text+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* Fetches app installations accessible to the user
|
||||
*
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { IntegrationService } from "@shared/types";
|
||||
import { Minute } from "@shared/utils/time";
|
||||
import { PluginManager, Hook } from "@server/utils/PluginManager";
|
||||
import config from "../plugin.json";
|
||||
import { GitHubIssueProvider } from "./GitHubIssueProvider";
|
||||
import router from "./api/github";
|
||||
import env from "./env";
|
||||
import { GitHub } from "./github";
|
||||
@@ -24,6 +26,10 @@ if (enabled) {
|
||||
type: Hook.UnfurlProvider,
|
||||
value: { unfurl: GitHub.unfurl, cacheExpiry: Minute.seconds },
|
||||
},
|
||||
{
|
||||
type: Hook.IssueProvider,
|
||||
value: new GitHubIssueProvider(),
|
||||
},
|
||||
{
|
||||
type: Hook.Uninstall,
|
||||
value: uninstall,
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
"use strict";
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
queryInterface.addColumn("integrations", "issueSources", {
|
||||
type: Sequelize.JSONB,
|
||||
allowNull: true,
|
||||
});
|
||||
},
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
queryInterface.removeColumn("integrations", "issueSources");
|
||||
},
|
||||
};
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
IsIn,
|
||||
AfterDestroy,
|
||||
} from "sequelize-typescript";
|
||||
import { IssueSource } from "@shared/schema";
|
||||
import { IntegrationType, IntegrationService } from "@shared/types";
|
||||
import type { IntegrationSettings } from "@shared/types";
|
||||
import Collection from "@server/models/Collection";
|
||||
@@ -53,6 +54,9 @@ class Integration<T = unknown> extends ParanoidModel<
|
||||
@Column(DataType.ARRAY(DataType.STRING))
|
||||
events: string[];
|
||||
|
||||
@Column(DataType.JSONB)
|
||||
issueSources: IssueSource[] | null;
|
||||
|
||||
// associations
|
||||
|
||||
@BelongsTo(() => User, "userId")
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Integration } from "@server/models";
|
||||
import BaseProcessor from "@server/queues/processors/BaseProcessor";
|
||||
import { IntegrationEvent, Event } from "@server/types";
|
||||
import { CacheHelper } from "@server/utils/CacheHelper";
|
||||
import CacheIssueSourcesTask from "../tasks/CacheIssueSourcesTask";
|
||||
|
||||
export default class IntegrationCreatedProcessor extends BaseProcessor {
|
||||
static applicableEvents: Event["name"][] = ["integrations.create"];
|
||||
@@ -18,6 +19,11 @@ export default class IntegrationCreatedProcessor extends BaseProcessor {
|
||||
return;
|
||||
}
|
||||
|
||||
// Store the available issue sources in the integration record.
|
||||
await CacheIssueSourcesTask.schedule({
|
||||
integrationId: integration.id,
|
||||
});
|
||||
|
||||
// Clear the cache of unfurled data for the team as it may be stale now.
|
||||
await CacheHelper.clearData(CacheHelper.getUnfurlKey(integration.teamId));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Integration } from "@server/models";
|
||||
import { sequelize } from "@server/storage/database";
|
||||
import { Hook, PluginManager } from "@server/utils/PluginManager";
|
||||
import BaseTask from "./BaseTask";
|
||||
|
||||
const plugins = PluginManager.getHooks(Hook.IssueProvider);
|
||||
|
||||
type Props = {
|
||||
integrationId: string;
|
||||
};
|
||||
|
||||
export default class CacheIssueSourcesTask extends BaseTask<Props> {
|
||||
async perform({ integrationId }: Props) {
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
const integration = await Integration.findByPk(integrationId, {
|
||||
transaction,
|
||||
lock: transaction.LOCK.UPDATE,
|
||||
});
|
||||
if (!integration) {
|
||||
return;
|
||||
}
|
||||
|
||||
const plugin = plugins.find(
|
||||
(p) => p.value.service === integration.service
|
||||
);
|
||||
if (!plugin) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sources = await plugin.value.fetchSources(integration);
|
||||
integration.issueSources = sources;
|
||||
await integration.save({ transaction });
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import groups from "./groups";
|
||||
import imports from "./imports";
|
||||
import installation from "./installation";
|
||||
import integrations from "./integrations";
|
||||
import issues from "./issues";
|
||||
import apiErrorHandler from "./middlewares/apiErrorHandler";
|
||||
import apiResponse from "./middlewares/apiResponse";
|
||||
import apiTracer from "./middlewares/apiTracer";
|
||||
@@ -99,6 +100,7 @@ router.use("/", urls.routes());
|
||||
router.use("/", userMemberships.routes());
|
||||
router.use("/", reactions.routes());
|
||||
router.use("/", imports.routes());
|
||||
router.use("/", issues.routes());
|
||||
|
||||
if (!env.isCloudHosted) {
|
||||
router.use("/", installation.routes());
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./issues";
|
||||
@@ -0,0 +1,70 @@
|
||||
import { InternalError, InvalidRequestError } from "@server/errors";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import validate from "@server/middlewares/validate";
|
||||
import { Integration } from "@server/models";
|
||||
import presentUnfurl from "@server/presenters/unfurl";
|
||||
import { APIContext } from "@server/types";
|
||||
import { CacheHelper } from "@server/utils/CacheHelper";
|
||||
import { Hook, PluginManager } from "@server/utils/PluginManager";
|
||||
import { IssueSource } from "@shared/schema";
|
||||
import { UserRole } from "@shared/types";
|
||||
import Router from "koa-router";
|
||||
import * as T from "./schema";
|
||||
|
||||
const router = new Router();
|
||||
const plugins = PluginManager.getHooks(Hook.IssueProvider);
|
||||
|
||||
router.post(
|
||||
"issues.list_sources",
|
||||
auth({ role: UserRole.Member }),
|
||||
async (ctx: APIContext) => {
|
||||
const { user } = ctx.state.auth;
|
||||
|
||||
const integrations = await Integration.findAll({
|
||||
attributes: ["issueSources"],
|
||||
where: { teamId: user.teamId },
|
||||
});
|
||||
|
||||
const sources = integrations
|
||||
.flatMap((integration) => integration.issueSources)
|
||||
.filter(Boolean) as IssueSource[];
|
||||
|
||||
ctx.body = {
|
||||
data: sources,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
"issues.create",
|
||||
auth({ role: UserRole.Member }),
|
||||
validate(T.IssuesCreateSchema),
|
||||
async (ctx: APIContext<T.IssuesCreateReq>) => {
|
||||
const { title, source } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
|
||||
const plugin = plugins.find((p) => p.value.service === source.service);
|
||||
|
||||
if (!plugin) {
|
||||
throw InvalidRequestError();
|
||||
}
|
||||
|
||||
const issue = await plugin.value.createIssue(title, source, user);
|
||||
|
||||
if (!issue) {
|
||||
throw InternalError();
|
||||
}
|
||||
|
||||
await CacheHelper.setData(
|
||||
CacheHelper.getUnfurlKey(user.teamId, issue.cacheKey),
|
||||
issue,
|
||||
plugin.value.cacheExpiry
|
||||
);
|
||||
|
||||
ctx.body = {
|
||||
data: await presentUnfurl(issue),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,12 @@
|
||||
import { IssueSource } from "@shared/schema";
|
||||
import { z } from "zod";
|
||||
import { BaseSchema } from "../schema";
|
||||
|
||||
export const IssuesCreateSchema = BaseSchema.extend({
|
||||
body: z.object({
|
||||
title: z.string().nonempty(),
|
||||
source: IssueSource,
|
||||
}),
|
||||
});
|
||||
|
||||
export type IssuesCreateReq = z.infer<typeof IssuesCreateSchema>;
|
||||
@@ -2,6 +2,7 @@ import { ParameterizedContext, DefaultContext } from "koa";
|
||||
import { IRouterParamContext } from "koa-router";
|
||||
import { InferAttributes, Model, Transaction } from "sequelize";
|
||||
import { z } from "zod";
|
||||
import { IssueSource } from "@shared/schema";
|
||||
import {
|
||||
CollectionSort,
|
||||
NavigationNode,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
JSONValue,
|
||||
UnfurlResourceType,
|
||||
ProsemirrorData,
|
||||
IntegrationType,
|
||||
} from "@shared/types";
|
||||
import { BaseSchema } from "@server/routes/api/schema";
|
||||
import { AccountProvisionerResult } from "./commands/accountProvisioner";
|
||||
@@ -585,6 +587,19 @@ export type UnfurlSignature = (
|
||||
|
||||
export type UninstallSignature = (integration: Integration) => Promise<void>;
|
||||
|
||||
export type CreateIssueResponse = Unfurl & { cacheKey: string };
|
||||
|
||||
export type IssueProvider = {
|
||||
listSources: (
|
||||
integration: Integration<IntegrationType.Embed>
|
||||
) => Promise<IssueSource[]>;
|
||||
createIssue: (
|
||||
title: string,
|
||||
source: IssueSource,
|
||||
actor: User
|
||||
) => Promise<CreateIssueResponse | undefined>;
|
||||
};
|
||||
|
||||
export type Replace<T, K extends keyof T, N extends string> = {
|
||||
[P in keyof T as P extends K ? N : P]: T[P extends K ? K : P];
|
||||
};
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Integration, User } from "@server/models";
|
||||
import { CreateIssueResponse } from "@server/types";
|
||||
import { IssueSource } from "@shared/schema";
|
||||
import {
|
||||
IntegrationType,
|
||||
IssueProviderIntegrationService,
|
||||
} from "@shared/types";
|
||||
import { Minute } from "@shared/utils/time";
|
||||
|
||||
export abstract class BaseIssueProvider {
|
||||
service: IssueProviderIntegrationService;
|
||||
cacheExpiry: number;
|
||||
|
||||
constructor(service: IssueProviderIntegrationService, cacheExpiry?: number) {
|
||||
this.service = service;
|
||||
this.cacheExpiry = cacheExpiry ?? Minute.seconds;
|
||||
}
|
||||
|
||||
abstract fetchSources(
|
||||
integration: Integration<IntegrationType.Embed>
|
||||
): Promise<IssueSource[]>;
|
||||
|
||||
abstract createIssue(
|
||||
title: string,
|
||||
source: IssueSource,
|
||||
actor: User
|
||||
): Promise<CreateIssueResponse | undefined>;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import Logger from "@server/logging/Logger";
|
||||
import type BaseProcessor from "@server/queues/processors/BaseProcessor";
|
||||
import type BaseTask from "@server/queues/tasks/BaseTask";
|
||||
import { UnfurlSignature, UninstallSignature } from "@server/types";
|
||||
import { BaseIssueProvider } from "./IssueProvider";
|
||||
|
||||
export enum PluginPriority {
|
||||
VeryHigh = 0,
|
||||
@@ -25,6 +26,7 @@ export enum Hook {
|
||||
API = "api",
|
||||
AuthProvider = "authProvider",
|
||||
EmailTemplate = "emailTemplate",
|
||||
IssueProvider = "issueProvider",
|
||||
Processor = "processor",
|
||||
Task = "task",
|
||||
UnfurlProvider = "unfurl",
|
||||
@@ -39,6 +41,7 @@ type PluginValueMap = {
|
||||
[Hook.API]: Router;
|
||||
[Hook.AuthProvider]: { router: Router; id: string };
|
||||
[Hook.EmailTemplate]: typeof BaseEmail;
|
||||
[Hook.IssueProvider]: BaseIssueProvider;
|
||||
[Hook.Processor]: typeof BaseProcessor;
|
||||
[Hook.Task]: typeof BaseTask<any>;
|
||||
[Hook.Uninstall]: UninstallSignature;
|
||||
|
||||
@@ -287,6 +287,8 @@ export const MentionPullRequest = observer((props: IssuePrProps) => {
|
||||
);
|
||||
});
|
||||
|
||||
export const MentionPlaceholder = () => <MentionLoading className="mention" />;
|
||||
|
||||
const MentionLoading = ({ className }: { className: string }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
|
||||
@@ -16,11 +16,17 @@ import * as React from "react";
|
||||
import { Primitive } from "utility-types";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import env from "../../env";
|
||||
import { MentionType, UnfurlResourceType, UnfurlResponse } from "../../types";
|
||||
import {
|
||||
MentionPlaceholder,
|
||||
MentionType,
|
||||
UnfurlResourceType,
|
||||
UnfurlResponse,
|
||||
} from "../../types";
|
||||
import {
|
||||
MentionCollection,
|
||||
MentionDocument,
|
||||
MentionIssue,
|
||||
MentionPlaceholder as MentionPlaceholderComp,
|
||||
MentionPullRequest,
|
||||
MentionUser,
|
||||
} from "../components/Mentions";
|
||||
@@ -141,6 +147,8 @@ export default class Mention extends Node {
|
||||
onChangeUnfurl={this.handleChangeUnfurl(props)}
|
||||
/>
|
||||
);
|
||||
case MentionPlaceholder:
|
||||
return <MentionPlaceholderComp />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
CollectionPermission,
|
||||
type ImportableIntegrationService,
|
||||
IntegrationService,
|
||||
IssueProviderIntegrationService,
|
||||
ProsemirrorDoc,
|
||||
} from "./types";
|
||||
import { PageType } from "plugins/notion/shared/types";
|
||||
@@ -57,3 +58,15 @@ export type ImportTaskOutput = {
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
}[];
|
||||
|
||||
export const IssueSource = z.object({
|
||||
id: z.string().nonempty(),
|
||||
name: z.string().nonempty(),
|
||||
owner: z.object({
|
||||
id: z.string().nonempty(),
|
||||
name: z.string().nonempty(),
|
||||
}),
|
||||
service: z.nativeEnum(IssueProviderIntegrationService),
|
||||
});
|
||||
|
||||
export type IssueSource = z.infer<typeof IssueSource>;
|
||||
|
||||
@@ -79,6 +79,8 @@ export enum MentionType {
|
||||
PullRequest = "pull_request",
|
||||
}
|
||||
|
||||
export const MentionPlaceholder = "mention_placeholder";
|
||||
|
||||
export type PublicEnv = {
|
||||
ROOT_SHARE_ID?: string;
|
||||
analytics: {
|
||||
@@ -146,6 +148,15 @@ export const UserCreatableIntegrationService = {
|
||||
Umami: IntegrationService.Umami,
|
||||
} as const;
|
||||
|
||||
export type IssueProviderIntegrationService = Extract<
|
||||
IntegrationService,
|
||||
IntegrationService.GitHub
|
||||
>;
|
||||
|
||||
export const IssueProviderIntegrationService = {
|
||||
Notion: IntegrationService.GitHub,
|
||||
} as const;
|
||||
|
||||
export enum CollectionPermission {
|
||||
Read = "read",
|
||||
ReadWrite = "read_write",
|
||||
|
||||
Reference in New Issue
Block a user