mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3187619328 | |||
| 4a8b8d5fa7 | |||
| 391fc5fdee | |||
| cbcf7d6a8e | |||
| 94eb1aa07d | |||
| ca66a6b2fa | |||
| 404a5991b3 |
@@ -211,6 +211,10 @@ GITHUB_APP_PRIVATE_KEY=
|
||||
LINEAR_CLIENT_ID=
|
||||
LINEAR_CLIENT_SECRET=
|
||||
|
||||
# The GitLab integration allows previewing issue and merge request links as rich mentions
|
||||
GITLAB_CLIENT_ID=
|
||||
GITLAB_CLIENT_SECRET=
|
||||
|
||||
# For a complete Slack integration with search and posting to channels the
|
||||
# following configs are also needed in addition to Slack authentication:
|
||||
# DOCS: https://docs.getoutline.com/s/hosting/doc/slack-G2mc8DOJHk
|
||||
|
||||
@@ -26,6 +26,3 @@ updates:
|
||||
aws:
|
||||
patterns:
|
||||
- "@aws-sdk/*"
|
||||
radix-ui:
|
||||
patterns:
|
||||
- "@radix-ui/*"
|
||||
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
node-version: 22.x
|
||||
cache: "yarn"
|
||||
- run: yarn install --frozen-lockfile --prefer-offline
|
||||
- run: yarn lint --quiet
|
||||
- run: yarn lint
|
||||
|
||||
types:
|
||||
needs: build
|
||||
|
||||
@@ -3,6 +3,7 @@ import { toast } from "sonner";
|
||||
import Comment from "~/models/Comment";
|
||||
import CommentDeleteDialog from "~/components/CommentDeleteDialog";
|
||||
import ViewReactionsDialog from "~/components/Reactions/ViewReactionsDialog";
|
||||
import history from "~/utils/history";
|
||||
import { createActionV2 } from "..";
|
||||
import { ActiveDocumentSection } from "../sections";
|
||||
|
||||
@@ -49,6 +50,16 @@ export const resolveCommentFactory = ({
|
||||
stores.policies.abilities(comment.documentId).update,
|
||||
perform: async ({ t }) => {
|
||||
await comment.resolve();
|
||||
|
||||
const locationState = history.location.state as Record<string, unknown>;
|
||||
history.replace({
|
||||
...history.location,
|
||||
state: {
|
||||
sidebarContext: locationState["sidebarContext"],
|
||||
commentId: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
onResolve();
|
||||
toast.success(t("Thread resolved"));
|
||||
},
|
||||
@@ -71,6 +82,16 @@ export const unresolveCommentFactory = ({
|
||||
stores.policies.abilities(comment.documentId).update,
|
||||
perform: async () => {
|
||||
await comment.unresolve();
|
||||
|
||||
const locationState = history.location.state as Record<string, unknown>;
|
||||
history.replace({
|
||||
...history.location,
|
||||
state: {
|
||||
sidebarContext: locationState["sidebarContext"],
|
||||
commentId: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
onUnresolve();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -11,15 +11,9 @@ class DocumentContext {
|
||||
/** The editor instance for this document */
|
||||
editor?: Editor;
|
||||
|
||||
/** The ID of the currently focused comment, or null if no comment is focused */
|
||||
@observable
|
||||
focusedCommentId: string | null = null;
|
||||
|
||||
/** Whether the editor has been initialized */
|
||||
@observable
|
||||
isEditorInitialized: boolean = false;
|
||||
|
||||
/** The headings in the document */
|
||||
@observable
|
||||
headings: Heading[] = [];
|
||||
|
||||
@@ -45,11 +39,6 @@ class DocumentContext {
|
||||
this.isEditorInitialized = initialized;
|
||||
};
|
||||
|
||||
@action
|
||||
setFocusedCommentId = (commentId: string | null) => {
|
||||
this.focusedCommentId = commentId;
|
||||
};
|
||||
|
||||
@action
|
||||
updateState = () => {
|
||||
this.updateHeadings();
|
||||
|
||||
@@ -30,10 +30,15 @@ const HoverPreviewIssue = React.forwardRef(function _HoverPreviewIssue(
|
||||
) {
|
||||
const authorName = author.name;
|
||||
const urlObj = new URL(url);
|
||||
const service =
|
||||
urlObj.hostname === "github.com"
|
||||
? IntegrationService.GitHub
|
||||
: IntegrationService.Linear;
|
||||
let service;
|
||||
|
||||
if (urlObj.hostname === "github.com") {
|
||||
service = IntegrationService.GitHub;
|
||||
} else if (urlObj.hostname === "gitlab.com") {
|
||||
service = IntegrationService.GitLab;
|
||||
} else {
|
||||
service = IntegrationService.Linear;
|
||||
}
|
||||
|
||||
return (
|
||||
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
|
||||
|
||||
@@ -57,7 +57,7 @@ export default function useEditorClickHandlers({ shareId }: Params) {
|
||||
}
|
||||
|
||||
if (isDocumentUrl(navigateTo)) {
|
||||
const document = documents.get(navigateTo);
|
||||
const document = documents.getByUrl(navigateTo);
|
||||
if (document) {
|
||||
navigateTo = document.path;
|
||||
}
|
||||
|
||||
@@ -1,44 +1,13 @@
|
||||
import { useLocation } from "react-router-dom";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
import useStores from "./useStores";
|
||||
import { useDocumentContext } from "~/components/DocumentContext";
|
||||
import { useEffect } from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
|
||||
/**
|
||||
* Custom hook to retrieve the currently focused comment in a document.
|
||||
* It checks both the document context and the query string for the comment ID.
|
||||
* If a comment is focused, it returns the comment itself or the parent thread if it exists
|
||||
*/
|
||||
export function useFocusedComment() {
|
||||
export default function useFocusedComment() {
|
||||
const { comments } = useStores();
|
||||
const context = useDocumentContext();
|
||||
const location = useLocation<{ commentId?: string }>();
|
||||
const query = useQuery();
|
||||
const focusedCommentId = context.focusedCommentId || query.get("commentId");
|
||||
const focusedCommentId = location.state?.commentId || query.get("commentId");
|
||||
const comment = focusedCommentId ? comments.get(focusedCommentId) : undefined;
|
||||
const history = useHistory();
|
||||
|
||||
// Move the query string into context
|
||||
useEffect(() => {
|
||||
if (focusedCommentId && context.focusedCommentId !== focusedCommentId) {
|
||||
context.setFocusedCommentId(focusedCommentId);
|
||||
}
|
||||
}, [focusedCommentId, context]);
|
||||
|
||||
// Clear query string from location
|
||||
useEffect(() => {
|
||||
if (focusedCommentId) {
|
||||
const params = new URLSearchParams(history.location.search);
|
||||
|
||||
if (params.get("commentId") === focusedCommentId) {
|
||||
params.delete("commentId");
|
||||
history.replace({
|
||||
pathname: history.location.pathname,
|
||||
search: params.toString(),
|
||||
state: history.location.state,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [focusedCommentId, history]);
|
||||
|
||||
return comment?.parentCommentId
|
||||
? comments.get(comment.parentCommentId)
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { useState, useCallback } from "react";
|
||||
import { Primitive } from "utility-types";
|
||||
import Storage from "@shared/utils/Storage";
|
||||
import { isBrowser } from "@shared/utils/browser";
|
||||
import Logger from "~/utils/Logger";
|
||||
import useEventListener from "./useEventListener";
|
||||
import usePrevious from "./usePrevious";
|
||||
|
||||
type Options = {
|
||||
/* Whether to listen and react to changes in the value from other tabs */
|
||||
@@ -42,7 +41,6 @@ export default function usePersistedState<T extends Primitive | object>(
|
||||
defaultValue: T,
|
||||
options?: Options
|
||||
): [T, (value: T) => void] {
|
||||
const previousKey = usePrevious(key);
|
||||
const [storedValue, setStoredValue] = useState(() => {
|
||||
if (!isBrowser) {
|
||||
return defaultValue;
|
||||
@@ -67,13 +65,6 @@ export default function usePersistedState<T extends Primitive | object>(
|
||||
[key, storedValue]
|
||||
);
|
||||
|
||||
// Sync state when key changes
|
||||
useEffect(() => {
|
||||
if (previousKey !== key) {
|
||||
setStoredValue(Storage.get(key) ?? defaultValue);
|
||||
}
|
||||
}, [previousKey, key, defaultValue]);
|
||||
|
||||
// Listen to the key changing in other tabs so we can keep UI in sync
|
||||
useEventListener("storage", (event: StorageEvent) => {
|
||||
if (options?.listen !== false && event.key === key && event.newValue) {
|
||||
|
||||
@@ -22,9 +22,6 @@ class GroupUser extends Model {
|
||||
/** The group that the user belongs to. */
|
||||
@Relation(() => Group, { onDelete: "cascade" })
|
||||
group: Group;
|
||||
|
||||
/** Whether the user is an admin of the group. */
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
export default GroupUser;
|
||||
|
||||
@@ -75,7 +75,8 @@ const CollectionScene = observer(function _CollectionScene() {
|
||||
const id = params.id || "";
|
||||
const urlId = id.split("-").pop() ?? "";
|
||||
|
||||
const collection: Collection | null | undefined = collections.get(id);
|
||||
const collection: Collection | null | undefined =
|
||||
collections.getByUrl(id) || collections.get(id);
|
||||
const can = usePolicy(collection);
|
||||
|
||||
const { pins, count } = usePinnedDocuments(urlId, collection?.id);
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
import queryString from "query-string";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory, useLocation } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { UserPreference } from "@shared/types";
|
||||
import { InputSelect, Option } from "~/components/InputSelect";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
import { CommentSortType } from "~/types";
|
||||
|
||||
type Props = {
|
||||
/** Callback when the sort type changes */
|
||||
onChange?: (sortType: CommentSortType | "resolved") => void;
|
||||
/** Whether resolved comments are being viewed */
|
||||
viewingResolved?: boolean;
|
||||
};
|
||||
|
||||
const CommentSortMenu = ({ viewingResolved, onChange }: Props) => {
|
||||
const CommentSortMenu = () => {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const sidebarContext = useLocationSidebarContext();
|
||||
const history = useHistory();
|
||||
const user = useCurrentUser();
|
||||
const params = useQuery();
|
||||
|
||||
const preferredSortType = user.getPreference(
|
||||
UserPreference.SortCommentsByOrderInDocument
|
||||
@@ -24,23 +25,42 @@ const CommentSortMenu = ({ viewingResolved, onChange }: Props) => {
|
||||
? CommentSortType.OrderInDocument
|
||||
: CommentSortType.MostRecent;
|
||||
|
||||
const viewingResolved = params.get("resolved") === "";
|
||||
const value = viewingResolved ? "resolved" : preferredSortType;
|
||||
|
||||
const handleChange = React.useCallback(
|
||||
(val: CommentSortType | "resolved") => {
|
||||
if (val !== "resolved") {
|
||||
if (val !== preferredSortType) {
|
||||
user.setPreference(
|
||||
UserPreference.SortCommentsByOrderInDocument,
|
||||
val === CommentSortType.OrderInDocument
|
||||
);
|
||||
void user.save();
|
||||
}
|
||||
(val: string) => {
|
||||
if (val === "resolved") {
|
||||
history.push({
|
||||
search: queryString.stringify({
|
||||
...queryString.parse(location.search),
|
||||
resolved: "",
|
||||
}),
|
||||
pathname: location.pathname,
|
||||
state: { sidebarContext },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
onChange?.(val);
|
||||
const sortType = val as CommentSortType;
|
||||
if (sortType !== preferredSortType) {
|
||||
user.setPreference(
|
||||
UserPreference.SortCommentsByOrderInDocument,
|
||||
sortType === CommentSortType.OrderInDocument
|
||||
);
|
||||
void user.save();
|
||||
}
|
||||
|
||||
history.push({
|
||||
search: queryString.stringify({
|
||||
...queryString.parse(location.search),
|
||||
resolved: undefined,
|
||||
}),
|
||||
pathname: location.pathname,
|
||||
state: { sidebarContext },
|
||||
});
|
||||
},
|
||||
[user, onChange, preferredSortType]
|
||||
[history, location, sidebarContext, user, preferredSortType]
|
||||
);
|
||||
|
||||
const options: Option[] = React.useMemo(
|
||||
|
||||
@@ -2,6 +2,7 @@ import { observer } from "mobx-react";
|
||||
import { darken } from "polished";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory, useLocation } from "react-router-dom";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import styled, { css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
@@ -16,6 +17,7 @@ import Facepile from "~/components/Facepile";
|
||||
import Fade from "~/components/Fade";
|
||||
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import useOnClickOutside from "~/hooks/useOnClickOutside";
|
||||
import usePersistedState from "~/hooks/usePersistedState";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
@@ -49,11 +51,14 @@ function CommentThread({
|
||||
collapseNumDisplayed = 3,
|
||||
}: Props) {
|
||||
const [scrollOnMount] = React.useState(focused && !window.location.hash);
|
||||
const { editor, setFocusedCommentId } = useDocumentContext();
|
||||
const { editor } = useDocumentContext();
|
||||
const { comments } = useStores();
|
||||
const topRef = React.useRef<HTMLDivElement>(null);
|
||||
const replyRef = React.useRef<HTMLDivElement>(null);
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const sidebarContext = useLocationSidebarContext();
|
||||
const [autoFocus, setAutoFocusOn, setAutoFocusOff] = useBoolean(thread.isNew);
|
||||
const user = useCurrentUser();
|
||||
|
||||
@@ -97,7 +102,14 @@ function CommentThread({
|
||||
!(event.target as HTMLElement).classList.contains("comment") &&
|
||||
event.defaultPrevented === false
|
||||
) {
|
||||
setFocusedCommentId(null);
|
||||
history.replace({
|
||||
search: location.search,
|
||||
pathname: location.pathname,
|
||||
state: {
|
||||
commentId: undefined,
|
||||
sidebarContext,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -106,7 +118,15 @@ function CommentThread({
|
||||
}, [editor, thread.id]);
|
||||
|
||||
const handleClickThread = () => {
|
||||
setFocusedCommentId(thread.id);
|
||||
history.replace({
|
||||
// Clear any commentId from the URL when explicitly focusing a thread
|
||||
search: thread.isResolved ? "resolved=" : "",
|
||||
pathname: location.pathname.replace(/\/history$/, ""),
|
||||
state: {
|
||||
commentId: thread.id,
|
||||
sidebarContext,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleClickExpand = (ev: React.SyntheticEvent) => {
|
||||
|
||||
@@ -30,7 +30,6 @@ import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import CommentMenu from "~/menus/CommentMenu";
|
||||
import CommentEditor from "./CommentEditor";
|
||||
import { HighlightedText } from "./HighlightText";
|
||||
import { useDocumentContext } from "~/components/DocumentContext";
|
||||
|
||||
/**
|
||||
* Hook to calculate if we should display a timestamp on a comment
|
||||
@@ -112,7 +111,6 @@ function CommentThreadItem({
|
||||
onEditStart,
|
||||
onEditEnd,
|
||||
}: Props) {
|
||||
const { setFocusedCommentId } = useDocumentContext();
|
||||
const { t } = useTranslation();
|
||||
const user = useCurrentUser();
|
||||
const [data, setData] = React.useState(comment.data);
|
||||
@@ -156,9 +154,6 @@ function CommentThreadItem({
|
||||
const handleUpdate = React.useCallback(
|
||||
(attrs: { resolved: boolean }) => {
|
||||
onUpdate?.(comment.id, attrs);
|
||||
if ("resolved" in attrs) {
|
||||
setFocusedCommentId(null);
|
||||
}
|
||||
},
|
||||
[comment.id, onUpdate]
|
||||
);
|
||||
|
||||
@@ -13,7 +13,7 @@ import Fade from "~/components/Fade";
|
||||
import Flex from "~/components/Flex";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import { useFocusedComment } from "~/hooks/useFocusedComment";
|
||||
import useFocusedComment from "~/hooks/useFocusedComment";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import usePersistedState from "~/hooks/usePersistedState";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
@@ -31,13 +31,11 @@ function Comments() {
|
||||
const { editor, isEditorInitialized } = useDocumentContext();
|
||||
const { t } = useTranslation();
|
||||
const match = useRouteMatch<{ documentSlug: string }>();
|
||||
const document = documents.get(match.params.documentSlug);
|
||||
const params = useQuery();
|
||||
const document = documents.getByUrl(match.params.documentSlug);
|
||||
const focusedComment = useFocusedComment();
|
||||
const can = usePolicy(document);
|
||||
const query = useQuery();
|
||||
const [viewingResolved, setViewingResolved] = useState(
|
||||
query.get("resolved") !== null || focusedComment?.isResolved || false
|
||||
);
|
||||
|
||||
const scrollableRef = useRef<HTMLDivElement | null>(null);
|
||||
const prevThreadCount = useRef(0);
|
||||
const isAtBottom = useRef(true);
|
||||
@@ -45,13 +43,6 @@ function Comments() {
|
||||
|
||||
useKeyDown("Escape", () => document && ui.set({ commentsExpanded: false }));
|
||||
|
||||
// Account for the resolved status of the comment changing
|
||||
useEffect(() => {
|
||||
if (focusedComment && focusedComment.isResolved !== viewingResolved) {
|
||||
setViewingResolved(focusedComment.isResolved);
|
||||
}
|
||||
}, [focusedComment, viewingResolved]);
|
||||
|
||||
const [draft, onSaveDraft] = usePersistedState<ProsemirrorData | undefined>(
|
||||
`draft-${document?.id}-new`,
|
||||
undefined
|
||||
@@ -66,6 +57,7 @@ function Comments() {
|
||||
}
|
||||
: { type: CommentSortType.MostRecent };
|
||||
|
||||
const viewingResolved = params.get("resolved") === "";
|
||||
const threads = !document
|
||||
? []
|
||||
: viewingResolved
|
||||
@@ -132,12 +124,7 @@ function Comments() {
|
||||
title={
|
||||
<Flex align="center" justify="space-between" auto>
|
||||
<span>{t("Comments")}</span>
|
||||
<CommentSortMenu
|
||||
viewingResolved={viewingResolved}
|
||||
onChange={(val) => {
|
||||
setViewingResolved(val === "resolved");
|
||||
}}
|
||||
/>
|
||||
<CommentSortMenu />
|
||||
</Flex>
|
||||
}
|
||||
onClose={() => ui.set({ commentsExpanded: false })}
|
||||
|
||||
@@ -67,7 +67,9 @@ function DataLoader({ match, children }: Props) {
|
||||
const { revisionId, documentSlug } = match.params;
|
||||
|
||||
// Allows loading by /doc/slug-<urlId> or /doc/<id>
|
||||
const document = documents.get(match.params.documentSlug);
|
||||
const document =
|
||||
documents.getByUrl(match.params.documentSlug) ??
|
||||
documents.get(match.params.documentSlug);
|
||||
|
||||
if (document) {
|
||||
setDocument(document);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { mergeRefs } from "react-merge-refs";
|
||||
import { useRouteMatch } from "react-router-dom";
|
||||
import { useHistory, useRouteMatch } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import Text from "@shared/components/Text";
|
||||
import { richExtensions, withComments } from "@shared/editor/nodes";
|
||||
@@ -19,7 +19,7 @@ import Time from "~/components/Time";
|
||||
import { withUIExtensions } from "~/editor/extensions";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import { useFocusedComment } from "~/hooks/useFocusedComment";
|
||||
import useFocusedComment from "~/hooks/useFocusedComment";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
@@ -55,15 +55,15 @@ type Props = Omit<EditorProps, "editorStyle"> & {
|
||||
* The main document editor includes an editable title with metadata below it,
|
||||
* and support for commenting.
|
||||
*/
|
||||
function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
function DocumentEditor(props: Props, ref: React.RefObject<unknown>) {
|
||||
const titleRef = React.useRef<RefHandle>(null);
|
||||
const { t } = useTranslation();
|
||||
const match = useRouteMatch();
|
||||
const { setFocusedCommentId } = useDocumentContext();
|
||||
const focusedComment = useFocusedComment();
|
||||
const { ui, comments } = useStores();
|
||||
const user = useCurrentUser({ rejectOnEmpty: false });
|
||||
const team = useCurrentTeam({ rejectOnEmpty: false });
|
||||
const history = useHistory();
|
||||
const sidebarContext = useLocationSidebarContext();
|
||||
const params = useQuery();
|
||||
const {
|
||||
@@ -95,11 +95,18 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
(focusedComment.isResolved && !viewingResolved) ||
|
||||
(!focusedComment.isResolved && viewingResolved)
|
||||
) {
|
||||
setFocusedCommentId(focusedComment.id);
|
||||
history.replace({
|
||||
search: focusedComment.isResolved ? "resolved=" : "",
|
||||
pathname: location.pathname,
|
||||
state: {
|
||||
commentId: focusedComment.id,
|
||||
sidebarContext,
|
||||
},
|
||||
});
|
||||
}
|
||||
ui.set({ commentsExpanded: true });
|
||||
}
|
||||
}, [focusedComment, ui, document.id, params]);
|
||||
}, [focusedComment, ui, document.id, history, params, sidebarContext]);
|
||||
|
||||
// Save document when blurring title, but delay so that if clicking on a
|
||||
// button this is allowed to execute first.
|
||||
@@ -120,6 +127,16 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
[focusAtStart, ref]
|
||||
);
|
||||
|
||||
const handleClickComment = React.useCallback(
|
||||
(commentId: string) => {
|
||||
history.replace({
|
||||
pathname: window.location.pathname.replace(/\/history$/, ""),
|
||||
state: { commentId, sidebarContext },
|
||||
});
|
||||
},
|
||||
[history, sidebarContext]
|
||||
);
|
||||
|
||||
// Create a Comment model in local store when a comment mark is created, this
|
||||
// acts as a local draft before submission.
|
||||
const handleDraftComment = React.useCallback(
|
||||
@@ -139,9 +156,13 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
);
|
||||
comment.id = commentId;
|
||||
comments.add(comment);
|
||||
setFocusedCommentId(commentId);
|
||||
|
||||
history.replace({
|
||||
pathname: window.location.pathname.replace(/\/history$/, ""),
|
||||
state: { commentId, sidebarContext },
|
||||
});
|
||||
},
|
||||
[comments, user?.id, props.id]
|
||||
[comments, user?.id, props.id, history, sidebarContext]
|
||||
);
|
||||
|
||||
// Soft delete the Comment model when associated mark is totally removed.
|
||||
@@ -237,7 +258,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
userId={user?.id}
|
||||
focusedCommentId={focusedComment?.id}
|
||||
onClickCommentMark={
|
||||
commentingEnabled && can.comment ? setFocusedCommentId : undefined
|
||||
commentingEnabled && can.comment ? handleClickComment : undefined
|
||||
}
|
||||
onCreateCommentMark={
|
||||
commentingEnabled && can.comment ? handleDraftComment : undefined
|
||||
|
||||
@@ -166,7 +166,7 @@ function DocumentHeader({
|
||||
);
|
||||
|
||||
useKeyDown(
|
||||
(event) => event.ctrlKey && event.altKey && event.code === "KeyH",
|
||||
(event) => event.ctrlKey && event.altKey && event.key === "˙",
|
||||
handleToggle,
|
||||
{
|
||||
allowInInput: true,
|
||||
|
||||
@@ -34,7 +34,7 @@ function History() {
|
||||
const match = useRouteMatch<{ documentSlug: string }>();
|
||||
const history = useHistory();
|
||||
const sidebarContext = useLocationSidebarContext();
|
||||
const document = documents.get(match.params.documentSlug);
|
||||
const document = documents.getByUrl(match.params.documentSlug);
|
||||
const [revisionsOffset, setRevisionsOffset] = React.useState(0);
|
||||
const [eventsOffset, setEventsOffset] = React.useState(0);
|
||||
|
||||
|
||||
@@ -51,7 +51,10 @@ type MessageEvent = {
|
||||
};
|
||||
};
|
||||
|
||||
function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
|
||||
function MultiplayerEditor(
|
||||
{ onSynced, ...props }: Props,
|
||||
ref: React.Ref<unknown>
|
||||
) {
|
||||
const documentId = props.id;
|
||||
const history = useHistory();
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import invariant from "invariant";
|
||||
import find from "lodash/find";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import orderBy from "lodash/orderBy";
|
||||
import sortBy from "lodash/sortBy";
|
||||
@@ -185,7 +186,7 @@ export default class CollectionsStore extends Store<Collection> {
|
||||
statusFilter: [CollectionStatusFilter.Archived],
|
||||
});
|
||||
|
||||
get(id: string = ""): Collection | undefined {
|
||||
get(id: string): Collection | undefined {
|
||||
return (
|
||||
this.data.get(id) ??
|
||||
this.orderedData.find((collection) => id.endsWith(collection.urlId))
|
||||
@@ -241,6 +242,10 @@ export default class CollectionsStore extends Store<Collection> {
|
||||
return this.orderedData.map((collection) => collection.asNavigationNode);
|
||||
}
|
||||
|
||||
getByUrl(url: string): Collection | null | undefined {
|
||||
return find(this.orderedData, (col: Collection) => url.endsWith(col.urlId));
|
||||
}
|
||||
|
||||
async delete(collection: Collection) {
|
||||
await super.delete(collection);
|
||||
await this.rootStore.documents.fetchRecentlyUpdated();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import invariant from "invariant";
|
||||
import compact from "lodash/compact";
|
||||
import filter from "lodash/filter";
|
||||
import find from "lodash/find";
|
||||
import omitBy from "lodash/omitBy";
|
||||
import orderBy from "lodash/orderBy";
|
||||
import { observable, action, computed, runInAction } from "mobx";
|
||||
@@ -459,7 +460,7 @@ export default class DocumentsStore extends Store<Document> {
|
||||
|
||||
@action
|
||||
prefetchDocument = async (id: string) => {
|
||||
if (!this.get(id)) {
|
||||
if (!this.data.get(id) && !this.getByUrl(id)) {
|
||||
return this.fetch(id, {
|
||||
prefetch: true,
|
||||
});
|
||||
@@ -745,6 +746,12 @@ export default class DocumentsStore extends Store<Document> {
|
||||
return subscription?.delete();
|
||||
};
|
||||
|
||||
getByUrl = (url = ""): Document | undefined =>
|
||||
find(
|
||||
this.orderedData,
|
||||
(doc) => url.endsWith(doc.urlId) || url.endsWith(doc.id)
|
||||
);
|
||||
|
||||
getCollectionForDocument(document: Document) {
|
||||
return document.collectionId
|
||||
? this.rootStore.collections.get(document.collectionId)
|
||||
|
||||
@@ -43,19 +43,10 @@ export default class GroupUsersStore extends Store<GroupUser> {
|
||||
};
|
||||
|
||||
@action
|
||||
async create({
|
||||
groupId,
|
||||
userId,
|
||||
isAdmin = false,
|
||||
}: {
|
||||
groupId: string;
|
||||
userId: string;
|
||||
isAdmin?: boolean;
|
||||
}) {
|
||||
async create({ groupId, userId }: { groupId: string; userId: string }) {
|
||||
const res = await client.post("/groups.add_user", {
|
||||
id: groupId,
|
||||
userId,
|
||||
isAdmin,
|
||||
});
|
||||
invariant(res?.data, "Group Membership data should be available");
|
||||
res.data.users.forEach(this.rootStore.users.add);
|
||||
@@ -79,29 +70,6 @@ export default class GroupUsersStore extends Store<GroupUser> {
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
async updateUser({
|
||||
groupId,
|
||||
userId,
|
||||
isAdmin,
|
||||
}: {
|
||||
groupId: string;
|
||||
userId: string;
|
||||
isAdmin: boolean;
|
||||
}) {
|
||||
const res = await client.post("/groups.update_user", {
|
||||
id: groupId,
|
||||
userId,
|
||||
isAdmin,
|
||||
});
|
||||
invariant(res?.data, "Group Membership data should be available");
|
||||
res.data.users.forEach(this.rootStore.users.add);
|
||||
res.data.groups.forEach(this.rootStore.groups.add);
|
||||
|
||||
const groupMemberships = res.data.groupMemberships.map(this.add);
|
||||
return groupMemberships[0];
|
||||
}
|
||||
|
||||
@action
|
||||
removeGroupMemberships = (groupId: string) => {
|
||||
this.data.forEach((_, key) => {
|
||||
|
||||
@@ -34,6 +34,13 @@ class IntegrationsStore extends Store<Integration> {
|
||||
(integration) => integration.service === IntegrationService.Linear
|
||||
);
|
||||
}
|
||||
|
||||
@computed
|
||||
get gitlab(): Integration<IntegrationType.Embed>[] {
|
||||
return this.orderedData.filter(
|
||||
(integration) => integration.service === IntegrationService.GitLab
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default IntegrationsStore;
|
||||
|
||||
@@ -47,7 +47,7 @@ class ApiClient {
|
||||
this.shareId = shareId;
|
||||
};
|
||||
|
||||
fetch = async <T = any>(
|
||||
fetch = async <T = unknown>(
|
||||
path: string,
|
||||
method: string,
|
||||
data: JSONObject | FormData | undefined,
|
||||
@@ -180,7 +180,7 @@ class ApiClient {
|
||||
const error: {
|
||||
message?: string;
|
||||
error?: string;
|
||||
data?: Record<string, any>;
|
||||
data?: Record<string, unknown>;
|
||||
} = {};
|
||||
|
||||
try {
|
||||
@@ -244,13 +244,13 @@ class ApiClient {
|
||||
throw err;
|
||||
};
|
||||
|
||||
get = <T = any>(
|
||||
get = <T = unknown>(
|
||||
path: string,
|
||||
data: JSONObject | undefined,
|
||||
options?: FetchOptions
|
||||
) => this.fetch<T>(path, "GET", data, options);
|
||||
|
||||
post = <T = any>(
|
||||
post = <T = unknown>(
|
||||
path: string,
|
||||
data?: JSONObject | FormData | undefined,
|
||||
options?: FetchOptions
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ type LogCategory =
|
||||
| "plugins"
|
||||
| "policies";
|
||||
|
||||
type Extra = Record<string, any>;
|
||||
type Extra = Record<string, unknown>;
|
||||
|
||||
class Logger {
|
||||
/**
|
||||
|
||||
@@ -37,6 +37,16 @@ export const isURLMentionable = ({
|
||||
);
|
||||
}
|
||||
|
||||
case IntegrationService.GitLab: {
|
||||
const settings =
|
||||
integration.settings as IntegrationSettings<IntegrationType.Embed>;
|
||||
|
||||
return (
|
||||
hostname === "gitlab.com" &&
|
||||
settings.gitlab?.project.path_with_namespace === pathParts.slice(1, -2).join("/") // ensure installed project path matches with the provided url.
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
@@ -67,6 +77,16 @@ export const determineMentionType = ({
|
||||
return type === "issue" ? MentionType.Issue : undefined;
|
||||
}
|
||||
|
||||
case IntegrationService.GitLab: {
|
||||
const type = pathParts[pathParts.length - 2];
|
||||
if (type === "issues") {
|
||||
return MentionType.Issue;
|
||||
} else if (type === "merge_requests") {
|
||||
return MentionType.PullRequest;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ export function settingsPath(...args: string[]): string {
|
||||
|
||||
export function commentPath(document: Document, comment: Comment): string {
|
||||
return `${documentPath(document)}?commentId=${comment.id}${
|
||||
comment.isResolved ? "&resolved=1" : ""
|
||||
comment.isResolved ? "&resolved=" : ""
|
||||
}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ export default {
|
||||
// TypeScript files
|
||||
"**/*.[tj]s?(x)": [
|
||||
(f) => `prettier --write ${f.join(" ")}`,
|
||||
(f) => (f.length > 20 ? `yarn lint --fix` : `oxlint ${f.join(" ")} --fix`),
|
||||
(f) => (f.length > 20 ? `yarn lint` : `oxlint ${f.join(" ")}`),
|
||||
() => `yarn build:i18n`,
|
||||
() => "git add shared/i18n/locales/en_US/translation.json",
|
||||
],
|
||||
|
||||
+30
-30
@@ -51,32 +51,32 @@
|
||||
"> 0.25%, not dead"
|
||||
],
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.873.0",
|
||||
"@aws-sdk/lib-storage": "3.873.0",
|
||||
"@aws-sdk/s3-presigned-post": "3.873.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.873.0",
|
||||
"@aws-sdk/signature-v4-crt": "^3.873.0",
|
||||
"@babel/core": "^7.28.3",
|
||||
"@aws-sdk/client-s3": "3.864.0",
|
||||
"@aws-sdk/lib-storage": "3.864.0",
|
||||
"@aws-sdk/s3-presigned-post": "3.864.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.864.0",
|
||||
"@aws-sdk/signature-v4-crt": "^3.864.0",
|
||||
"@babel/core": "^7.27.7",
|
||||
"@babel/plugin-proposal-decorators": "^7.28.0",
|
||||
"@babel/plugin-transform-class-properties": "^7.27.1",
|
||||
"@babel/plugin-transform-destructuring": "^7.28.0",
|
||||
"@babel/plugin-transform-regenerator": "^7.28.3",
|
||||
"@babel/preset-env": "^7.28.3",
|
||||
"@babel/plugin-transform-regenerator": "^7.28.1",
|
||||
"@babel/preset-env": "^7.28.0",
|
||||
"@babel/preset-react": "^7.27.1",
|
||||
"@benrbray/prosemirror-math": "^0.2.2",
|
||||
"@bull-board/api": "^6.7.10",
|
||||
"@bull-board/koa": "^6.12.0",
|
||||
"@css-inline/css-inline-wasm": "^0.17.0",
|
||||
"@css-inline/css-inline-wasm": "^0.14.3",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^6.0.1",
|
||||
"@dnd-kit/sortable": "^7.0.2",
|
||||
"@dotenvx/dotenvx": "^1.49.0",
|
||||
"@dotenvx/dotenvx": "^1.48.4",
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@fast-csv/parse": "^5.0.5",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.7.2",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.7.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.7.2",
|
||||
"@fortawesome/react-fontawesome": "^0.2.6",
|
||||
"@fortawesome/react-fontawesome": "^0.2.3",
|
||||
"@getoutline/react-roving-tabindex": "^3.2.4",
|
||||
"@hocuspocus/extension-redis": "1.1.2",
|
||||
"@hocuspocus/extension-throttle": "1.1.2",
|
||||
@@ -84,22 +84,22 @@
|
||||
"@hocuspocus/server": "1.1.2",
|
||||
"@joplin/turndown-plugin-gfm": "^1.0.49",
|
||||
"@juggle/resize-observer": "^3.4.0",
|
||||
"@linear/sdk": "^39.2.1",
|
||||
"@linear/sdk": "^39.0.0",
|
||||
"@node-oauth/oauth2-server": "^5.2.0",
|
||||
"@notionhq/client": "^2.3.0",
|
||||
"@octokit/auth-app": "^6.1.4",
|
||||
"@octokit/webhooks": "^13.9.1",
|
||||
"@outlinewiki/koa-passport": "^4.2.1",
|
||||
"@outlinewiki/passport-azure-ad-oauth2": "^0.1.0",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-one-time-password-field": "^0.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@radix-ui/react-collapsible": "^1.1.11",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-one-time-password-field": "^0.1.7",
|
||||
"@radix-ui/react-popover": "^1.1.14",
|
||||
"@radix-ui/react-select": "^2.1.4",
|
||||
"@radix-ui/react-switch": "^1.2.5",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@radix-ui/react-visually-hidden": "^1.2.2",
|
||||
"@sentry/node": "^7.120.4",
|
||||
"@sentry/react": "^7.120.4",
|
||||
@@ -123,11 +123,11 @@
|
||||
"content-disposition": "^0.5.4",
|
||||
"cookie": "^0.7.0",
|
||||
"copy-to-clipboard": "^3.3.3",
|
||||
"core-js": "^3.45.1",
|
||||
"core-js": "^3.37.0",
|
||||
"crypto-js": "^4.2.0",
|
||||
"datadog-metrics": "^0.12.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"dd-trace": "^5.63.0",
|
||||
"dd-trace": "^5.62.0",
|
||||
"diff": "^5.2.0",
|
||||
"email-providers": "^1.14.0",
|
||||
"emoji-mart": "^5.6.0",
|
||||
@@ -170,7 +170,7 @@
|
||||
"markdown-it": "^14.1.0",
|
||||
"markdown-it-container": "^3.0.0",
|
||||
"markdown-it-emoji": "^3.0.0",
|
||||
"mermaid": "11.10.1",
|
||||
"mermaid": "11.9.0",
|
||||
"mime-types": "^3.0.1",
|
||||
"mobx": "^4.15.4",
|
||||
"mobx-react": "^6.3.1",
|
||||
@@ -255,17 +255,17 @@
|
||||
"styled-normalize": "^8.1.1",
|
||||
"throng": "^5.0.0",
|
||||
"tiny-cookie": "^2.5.1",
|
||||
"tmp": "^0.2.5",
|
||||
"tmp": "^0.2.4",
|
||||
"tunnel-agent": "^0.6.0",
|
||||
"turndown": "^7.2.0",
|
||||
"ukkonen": "^2.2.0",
|
||||
"ukkonen": "^2.1.0",
|
||||
"umzug": "^3.8.2",
|
||||
"utility-types": "^3.11.0",
|
||||
"uuid": "^8.3.2",
|
||||
"validator": "13.15.15",
|
||||
"vaul": "^1.1.2",
|
||||
"vite": "npm:rolldown-vite@latest",
|
||||
"vite-plugin-pwa": "1.0.3",
|
||||
"vite-plugin-pwa": "^1.0.2",
|
||||
"winston": "^3.17.0",
|
||||
"ws": "^7.5.10",
|
||||
"y-indexeddb": "^9.0.11",
|
||||
@@ -276,7 +276,7 @@
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.28.3",
|
||||
"@babel/cli": "^7.28.0",
|
||||
"@babel/preset-typescript": "^7.27.1",
|
||||
"@faker-js/faker": "^8.4.1",
|
||||
"@relative-ci/agent": "^4.3.1",
|
||||
@@ -351,7 +351,7 @@
|
||||
"babel-plugin-tsconfig-paths-module-resolver": "^1.0.4",
|
||||
"browserslist-to-esbuild": "^1.2.0",
|
||||
"concurrently": "^8.2.2",
|
||||
"discord-api-types": "^0.38.20",
|
||||
"discord-api-types": "^0.37.119",
|
||||
"husky": "^8.0.3",
|
||||
"i18next-parser": "^8.13.0",
|
||||
"ioredis-mock": "^8.9.0",
|
||||
@@ -365,7 +365,7 @@
|
||||
"prettier": "^3.6.2",
|
||||
"react-refresh": "^0.17.0",
|
||||
"rimraf": "^2.5.4",
|
||||
"rollup-plugin-webpack-stats": "2.1.3",
|
||||
"rollup-plugin-webpack-stats": "^2.1.3",
|
||||
"terser": "^5.43.1",
|
||||
"typescript": "^5.9.2",
|
||||
"yarn-deduplicate": "^6.0.2"
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
/** The size of the icon, 24px is default to match standard icons */
|
||||
size?: number;
|
||||
/** The color of the icon, defaults to the current text color */
|
||||
fill?: string;
|
||||
};
|
||||
|
||||
export default function Icon({ size = 24, fill = "currentColor" }: Props) {
|
||||
return (
|
||||
<svg
|
||||
fill={fill}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
version="1.1"
|
||||
>
|
||||
<path
|
||||
d="M12 20.8L4.6 13.4L6.3 7.8L12 13.4L17.7 7.8L19.4 13.4L12 20.8Z"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
<path
|
||||
d="M12 20.8L4.6 13.4L6.3 7.8L12 13.4L12 20.8Z"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
fillOpacity="0.3"
|
||||
/>
|
||||
<path
|
||||
d="M4.6 13.4L2.5 7.8L6.3 7.8L4.6 13.4Z"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
fillOpacity="0.5"
|
||||
/>
|
||||
<path
|
||||
d="M19.4 13.4L21.5 7.8L17.7 7.8L19.4 13.4Z"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
fillOpacity="0.5"
|
||||
/>
|
||||
<path
|
||||
d="M6.3 7.8L8.7 2.2L15.3 2.2L17.7 7.8L6.3 7.8Z"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
fillOpacity="0.7"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { IntegrationService } from "@shared/types";
|
||||
import { ConnectedButton } from "~/scenes/Settings/components/ConnectedButton";
|
||||
import { AvatarSize } from "~/components/Avatar";
|
||||
import Flex from "~/components/Flex";
|
||||
import Heading from "~/components/Heading";
|
||||
import List from "~/components/List";
|
||||
import ListItem from "~/components/List/Item";
|
||||
import Notice from "~/components/Notice";
|
||||
import PlaceholderText from "~/components/PlaceholderText";
|
||||
import Scene from "~/components/Scene";
|
||||
import TeamLogo from "~/components/TeamLogo";
|
||||
import Text from "~/components/Text";
|
||||
import Time from "~/components/Time";
|
||||
import env from "~/env";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import GitLabIcon from "./Icon";
|
||||
import { GitLabConnectButton } from "./components/GitLabButton";
|
||||
|
||||
function GitLab() {
|
||||
const { integrations } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const query = useQuery();
|
||||
const error = query.get("error");
|
||||
const appName = env.APP_NAME;
|
||||
|
||||
React.useEffect(() => {
|
||||
void integrations.fetchAll({
|
||||
service: IntegrationService.GitLab,
|
||||
withRelations: true,
|
||||
});
|
||||
}, [integrations]);
|
||||
|
||||
return (
|
||||
<Scene title="GitLab" icon={<GitLabIcon />}>
|
||||
<Heading>GitLab</Heading>
|
||||
|
||||
{error === "access_denied" && (
|
||||
<Notice>
|
||||
<Trans>
|
||||
Whoops, you need to accept the permissions in GitLab to connect{" "}
|
||||
{{ appName }} to your project. Try again?
|
||||
</Trans>
|
||||
</Notice>
|
||||
)}
|
||||
{error === "unauthenticated" && (
|
||||
<Notice>
|
||||
<Trans>
|
||||
Something went wrong while authenticating your request. Please try
|
||||
logging in again.
|
||||
</Trans>
|
||||
</Notice>
|
||||
)}
|
||||
{env.GITLAB_CLIENT_ID ? (
|
||||
<>
|
||||
<Text as="p">
|
||||
<Trans>
|
||||
Enable previews of GitLab issues and merge requests in documents by connecting a
|
||||
GitLab project to {appName}.
|
||||
</Trans>
|
||||
</Text>
|
||||
{integrations.gitlab.length ? (
|
||||
<>
|
||||
<Heading as="h2">
|
||||
<Flex justify="space-between" auto>
|
||||
{t("Connected")}
|
||||
<GitLabConnectButton icon={<PlusIcon />} />
|
||||
</Flex>
|
||||
</Heading>
|
||||
<List>
|
||||
{integrations.gitlab.map((integration) => {
|
||||
const gitlabProject =
|
||||
integration.settings?.gitlab?.project;
|
||||
const integrationCreatedBy = integration.user
|
||||
? integration.user.name
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={gitlabProject?.id}
|
||||
small
|
||||
title={gitlabProject?.name}
|
||||
subtitle={
|
||||
integrationCreatedBy ? (
|
||||
<>
|
||||
<Trans>Enabled by {{ integrationCreatedBy }}</Trans>{" "}
|
||||
·{" "}
|
||||
<Time
|
||||
dateTime={integration.createdAt}
|
||||
relative={false}
|
||||
format={{ en_US: "MMMM d, y" }}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<PlaceholderText />
|
||||
)
|
||||
}
|
||||
image={
|
||||
<TeamLogo
|
||||
src={gitlabProject?.avatar_url}
|
||||
size={AvatarSize.Large}
|
||||
/>
|
||||
}
|
||||
actions={
|
||||
<ConnectedButton
|
||||
onClick={integration.delete}
|
||||
confirmationMessage={t(
|
||||
"Disconnecting will prevent previewing GitLab links from this project in documents. Are you sure?"
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
</>
|
||||
) : (
|
||||
<p>
|
||||
<GitLabConnectButton icon={<GitLabIcon />} />
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Notice>
|
||||
<Trans>
|
||||
The GitLab integration is currently disabled. Please set the
|
||||
associated environment variables and restart the server to enable
|
||||
the integration.
|
||||
</Trans>
|
||||
</Notice>
|
||||
)}
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(GitLab);
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Button, { type Props } from "~/components/Button";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import { redirectTo } from "~/utils/urls";
|
||||
import { GitLabUtils } from "../../shared/GitLabUtils";
|
||||
|
||||
export function GitLabConnectButton(props: Props<HTMLButtonElement>) {
|
||||
const { t } = useTranslation();
|
||||
const team = useCurrentTeam();
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={() =>
|
||||
redirectTo(GitLabUtils.authUrl({ state: { teamId: team.id } }))
|
||||
}
|
||||
neutral
|
||||
{...props}
|
||||
>
|
||||
{t("Connect")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import * as React from "react";
|
||||
import { Hook, PluginManager } from "~/utils/PluginManager";
|
||||
import config from "../plugin.json";
|
||||
import Icon from "./Icon";
|
||||
|
||||
PluginManager.add([
|
||||
{
|
||||
...config,
|
||||
type: Hook.Settings,
|
||||
value: {
|
||||
group: "Integrations",
|
||||
icon: Icon,
|
||||
component: React.lazy(() => import("./Settings")),
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"id": "gitlab",
|
||||
"name": "GitLab",
|
||||
"priority": 16,
|
||||
"description": "Adds a GitLab integration for link unfurling and converting links to mentions.",
|
||||
"after": "linear"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import Router from "koa-router";
|
||||
import { IntegrationService, IntegrationType } from "@shared/types";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import apexAuthRedirect from "@server/middlewares/apexAuthRedirect";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import { transaction } from "@server/middlewares/transaction";
|
||||
import validate from "@server/middlewares/validate";
|
||||
import { IntegrationAuthentication, Integration } from "@server/models";
|
||||
import { APIContext } from "@server/types";
|
||||
import { GitLab } from "../gitlab";
|
||||
import UploadGitLabProjectAvatarTask from "../tasks/UploadGitLabProjectAvatarTask";
|
||||
import * as T from "./schema";
|
||||
import { GitLabUtils } from "plugins/gitlab/shared/GitLabUtils";
|
||||
|
||||
const router = new Router();
|
||||
|
||||
router.get(
|
||||
"gitlab.callback",
|
||||
auth({
|
||||
optional: true,
|
||||
}),
|
||||
validate(T.GitLabCallbackSchema),
|
||||
apexAuthRedirect<T.GitLabCallbackReq>({
|
||||
getTeamId: (ctx) => GitLabUtils.parseState(ctx.input.query.state)?.teamId,
|
||||
getRedirectPath: (ctx, team) =>
|
||||
GitLabUtils.callbackUrl({
|
||||
baseUrl: team.url,
|
||||
params: ctx.request.querystring,
|
||||
}),
|
||||
getErrorPath: () => GitLabUtils.errorUrl("unauthenticated"),
|
||||
}),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.GitLabCallbackReq>) => {
|
||||
const { code, error } = ctx.input.query;
|
||||
const { user } = ctx.state.auth;
|
||||
const { transaction } = ctx.state;
|
||||
|
||||
// Check error after any sub-domain redirection. Otherwise, the user will be redirected to the root domain.
|
||||
if (error) {
|
||||
ctx.redirect(GitLabUtils.errorUrl(error));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// validation middleware ensures that code is non-null at this point.
|
||||
const oauth = await GitLab.oauthAccess(code!);
|
||||
const project = await GitLab.getInstalledProject(oauth.access_token);
|
||||
|
||||
const authentication = await IntegrationAuthentication.create(
|
||||
{
|
||||
service: IntegrationService.GitLab,
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
token: oauth.access_token,
|
||||
scopes: oauth.scope.split(" "),
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
const integration = await Integration.create<
|
||||
Integration<IntegrationType.Embed>
|
||||
>(
|
||||
{
|
||||
service: IntegrationService.GitLab,
|
||||
type: IntegrationType.Embed,
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
authenticationId: authentication.id,
|
||||
settings: {
|
||||
gitlab: {
|
||||
project: {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
path_with_namespace: project.path_with_namespace,
|
||||
avatar_url: project.avatar_url,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
|
||||
transaction.afterCommit(async () => {
|
||||
if (project.avatar_url) {
|
||||
await new UploadGitLabProjectAvatarTask().schedule({
|
||||
integrationId: integration.id,
|
||||
avatarUrl: project.avatar_url,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
ctx.redirect(GitLabUtils.successUrl());
|
||||
} catch (err) {
|
||||
Logger.error("Encountered error during GitLab OAuth callback", err);
|
||||
ctx.redirect(GitLabUtils.errorUrl("unknown"));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import { z } from "zod";
|
||||
import { BaseSchema } from "@server/routes/api/schema";
|
||||
|
||||
export const GitLabCallbackSchema = BaseSchema.extend({
|
||||
query: z
|
||||
.object({
|
||||
code: z.string().nullish(),
|
||||
state: z.string(),
|
||||
error: z.string().nullish(),
|
||||
})
|
||||
.refine((req) => !(isEmpty(req.code) && isEmpty(req.error)), {
|
||||
message: "one of code or error is required",
|
||||
}),
|
||||
});
|
||||
|
||||
export type GitLabCallbackReq = z.infer<typeof GitLabCallbackSchema>;
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import env from "@server/env";
|
||||
|
||||
export default {
|
||||
GITLAB_CLIENT_ID: env.GITLAB_CLIENT_ID,
|
||||
GITLAB_CLIENT_SECRET: env.GITLAB_CLIENT_SECRET,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
IntegrationService,
|
||||
IntegrationType,
|
||||
UnfurlResourceType,
|
||||
} from "@shared/types";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { Integration } from "@server/models";
|
||||
import User from "@server/models/User";
|
||||
import { UnfurlIssueOrPR, UnfurlSignature } from "@server/types";
|
||||
import { GitLabUtils } from "../shared/GitLabUtils";
|
||||
import env from "./env";
|
||||
|
||||
const AccessTokenResponseSchema = z.object({
|
||||
access_token: z.string(),
|
||||
token_type: z.string(),
|
||||
expires_in: z.number(),
|
||||
refresh_token: z.string(),
|
||||
scope: z.string(),
|
||||
created_at: z.number(),
|
||||
});
|
||||
|
||||
const GitLabProjectSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
path_with_namespace: z.string(),
|
||||
avatar_url: z.string().optional(),
|
||||
});
|
||||
|
||||
const GitLabIssueSchema = z.object({
|
||||
id: z.number(),
|
||||
iid: z.number(),
|
||||
title: z.string(),
|
||||
description: z.string().nullable(),
|
||||
state: z.string(),
|
||||
created_at: z.string(),
|
||||
author: z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
avatar_url: z.string().nullable(),
|
||||
}),
|
||||
labels: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
const GitLabMergeRequestSchema = z.object({
|
||||
id: z.number(),
|
||||
iid: z.number(),
|
||||
title: z.string(),
|
||||
description: z.string().nullable(),
|
||||
state: z.string(),
|
||||
created_at: z.string(),
|
||||
author: z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
avatar_url: z.string().nullable(),
|
||||
}),
|
||||
labels: z.array(z.string()).optional(),
|
||||
draft: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export class GitLab {
|
||||
private static supportedUnfurls = [
|
||||
UnfurlResourceType.Issue,
|
||||
UnfurlResourceType.PR,
|
||||
];
|
||||
|
||||
static async oauthAccess(code: string) {
|
||||
const headers = {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
};
|
||||
|
||||
const body = new URLSearchParams();
|
||||
body.set("code", code);
|
||||
body.set("client_id", env.GITLAB_CLIENT_ID!);
|
||||
body.set("client_secret", env.GITLAB_CLIENT_SECRET!);
|
||||
body.set("redirect_uri", GitLabUtils.callbackUrl());
|
||||
body.set("grant_type", "authorization_code");
|
||||
|
||||
const res = await fetch(GitLabUtils.tokenUrl, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error(
|
||||
`Error while exchanging oauth code from GitLab; status: ${res.status}`
|
||||
);
|
||||
}
|
||||
|
||||
return AccessTokenResponseSchema.parse(await res.json());
|
||||
}
|
||||
|
||||
static async revokeAccess(accessToken: string) {
|
||||
const headers = {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
};
|
||||
|
||||
const body = new URLSearchParams();
|
||||
body.set("client_id", env.GITLAB_CLIENT_ID!);
|
||||
body.set("client_secret", env.GITLAB_CLIENT_SECRET!);
|
||||
body.set("token", accessToken);
|
||||
|
||||
await fetch(GitLabUtils.revokeUrl, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
static async getInstalledProject(accessToken: string) {
|
||||
const headers = {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: "application/json",
|
||||
};
|
||||
|
||||
// Get the first project the user has access to
|
||||
// In a real implementation, we would want to let the user select which project to connect
|
||||
const res = await fetch(
|
||||
"https://gitlab.com/api/v4/projects?membership=true&per_page=1",
|
||||
{
|
||||
headers,
|
||||
}
|
||||
);
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error(
|
||||
`Error while fetching GitLab projects; status: ${res.status}`
|
||||
);
|
||||
}
|
||||
|
||||
const projects = await res.json();
|
||||
if (!projects.length) {
|
||||
throw new Error("No GitLab projects found");
|
||||
}
|
||||
|
||||
return GitLabProjectSchema.parse(projects[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param url GitLab resource url
|
||||
* @param actor User attempting to unfurl resource url
|
||||
* @returns An object containing resource details e.g, a GitLab issue or merge request details
|
||||
*/
|
||||
static unfurl: UnfurlSignature = async (url: string, actor: User) => {
|
||||
const resource = GitLab.parseUrl(url);
|
||||
|
||||
if (!resource) {
|
||||
return;
|
||||
}
|
||||
|
||||
const integration = (await Integration.scope("withAuthentication").findOne({
|
||||
where: {
|
||||
service: IntegrationService.GitLab,
|
||||
teamId: actor.teamId,
|
||||
"settings.gitlab.project.path_with_namespace": resource.projectPath,
|
||||
},
|
||||
})) as Integration<IntegrationType.Embed>;
|
||||
|
||||
if (!integration) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const headers = {
|
||||
Authorization: `Bearer ${integration.authentication.token}`,
|
||||
Accept: "application/json",
|
||||
};
|
||||
|
||||
let apiUrl: string;
|
||||
let resourceSchema: z.ZodObject<z.ZodRawShape>;
|
||||
let resourceType: UnfurlResourceType;
|
||||
|
||||
if (resource.type === "issues") {
|
||||
apiUrl = `https://gitlab.com/api/v4/projects/${encodeURIComponent(resource.projectPath)}/issues/${resource.id}`;
|
||||
resourceSchema = GitLabIssueSchema;
|
||||
resourceType = UnfurlResourceType.Issue;
|
||||
} else if (resource.type === "merge_requests") {
|
||||
apiUrl = `https://gitlab.com/api/v4/projects/${encodeURIComponent(resource.projectPath)}/merge_requests/${resource.id}`;
|
||||
resourceSchema = GitLabMergeRequestSchema;
|
||||
resourceType = UnfurlResourceType.PR;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch(apiUrl, { headers });
|
||||
|
||||
if (res.status !== 200) {
|
||||
return { error: `Resource not found (${res.status})` };
|
||||
}
|
||||
|
||||
const data = resourceSchema.parse(await res.json());
|
||||
|
||||
// Fetch labels if they exist
|
||||
let labels = [];
|
||||
if (data.labels && data.labels.length > 0) {
|
||||
labels = data.labels.map((label) => ({
|
||||
name: label,
|
||||
color: "#428BCA", // Default GitLab blue
|
||||
}));
|
||||
}
|
||||
|
||||
return {
|
||||
type: resourceType,
|
||||
url,
|
||||
id: `#${data.iid}`,
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
author: {
|
||||
name: data.author.name,
|
||||
avatarUrl: data.author.avatar_url || "",
|
||||
},
|
||||
labels,
|
||||
state: {
|
||||
name: data.state,
|
||||
color: data.state === "opened" ? "#1aaa55" : "#db3b21", // Green for open, red for closed
|
||||
draft:
|
||||
resourceType === UnfurlResourceType.PR ? data.draft : undefined,
|
||||
},
|
||||
createdAt: data.created_at,
|
||||
} satisfies UnfurlIssueOrPR;
|
||||
} catch (err) {
|
||||
Logger.warn("Failed to fetch resource from GitLab", err);
|
||||
return { error: err.message || "Unknown error" };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses a given URL and returns resource identifiers for GitLab specific URLs
|
||||
*
|
||||
* @param url URL to parse
|
||||
* @returns {object} Containing resource identifiers - `projectPath`, `type`, and `id`.
|
||||
*/
|
||||
private static parseUrl(url: string) {
|
||||
const { hostname, pathname } = new URL(url);
|
||||
if (hostname !== "gitlab.com") {
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = pathname.split("/");
|
||||
// Remove empty first element
|
||||
parts.shift();
|
||||
|
||||
// GitLab URLs are in the format: /namespace/project/-/issues/1 or /namespace/project/-/merge_requests/1
|
||||
// The namespace can have multiple levels (e.g., /group/subgroup/project/-/issues/1)
|
||||
if (parts.length < 4) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the index of "-" which separates project path from resource type
|
||||
const separatorIndex = parts.indexOf("-");
|
||||
if (separatorIndex === -1 || separatorIndex === parts.length - 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const projectPath = parts.slice(0, separatorIndex).join("/");
|
||||
const type = parts[separatorIndex + 1];
|
||||
const id = parts[separatorIndex + 2];
|
||||
|
||||
if (
|
||||
!type ||
|
||||
!id ||
|
||||
!GitLab.supportedUnfurls.includes(type as UnfurlResourceType)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
return { projectPath, type, id };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { IntegrationType } from "@shared/types";
|
||||
import BaseTask from "@server/queues/tasks/BaseTask";
|
||||
import { Integration } from "@server/models";
|
||||
import { FileOperation } from "@server/models";
|
||||
import fetch from "node-fetch";
|
||||
|
||||
type Props = {
|
||||
integrationId: string;
|
||||
avatarUrl: string;
|
||||
};
|
||||
|
||||
export default class UploadGitLabProjectAvatarTask extends BaseTask<Props> {
|
||||
public async perform({ integrationId, avatarUrl }: Props) {
|
||||
const integration = await Integration.findByPk(integrationId, {
|
||||
rejectOnEmpty: true,
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await fetch(avatarUrl);
|
||||
const buffer = await res.buffer();
|
||||
const name = avatarUrl.split("/").pop() || "avatar";
|
||||
const contentType = res.headers.get("content-type") || "image/png";
|
||||
|
||||
const operation = await FileOperation.createFromBuffer({
|
||||
buffer,
|
||||
contentType,
|
||||
name,
|
||||
userId: integration.userId,
|
||||
teamId: integration.teamId,
|
||||
source: "gitlab",
|
||||
});
|
||||
|
||||
await integration.update({
|
||||
settings: {
|
||||
...integration.settings,
|
||||
gitlab: {
|
||||
...(integration.settings as Integration<IntegrationType.Embed>)
|
||||
.gitlab,
|
||||
project: {
|
||||
...(integration.settings as Integration<IntegrationType.Embed>)
|
||||
.gitlab?.project,
|
||||
avatar_url: operation.url,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
// If the avatar upload fails, we don't need to fail the entire task
|
||||
// as it's not critical to the integration's functionality.
|
||||
// Just log the error and continue.
|
||||
this.logger.error(
|
||||
`Failed to upload GitLab project avatar: ${err.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import queryString from "query-string";
|
||||
import env from "@shared/env";
|
||||
import { integrationSettingsPath } from "@shared/utils/routeHelpers";
|
||||
|
||||
export type OAuthState = {
|
||||
teamId: string;
|
||||
};
|
||||
|
||||
export class GitLabUtils {
|
||||
private static oauthScopes = "api read_api read_user read_repository";
|
||||
|
||||
public static tokenUrl = "https://gitlab.com/oauth/token";
|
||||
public static revokeUrl = "https://gitlab.com/oauth/revoke";
|
||||
private static authBaseUrl = "https://gitlab.com/oauth/authorize";
|
||||
|
||||
private static settingsUrl = integrationSettingsPath("gitlab");
|
||||
|
||||
static parseState(state: string): OAuthState {
|
||||
return JSON.parse(state);
|
||||
}
|
||||
|
||||
static successUrl() {
|
||||
return this.settingsUrl;
|
||||
}
|
||||
|
||||
static errorUrl(error: string) {
|
||||
return `${this.settingsUrl}?error=${error}`;
|
||||
}
|
||||
|
||||
static callbackUrl(
|
||||
{ baseUrl, params }: { baseUrl: string; params?: string } = {
|
||||
baseUrl: env.URL,
|
||||
params: undefined,
|
||||
}
|
||||
) {
|
||||
return params
|
||||
? `${baseUrl}/api/gitlab.callback?${params}`
|
||||
: `${baseUrl}/api/gitlab.callback`;
|
||||
}
|
||||
|
||||
static authUrl({ state }: { state: OAuthState }) {
|
||||
const params = {
|
||||
client_id: env.GITLAB_CLIENT_ID,
|
||||
redirect_uri: this.callbackUrl(),
|
||||
state: JSON.stringify(state),
|
||||
scope: this.oauthScopes,
|
||||
response_type: "code",
|
||||
};
|
||||
return `${this.authBaseUrl}?${queryString.stringify(params)}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,11 +21,6 @@ type ParsePageOutput = ImportTaskOutput[number] & {
|
||||
};
|
||||
|
||||
export default class NotionAPIImportTask extends APIImportTask<IntegrationService.Notion> {
|
||||
private skippableErrorMessages = [
|
||||
"Database retrievals do not support linked databases",
|
||||
"does not contain any data sources accessible by this API bot", // error msg for linked database views
|
||||
];
|
||||
|
||||
/**
|
||||
* Process the Notion import task.
|
||||
* This fetches data from Notion and converts it to task output.
|
||||
@@ -143,8 +138,8 @@ export default class NotionAPIImportTask extends APIImportTask<IntegrationServic
|
||||
if (
|
||||
error.code === APIErrorCode.ObjectNotFound ||
|
||||
error.code === APIErrorCode.Unauthorized ||
|
||||
this.skippableErrorMessages.some((errorMsg) =>
|
||||
error.message.includes(errorMsg)
|
||||
error.message.includes(
|
||||
"Database retrievals do not support linked databases"
|
||||
)
|
||||
) {
|
||||
Logger.warn(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { HttpsProxyAgent } from "https-proxy-agent";
|
||||
import OAuth2Strategy, { Strategy } from "passport-oauth2";
|
||||
import { Request } from "express";
|
||||
|
||||
export class OIDCStrategy extends Strategy {
|
||||
constructor(
|
||||
@@ -14,12 +15,12 @@ export class OIDCStrategy extends Strategy {
|
||||
}
|
||||
}
|
||||
|
||||
authenticate(req: any, options: any) {
|
||||
authenticate(req: Request, options: Record<string, unknown>) {
|
||||
options.originalQuery = req.query;
|
||||
super.authenticate(req, options);
|
||||
}
|
||||
|
||||
authorizationParams(options: any) {
|
||||
authorizationParams(options: Record<string, unknown>) {
|
||||
return {
|
||||
...options.originalQuery,
|
||||
...super.authorizationParams?.(options),
|
||||
|
||||
@@ -6,7 +6,7 @@ import env from "./env";
|
||||
|
||||
const SLACK_API_URL = "https://slack.com/api";
|
||||
|
||||
export async function post(endpoint: string, body: Record<string, any>) {
|
||||
export async function post(endpoint: string, body: Record<string, unknown>) {
|
||||
let data;
|
||||
const token = body.token;
|
||||
|
||||
@@ -30,7 +30,7 @@ export async function post(endpoint: string, body: Record<string, any>) {
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function request(endpoint: string, body: Record<string, any>) {
|
||||
export async function request(endpoint: string, body: Record<string, unknown>) {
|
||||
let data;
|
||||
|
||||
try {
|
||||
|
||||
@@ -16,7 +16,7 @@ export class SlackUtils {
|
||||
static createState(
|
||||
teamId: string,
|
||||
type: IntegrationType,
|
||||
data?: Record<string, any>
|
||||
data?: Record<string, unknown>
|
||||
) {
|
||||
return JSON.stringify({ type, teamId, ...data });
|
||||
}
|
||||
|
||||
+2
-2
@@ -78,7 +78,7 @@ export class Environment {
|
||||
/**
|
||||
* The url of the database.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsNotEmpty()
|
||||
@IsUrl({
|
||||
require_tld: false,
|
||||
allow_underscores: true,
|
||||
@@ -91,7 +91,7 @@ export class Environment {
|
||||
"DATABASE_USER",
|
||||
"DATABASE_PASSWORD",
|
||||
])
|
||||
public DATABASE_URL = this.toOptionalString(environment.DATABASE_URL);
|
||||
public DATABASE_URL = environment.DATABASE_URL ?? "";
|
||||
|
||||
/**
|
||||
* Database host for individual component configuration.
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
await queryInterface.addColumn("group_users", "isAdmin", {
|
||||
type: Sequelize.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
});
|
||||
},
|
||||
|
||||
async down(queryInterface) {
|
||||
await queryInterface.removeColumn("group_users", "isAdmin");
|
||||
},
|
||||
};
|
||||
@@ -65,9 +65,6 @@ class GroupUser extends Model<
|
||||
@Column(DataType.UUID)
|
||||
createdById: string;
|
||||
|
||||
@Column(DataType.BOOLEAN)
|
||||
isAdmin: boolean;
|
||||
|
||||
get modelId() {
|
||||
return this.groupId;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ export default class AuthenticationHelper {
|
||||
info: Scope.Read,
|
||||
search: Scope.Read,
|
||||
documents: Scope.Read,
|
||||
export: Scope.Read,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Group, User, Team } from "@server/models";
|
||||
import { allow } from "./cancan";
|
||||
import { and, isTeamAdmin, isTeamModel, isTeamMutable, isGroupAdmin } from "./utils";
|
||||
import { and, isTeamAdmin, isTeamModel, isTeamMutable } from "./utils";
|
||||
|
||||
allow(User, "createGroup", Team, (actor, team) =>
|
||||
and(
|
||||
@@ -26,18 +26,10 @@ allow(User, "read", Group, (actor, team) =>
|
||||
)
|
||||
);
|
||||
|
||||
allow(User, "update", Group, async (actor, group) => {
|
||||
return and(
|
||||
//
|
||||
await isGroupAdmin(actor, group),
|
||||
isTeamMutable(actor)
|
||||
);
|
||||
});
|
||||
|
||||
allow(User, "delete", Group, (actor, group) =>
|
||||
allow(User, ["update", "delete"], Group, (actor, team) =>
|
||||
and(
|
||||
//
|
||||
isTeamAdmin(actor, group),
|
||||
isTeamAdmin(actor, team),
|
||||
isTeamMutable(actor)
|
||||
)
|
||||
);
|
||||
|
||||
@@ -100,36 +100,3 @@ export function isCloudHosted() {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the actor is an admin of the group.
|
||||
*
|
||||
* @param actor The actor to check
|
||||
* @param model The group model to check
|
||||
* @returns True if the actor is an admin of the group
|
||||
*/
|
||||
export async function isGroupAdmin(
|
||||
actor: User,
|
||||
model: Model | null | undefined
|
||||
): Promise<boolean> {
|
||||
if (!model || !("id" in model)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Team admins are always group admins
|
||||
if (isTeamAdmin(actor, model)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if the user is a group admin
|
||||
const { GroupUser } = await import("@server/models");
|
||||
const membership = await GroupUser.findOne({
|
||||
where: {
|
||||
userId: actor.id,
|
||||
groupId: model.id,
|
||||
isAdmin: true,
|
||||
},
|
||||
});
|
||||
|
||||
return !!membership;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ export default function presentGroupUser(
|
||||
id: `${membership.userId}-${membership.groupId}`,
|
||||
userId: membership.userId,
|
||||
groupId: membership.groupId,
|
||||
isAdmin: membership.isAdmin,
|
||||
user: options?.includeUser ? presentUser(membership.user) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ export enum TaskSchedule {
|
||||
Minute = "minute",
|
||||
}
|
||||
|
||||
export default abstract class BaseTask<T extends Record<string, any>> {
|
||||
export default abstract class BaseTask<T extends Record<string, unknown>> {
|
||||
/**
|
||||
* An optional schedule for this task to be run automatically.
|
||||
*/
|
||||
@@ -43,7 +43,7 @@ export default abstract class BaseTask<T extends Record<string, any>> {
|
||||
* @param props Properties to be used by the task
|
||||
* @returns A promise that resolves once the task has completed.
|
||||
*/
|
||||
public abstract perform(props: T): Promise<any>;
|
||||
public abstract perform(props: T): Promise<unknown>;
|
||||
|
||||
/**
|
||||
* Handle failure when all attempts are exhausted for the task.
|
||||
|
||||
@@ -3,7 +3,7 @@ import BaseTask from "./BaseTask";
|
||||
|
||||
type Props = {
|
||||
templateName: string;
|
||||
props: Record<string, any>;
|
||||
props: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export default class EmailTask extends BaseTask<Props> {
|
||||
|
||||
@@ -72,35 +72,6 @@ export default class ExportJSONTask extends ExportTask {
|
||||
attachments: {},
|
||||
};
|
||||
|
||||
async function addAttachments(attachments: Attachment[]) {
|
||||
await Promise.all(
|
||||
attachments.map(async (attachment) => {
|
||||
zip.file(
|
||||
attachment.key,
|
||||
new Promise<Buffer>((resolve) => {
|
||||
attachment.buffer.then(resolve).catch((err) => {
|
||||
Logger.warn(`Failed to read attachment from storage`, {
|
||||
attachmentId: attachment.id,
|
||||
teamId: attachment.teamId,
|
||||
error: err.message,
|
||||
});
|
||||
resolve(Buffer.from(""));
|
||||
});
|
||||
}),
|
||||
{
|
||||
date: attachment.updatedAt,
|
||||
createFolders: true,
|
||||
}
|
||||
);
|
||||
|
||||
output.attachments[attachment.id] = {
|
||||
...omit(presentAttachment(attachment), "url"),
|
||||
key: attachment.key,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function addDocumentTree(nodes: NavigationNode[]) {
|
||||
for (const node of nodes) {
|
||||
const document = await Document.findByPk(node.id, {
|
||||
@@ -111,7 +82,7 @@ export default class ExportJSONTask extends ExportTask {
|
||||
continue;
|
||||
}
|
||||
|
||||
const documentAttachments = includeAttachments
|
||||
const attachments = includeAttachments
|
||||
? await Attachment.findAll({
|
||||
where: {
|
||||
teamId: document.teamId,
|
||||
@@ -122,7 +93,32 @@ export default class ExportJSONTask extends ExportTask {
|
||||
})
|
||||
: [];
|
||||
|
||||
await addAttachments(documentAttachments);
|
||||
await Promise.all(
|
||||
attachments.map(async (attachment) => {
|
||||
zip.file(
|
||||
attachment.key,
|
||||
new Promise<Buffer>((resolve) => {
|
||||
attachment.buffer.then(resolve).catch((err) => {
|
||||
Logger.warn(`Failed to read attachment from storage`, {
|
||||
attachmentId: attachment.id,
|
||||
teamId: attachment.teamId,
|
||||
error: err.message,
|
||||
});
|
||||
resolve(Buffer.from(""));
|
||||
});
|
||||
}),
|
||||
{
|
||||
date: attachment.updatedAt,
|
||||
createFolders: true,
|
||||
}
|
||||
);
|
||||
|
||||
output.attachments[attachment.id] = {
|
||||
...omit(presentAttachment(attachment), "url"),
|
||||
key: attachment.key,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
output.documents[document.id] = {
|
||||
id: document.id,
|
||||
@@ -150,19 +146,6 @@ export default class ExportJSONTask extends ExportTask {
|
||||
}
|
||||
}
|
||||
|
||||
const collectionAttachments = includeAttachments
|
||||
? await Attachment.findAll({
|
||||
where: {
|
||||
teamId: collection.teamId,
|
||||
id: ProsemirrorHelper.parseAttachmentIds(
|
||||
DocumentHelper.toProsemirror(collection)
|
||||
),
|
||||
},
|
||||
})
|
||||
: [];
|
||||
|
||||
await addAttachments(collectionAttachments);
|
||||
|
||||
if (collection.documentStructure) {
|
||||
await addDocumentTree(collection.documentStructure);
|
||||
}
|
||||
|
||||
@@ -825,7 +825,7 @@ describe("#documents.list", () => {
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data).toHaveLength(2);
|
||||
const docIds = body.data.map((doc: any) => doc.id);
|
||||
const docIds = body.data.map((doc: { id: string }) => doc.id);
|
||||
expect(docIds).toContain(docs[0].id);
|
||||
expect(docIds).toContain(docs[1].id);
|
||||
expect(docIds).not.toContain(docs[2].id);
|
||||
@@ -5361,7 +5361,7 @@ describe("#documents.documents", () => {
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.data.id).toBe(parent.id);
|
||||
const childIds = body.data.children.map((node: any) => node.id);
|
||||
const childIds = body.data.children.map((node: { id: string }) => node.id);
|
||||
expect(childIds).toContain(child1.id);
|
||||
expect(childIds).toContain(child2.id);
|
||||
});
|
||||
|
||||
@@ -61,7 +61,6 @@ describe("#groups.update", () => {
|
||||
});
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
|
||||
describe("when user is admin", () => {
|
||||
let user: User, group: Group;
|
||||
beforeEach(async () => {
|
||||
@@ -92,53 +91,7 @@ describe("#groups.update", () => {
|
||||
expect(body.data.name).toBe("Test");
|
||||
expect(body.data.externalId).toBe("123");
|
||||
});
|
||||
});
|
||||
|
||||
describe("when user is group admin", () => {
|
||||
let user: User, group: Group;
|
||||
beforeEach(async () => {
|
||||
user = await buildUser();
|
||||
group = await buildGroup({
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
// Make the user a group admin
|
||||
const admin = await buildAdmin({
|
||||
teamId: user.teamId,
|
||||
});
|
||||
await server.post("/api/groups.add_user", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
id: group.id,
|
||||
userId: user.id,
|
||||
isAdmin: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("allows group admin to edit a group", async () => {
|
||||
const res = await server.post("/api/groups.update", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: group.id,
|
||||
name: "Test by Group Admin",
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.name).toBe("Test by Group Admin");
|
||||
});
|
||||
});
|
||||
|
||||
describe("when checking for noop updates", () => {
|
||||
let user: User, group: Group;
|
||||
beforeEach(async () => {
|
||||
user = await buildAdmin();
|
||||
group = await buildGroup({
|
||||
teamId: user.teamId,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not create an event if the update is a noop", async () => {
|
||||
const res = await server.post("/api/groups.update", {
|
||||
body: {
|
||||
@@ -601,27 +554,6 @@ describe("#groups.add_user", () => {
|
||||
expect(users.length).toEqual(1);
|
||||
});
|
||||
|
||||
it("should add user to group as admin", async () => {
|
||||
const user = await buildAdmin();
|
||||
const anotherUser = await buildUser({
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const group = await buildGroup({
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const res = await server.post("/api/groups.add_user", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: group.id,
|
||||
userId: anotherUser.id,
|
||||
isAdmin: true,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.groupMemberships[0].isAdmin).toEqual(true);
|
||||
});
|
||||
|
||||
it("should require authentication", async () => {
|
||||
const res = await server.post("/api/groups.add_user");
|
||||
expect(res.status).toEqual(401);
|
||||
@@ -736,112 +668,3 @@ describe("#groups.remove_user", () => {
|
||||
expect(body).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe("#groups.update_user", () => {
|
||||
it("should update user admin status in group", async () => {
|
||||
const user = await buildAdmin();
|
||||
const anotherUser = await buildUser({
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const group = await buildGroup({
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
// First add the user to the group
|
||||
await server.post("/api/groups.add_user", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: group.id,
|
||||
userId: anotherUser.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Then update the user to be an admin
|
||||
const res = await server.post("/api/groups.update_user", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: group.id,
|
||||
userId: anotherUser.id,
|
||||
isAdmin: true,
|
||||
},
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.groupMemberships[0].isAdmin).toEqual(true);
|
||||
|
||||
// Update the user to not be an admin
|
||||
const res2 = await server.post("/api/groups.update_user", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: group.id,
|
||||
userId: anotherUser.id,
|
||||
isAdmin: false,
|
||||
},
|
||||
});
|
||||
|
||||
const body2 = await res2.json();
|
||||
expect(res2.status).toEqual(200);
|
||||
expect(body2.data.groupMemberships[0].isAdmin).toEqual(false);
|
||||
});
|
||||
|
||||
it("should require authentication", async () => {
|
||||
const res = await server.post("/api/groups.update_user");
|
||||
expect(res.status).toEqual(401);
|
||||
});
|
||||
|
||||
it("should require admin", async () => {
|
||||
const user = await buildUser();
|
||||
const anotherUser = await buildUser({
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const group = await buildGroup({
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
// Add the user to the group
|
||||
const admin = await buildAdmin({
|
||||
teamId: user.teamId,
|
||||
});
|
||||
await server.post("/api/groups.add_user", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
id: group.id,
|
||||
userId: anotherUser.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Try to update as non-admin
|
||||
const res = await server.post("/api/groups.update_user", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: group.id,
|
||||
userId: anotherUser.id,
|
||||
isAdmin: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
|
||||
it("should 404 if user is not in group", async () => {
|
||||
const user = await buildAdmin();
|
||||
const anotherUser = await buildUser({
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const group = await buildGroup({
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/groups.update_user", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: group.id,
|
||||
userId: anotherUser.id,
|
||||
isAdmin: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toEqual(404);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -251,7 +251,7 @@ router.post(
|
||||
validate(T.GroupsAddUserSchema),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.GroupsAddUserReq>) => {
|
||||
const { id, userId, isAdmin } = ctx.input.body;
|
||||
const { id, userId } = ctx.input.body;
|
||||
const actor = ctx.state.auth.user;
|
||||
const { transaction } = ctx.state;
|
||||
|
||||
@@ -270,17 +270,11 @@ router.post(
|
||||
},
|
||||
defaults: {
|
||||
createdById: actor.id,
|
||||
isAdmin: isAdmin || false,
|
||||
},
|
||||
},
|
||||
{ name: "add_user" }
|
||||
);
|
||||
|
||||
// If the user already exists in the group, update the admin status if provided
|
||||
if (isAdmin !== undefined && groupUser.isAdmin !== isAdmin) {
|
||||
await groupUser.update({ isAdmin });
|
||||
}
|
||||
|
||||
groupUser.user = user;
|
||||
|
||||
ctx.body = {
|
||||
@@ -328,46 +322,4 @@ router.post(
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
"groups.update_user",
|
||||
auth(),
|
||||
validate(T.GroupsUpdateUserSchema),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.GroupsUpdateUserReq>) => {
|
||||
const { id, userId, isAdmin } = ctx.input.body;
|
||||
const actor = ctx.state.auth.user;
|
||||
const { transaction } = ctx.state;
|
||||
|
||||
const group = await Group.findByPk(id, { transaction });
|
||||
authorize(actor, "update", group);
|
||||
|
||||
const user = await User.findByPk(userId, { transaction });
|
||||
authorize(actor, "read", user);
|
||||
|
||||
const groupUser = await GroupUser.unscoped().findOne({
|
||||
where: {
|
||||
groupId: group.id,
|
||||
userId: user.id,
|
||||
},
|
||||
transaction,
|
||||
lock: transaction.LOCK.UPDATE,
|
||||
});
|
||||
|
||||
if (!groupUser) {
|
||||
ctx.throw(404, "User is not a member of this group");
|
||||
}
|
||||
|
||||
await groupUser.update({ isAdmin });
|
||||
groupUser.user = user;
|
||||
|
||||
ctx.body = {
|
||||
data: {
|
||||
users: [presentUser(user)],
|
||||
groupMemberships: [presentGroupUser(groupUser, { includeUser: true })],
|
||||
groups: [await presentGroup(group)],
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -85,8 +85,6 @@ export const GroupsAddUserSchema = z.object({
|
||||
body: BaseIdSchema.extend({
|
||||
/** User Id */
|
||||
userId: z.string().uuid(),
|
||||
/** Whether the user is an admin of the group */
|
||||
isAdmin: z.boolean().optional().default(false),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -100,14 +98,3 @@ export const GroupsRemoveUserSchema = z.object({
|
||||
});
|
||||
|
||||
export type GroupsRemoveUserReq = z.infer<typeof GroupsRemoveUserSchema>;
|
||||
|
||||
export const GroupsUpdateUserSchema = z.object({
|
||||
body: BaseIdSchema.extend({
|
||||
/** User Id */
|
||||
userId: z.string().uuid(),
|
||||
/** Whether the user is an admin of the group */
|
||||
isAdmin: z.boolean(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type GroupsUpdateUserReq = z.infer<typeof GroupsUpdateUserSchema>;
|
||||
|
||||
@@ -153,7 +153,9 @@ describe("#relationships.info", () => {
|
||||
expect(body.data.relationship).toBeTruthy();
|
||||
expect(body.data.documents).toHaveLength(2);
|
||||
// User can read their own document but admin document should also be included
|
||||
const documentIds = body.data.documents.map((doc: any) => doc.id);
|
||||
const documentIds = body.data.documents.map(
|
||||
(doc: { id: string }) => doc.id
|
||||
);
|
||||
expect(documentIds).toContain(userDocument.id);
|
||||
});
|
||||
|
||||
@@ -170,7 +172,9 @@ describe("#relationships.info", () => {
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.documents).toHaveLength(2);
|
||||
const documentIds = body.data.documents.map((doc: any) => doc.id);
|
||||
const documentIds = body.data.documents.map(
|
||||
(doc: { id: string }) => doc.id
|
||||
);
|
||||
expect(documentIds).toContain(document.id);
|
||||
expect(documentIds).toContain(reverseDocument.id);
|
||||
});
|
||||
@@ -265,7 +269,7 @@ describe("#relationships.list", () => {
|
||||
expect(body.data.relationships).toBeTruthy();
|
||||
|
||||
// All returned relationships should be backlinks
|
||||
body.data.relationships.forEach((rel: any) => {
|
||||
body.data.relationships.forEach((rel: { type: string }) => {
|
||||
expect(rel.type).toEqual(RelationshipType.Backlink);
|
||||
});
|
||||
});
|
||||
@@ -283,7 +287,7 @@ describe("#relationships.list", () => {
|
||||
expect(body.data.relationships).toBeTruthy();
|
||||
|
||||
// All returned relationships should have the specified documentId
|
||||
body.data.relationships.forEach((rel: any) => {
|
||||
body.data.relationships.forEach((rel: { documentId: string }) => {
|
||||
expect(rel.documentId).toEqual(documents[0].id);
|
||||
});
|
||||
});
|
||||
@@ -301,7 +305,7 @@ describe("#relationships.list", () => {
|
||||
expect(body.data.relationships).toBeTruthy();
|
||||
|
||||
// All returned relationships should have the specified reverseDocumentId
|
||||
body.data.relationships.forEach((rel: any) => {
|
||||
body.data.relationships.forEach((rel: { reverseDocumentId: string }) => {
|
||||
expect(rel.reverseDocumentId).toEqual(documents[1].id);
|
||||
});
|
||||
});
|
||||
@@ -320,10 +324,12 @@ describe("#relationships.list", () => {
|
||||
expect(body.data.relationships).toBeTruthy();
|
||||
|
||||
// All returned relationships should match both filters
|
||||
body.data.relationships.forEach((rel: any) => {
|
||||
expect(rel.type).toEqual(RelationshipType.Backlink);
|
||||
expect(rel.documentId).toEqual(documents[0].id);
|
||||
});
|
||||
body.data.relationships.forEach(
|
||||
(rel: { type: string; documentId: string }) => {
|
||||
expect(rel.type).toEqual(RelationshipType.Backlink);
|
||||
expect(rel.documentId).toEqual(documents[0].id);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("should fail with status 400 bad request when documentId is invalid", async () => {
|
||||
|
||||
@@ -38,7 +38,7 @@ describe("#searches.list", () => {
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data).toHaveLength(3);
|
||||
const queries = body.data.map((d: any) => d.query);
|
||||
const queries = body.data.map((d: { query: string }) => d.query);
|
||||
expect(queries).toContain("query");
|
||||
expect(queries).toContain("foo");
|
||||
expect(queries).toContain("bar");
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import * as React from "react";
|
||||
import { isSafari } from "../../utils/browser";
|
||||
import { BaseIconProps } from ".";
|
||||
|
||||
/** Renders an icon for a specific GitLab issue state */
|
||||
export function GitLabIssueStatusIcon(props: BaseIconProps) {
|
||||
const { state } = props;
|
||||
const isOpen = state.name === "opened";
|
||||
const color = state.color || (isOpen ? "#1aaa55" : "#db3b21"); // Green for open, red for closed
|
||||
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={{ marginTop: isSafari() ? 0 : -2 }}
|
||||
>
|
||||
<circle cx="8" cy="8" r="7" stroke={color} strokeWidth="2" fill="none" />
|
||||
{!isOpen && (
|
||||
<path
|
||||
d="M4.5 4.5L11.5 11.5M4.5 11.5L11.5 4.5"
|
||||
stroke={color}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
)}
|
||||
{state.draft && (
|
||||
<rect x="4" y="7" width="8" height="2" rx="1" fill={color} />
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
UnfurlResponse,
|
||||
} from "../../types";
|
||||
import { GitHubIssueStatusIcon } from "./GitHubIssueStatusIcon";
|
||||
import { GitLabIssueStatusIcon } from "./GitLabIssueStatusIcon";
|
||||
import { LinearIssueStatusIcon } from "./LinearIssueStatusIcon";
|
||||
|
||||
export type BaseIconProps = {
|
||||
@@ -33,6 +34,8 @@ function getIcon(props: Props) {
|
||||
return <GitHubIssueStatusIcon {...props} />;
|
||||
case IntegrationService.Linear:
|
||||
return <LinearIssueStatusIcon {...props} />;
|
||||
case IntegrationService.GitLab:
|
||||
return <GitLabIssueStatusIcon {...props} />;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -120,7 +120,6 @@ export default class Mention extends Node {
|
||||
toPlainText(node),
|
||||
],
|
||||
toPlainText,
|
||||
leafText: toPlainText,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import { NodeSpec, NodeType, Node as ProsemirrorNode } from "prosemirror-model";
|
||||
import deleteEmptyFirstParagraph from "../commands/deleteEmptyFirstParagraph";
|
||||
import { MarkdownSerializerState } from "../lib/markdown/serializer";
|
||||
import Node from "./Node";
|
||||
import { EditorStyleHelper } from "../styles/EditorStyleHelper";
|
||||
|
||||
export default class Paragraph extends Node {
|
||||
get name() {
|
||||
@@ -14,23 +13,7 @@ export default class Paragraph extends Node {
|
||||
return {
|
||||
content: "inline*",
|
||||
group: "block",
|
||||
parseDOM: [
|
||||
{
|
||||
tag: "p",
|
||||
getAttrs: (dom) => {
|
||||
if (!(dom instanceof HTMLElement)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// We must suppress image captions from being parsed as a separate paragraph.
|
||||
if (dom.classList.contains(EditorStyleHelper.imageCaption)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return {};
|
||||
},
|
||||
},
|
||||
],
|
||||
parseDOM: [{ tag: "p" }],
|
||||
toDOM: () => ["p", { dir: "auto" }, 0],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1182,6 +1182,10 @@
|
||||
"Enabled by {{integrationCreatedBy}}": "Enabled by {{integrationCreatedBy}}",
|
||||
"Disconnecting will prevent previewing GitHub links from this organization in documents. Are you sure?": "Disconnecting will prevent previewing GitHub links from this organization in documents. Are you sure?",
|
||||
"The GitHub integration is currently disabled. Please set the associated environment variables and restart the server to enable the integration.": "The GitHub integration is currently disabled. Please set the associated environment variables and restart the server to enable the integration.",
|
||||
"Whoops, you need to accept the permissions in GitLab to connect {{appName}} to your project. Try again?": "Whoops, you need to accept the permissions in GitLab to connect {{appName}} to your project. Try again?",
|
||||
"Enable previews of GitLab issues and merge requests in documents by connecting a GitLab project to {appName}.": "Enable previews of GitLab issues and merge requests in documents by connecting a GitLab project to {appName}.",
|
||||
"Disconnecting will prevent previewing GitLab links from this project in documents. Are you sure?": "Disconnecting will prevent previewing GitLab links from this project in documents. Are you sure?",
|
||||
"The GitLab integration is currently disabled. Please set the associated environment variables and restart the server to enable the integration.": "The GitLab integration is currently disabled. Please set the associated environment variables and restart the server to enable the integration.",
|
||||
"Google Analytics": "Google Analytics",
|
||||
"Add a Google Analytics 4 measurement ID to send document views and analytics from the workspace to your own Google Analytics account.": "Add a Google Analytics 4 measurement ID to send document views and analytics from the workspace to your own Google Analytics account.",
|
||||
"Measurement ID": "Measurement ID",
|
||||
|
||||
+6
-1
@@ -126,6 +126,7 @@ export enum IntegrationService {
|
||||
Umami = "umami",
|
||||
GitHub = "github",
|
||||
Linear = "linear",
|
||||
GitLab = "gitlab",
|
||||
Notion = "notion",
|
||||
}
|
||||
|
||||
@@ -140,12 +141,13 @@ export const ImportableIntegrationService = {
|
||||
|
||||
export type IssueTrackerIntegrationService = Extract<
|
||||
IntegrationService,
|
||||
IntegrationService.GitHub | IntegrationService.Linear
|
||||
IntegrationService.GitHub | IntegrationService.Linear | IntegrationService.GitLab
|
||||
>;
|
||||
|
||||
export const IssueTrackerIntegrationService = {
|
||||
GitHub: IntegrationService.GitHub,
|
||||
Linear: IntegrationService.Linear,
|
||||
GitLab: IntegrationService.GitLab,
|
||||
} as const;
|
||||
|
||||
export type UserCreatableIntegrationService = Extract<
|
||||
@@ -189,6 +191,9 @@ export type IntegrationSettings<T> = T extends IntegrationType.Embed
|
||||
linear?: {
|
||||
workspace: { id: string; name: string; key: string; logoUrl?: string };
|
||||
};
|
||||
gitlab?: {
|
||||
project: { id: string; name: string; path_with_namespace: string; avatar_url?: string };
|
||||
};
|
||||
}
|
||||
: T extends IntegrationType.Analytics
|
||||
? { measurementId: string; instanceUrl?: string; scriptName?: string }
|
||||
|
||||
@@ -14,7 +14,7 @@ const stripEmojis = (value: string) => value.replace(regex, "");
|
||||
|
||||
const cleanValue = (value: string) => stripEmojis(deburr(value));
|
||||
|
||||
function getSortByField<T extends Record<string, any>>(
|
||||
function getSortByField<T extends Record<string, unknown>>(
|
||||
item: T,
|
||||
keyOrCallback: string | ((item: T) => string)
|
||||
) {
|
||||
@@ -25,7 +25,7 @@ function getSortByField<T extends Record<string, any>>(
|
||||
return cleanValue(field);
|
||||
}
|
||||
|
||||
function naturalSortBy<T extends Record<string, any>>(
|
||||
function naturalSortBy<T extends Record<string, unknown>>(
|
||||
items: T[],
|
||||
key: string | ((item: T) => string),
|
||||
sortOptions?: NaturalSortOptions
|
||||
|
||||
Reference in New Issue
Block a user