Compare commits

..

22 Commits

Author SHA1 Message Date
codegen-sh[bot] 739290b5fc fix: Fix TypeScript error in naturalSort.ts by ensuring string conversion 2025-08-19 01:17:07 +00:00
codegen-sh[bot] cd67566e3e Fix ESLint issues: Add missing React keys to fragments in ApiKeyListItem and OAuthAuthorize components 2025-08-19 00:57:59 +00:00
codegen-sh[bot] 7e9ce2fc64 fix: Update React import in GitLabIssueStatusIcon to match other components 2025-08-19 00:35:46 +00:00
codegen-sh[bot] c0ff5aa55b fix: Fix TypeScript errors in GitLab integration
- Fix LazyComponent type in client/index.tsx
- Remove unused contentType variable in UploadGitLabProjectAvatarTask.ts
- Remove unused UnfurlIssueOrPR import in gitlab.ts
2025-08-19 00:34:39 +00:00
codegen-sh[bot] 68bc6d20af fix: Replace any with Record<string, unknown> in OIDCStrategy 2025-08-19 00:18:27 +00:00
codegen-sh[bot] 27f003d9c9 Fix: Change queryString import to use namespace import 2025-08-19 00:13:11 +00:00
codegen-sh[bot] b689ebd8ca fix: Update GitLabIssueStatusIcon to match style of other icon components 2025-08-18 23:49:32 +00:00
codegen-sh[bot] 8e74bb7d01 fix: add type guard for draft property in GitLabIssueStatusIcon 2025-08-18 23:20:56 +00:00
codegen-sh[bot] c02bc22cce Fix code formatting issues to pass CI checks 2025-08-18 23:16:18 +00:00
codegen-sh[bot] 7ea308a52c Fix linting issues in React Hook dependencies 2025-08-18 23:15:39 +00:00
codegen-sh[bot] e4d1a38367 fix: Fix TypeScript errors in OIDCStrategy
- Change Record<string, unknown> to any to match the Strategy interface
- Add null check for options parameter
2025-08-18 23:15:22 +00:00
codegen-sh[bot] 98d54da0de fix: Remove trailing whitespace in HoverPreviewIssue.tsx 2025-08-18 23:03:21 +00:00
codegen-sh[bot] 4b638ae346 fix: fix remaining linting errors 2025-08-18 22:38:22 +00:00
codegen-sh[bot] abb849e1f6 fix: replace any with unknown in types and add proper type definitions 2025-08-18 22:30:48 +00:00
codegen-sh[bot] 2ec65e3dfc fix: replace any with unknown in MutexLock 2025-08-18 22:27:16 +00:00
codegen-sh[bot] 4e493972e5 fix: fix remaining linting errors 2025-08-18 22:24:07 +00:00
codegen-sh[bot] 4a8b8d5fa7 fix: replace any types with unknown to fix linting errors 2025-08-18 22:19:11 +00:00
codegen-sh[bot] 391fc5fdee fix: replace any types in MultiplayerEditor and SlackUtils 2025-08-18 22:15:50 +00:00
codegen-sh[bot] cbcf7d6a8e fix: replace more any types with more specific types to fix linting errors 2025-08-18 22:15:05 +00:00
codegen-sh[bot] 94eb1aa07d fix: replace any types with more specific types to fix linting errors 2025-08-18 22:13:36 +00:00
codegen-sh[bot] ca66a6b2fa fix: Fix linting issues in GitLab integration and OIDC strategy
- Remove unused IntegrationService import in UploadGitLabProjectAvatarTask
- Replace 'any' type with proper types in gitlab.ts and OIDCStrategy.ts
2025-08-18 21:59:03 +00:00
codegen-sh[bot] 404a5991b3 feat: Add GitLab integration matching Linear integration patterns 2025-08-18 21:47:16 +00:00
86 changed files with 1651 additions and 1094 deletions
+4
View File
@@ -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
-3
View File
@@ -26,6 +26,3 @@ updates:
aws:
patterns:
- "@aws-sdk/*"
radix-ui:
patterns:
- "@radix-ui/*"
+1 -1
View File
@@ -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
+21
View File
@@ -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();
},
});
+1 -1
View File
@@ -125,8 +125,8 @@ function Collaborators(props: Props) {
return (
<AvatarWithPresence
{...rest}
key={collaborator.id}
{...rest}
user={collaborator}
isPresent={isPresent}
isEditing={isEditing}
-11
View File
@@ -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">
+2 -2
View File
@@ -1,7 +1,7 @@
import * as React from "react";
import lazyWithRetry from "~/utils/lazyWithRetry";
export interface LazyComponent<T extends React.ComponentType<any>> {
export interface LazyComponent<T extends React.ComponentType<unknown>> {
Component: React.LazyExoticComponent<T>;
preload: () => Promise<{ default: T }>;
}
@@ -34,7 +34,7 @@ interface LazyLoadOptions {
* MyComponent.preload();
* ```
*/
export function createLazyComponent<T extends React.ComponentType<any>>(
export function createLazyComponent<T extends React.ComponentType<unknown>>(
factory: () => Promise<{ default: T }>,
options: LazyLoadOptions = {}
): LazyComponent<T> {
+1 -1
View File
@@ -14,7 +14,7 @@ describe("PaginatedList", () => {
i18n,
tReady: true,
t: ((key: string) => key) as TFunction,
} as any;
} as unknown;
it("with no items renders nothing", () => {
const result = render(
+6 -4
View File
@@ -34,11 +34,11 @@ interface Props<T extends PaginatedItem>
* @param options Pagination and other query options
*/
fetch?: (
options: Record<string, any> | undefined
options: Record<string, unknown> | undefined
) => Promise<unknown[] | undefined> | undefined;
/** Additional options to pass to the fetch function */
options?: Record<string, any>;
options?: Record<string, unknown>;
/** Optional header content to display above the list */
heading?: React.ReactNode;
@@ -77,7 +77,9 @@ interface Props<T extends PaginatedItem>
* Function to render section headings (typically date-based)
* @param name The heading text or element to render
*/
renderHeading?: (name: React.ReactElement<any> | string) => React.ReactNode;
renderHeading?: (
name: React.ReactElement<unknown> | string
) => React.ReactNode;
/**
* Handler for escape key press
@@ -206,7 +208,7 @@ const PaginatedList = <T extends PaginatedItem>({
if (fetch) {
void fetchResults();
}
}, [fetch]);
}, [fetch, fetchResults]);
// Handle updates to fetch or options
React.useEffect(() => {
+1 -1
View File
@@ -9,7 +9,7 @@ function Toasts() {
return (
<StyledToaster
theme={ui.resolvedTheme as any}
theme={ui.resolvedTheme as unknown}
closeButton
toastOptions={{
duration: 5000,
+24 -18
View File
@@ -70,7 +70,7 @@ const LinkEditor: React.FC<Props> = ({
React.useCallback(async () => {
const res = await client.post("/suggestions.mention", { query });
res.data.documents.map(documents.add);
}, [query])
}, [query, documents.add])
);
useEffect(() => {
@@ -79,6 +79,22 @@ const LinkEditor: React.FC<Props> = ({
}
}, [trimmedQuery, request]);
const save = React.useCallback(
(href: string, title?: string) => {
href = href.trim();
if (href.length === 0) {
return;
}
discardRef.current = true;
href = sanitizeUrl(href) ?? "";
onSelectLink({ href, title, from, to });
},
[onSelectLink, from, to]
);
useEffect(() => {
const handleGlobalKeyDown = (event: KeyboardEvent) => {
if (event.key === "k" && event.metaKey) {
@@ -107,20 +123,7 @@ const LinkEditor: React.FC<Props> = ({
save(trimmedQuery, trimmedQuery);
};
}, [trimmedQuery, initialValue]);
const save = (href: string, title?: string) => {
href = href.trim();
if (href.length === 0) {
return;
}
discardRef.current = true;
href = sanitizeUrl(href) ?? "";
onSelectLink({ href, title, from, to });
};
}, [trimmedQuery, initialValue, handleRemoveLink, save]);
const moveSelectionToEnd = () => {
const { state, dispatch } = view;
@@ -195,7 +198,7 @@ const LinkEditor: React.FC<Props> = ({
}
};
const handleRemoveLink = () => {
const handleRemoveLink = React.useCallback(() => {
discardRef.current = true;
const { state, dispatch } = view;
@@ -203,9 +206,12 @@ const LinkEditor: React.FC<Props> = ({
dispatch(state.tr.removeMark(from, to, mark));
}
onRemoveLink?.();
if (onRemoveLink) {
onRemoveLink();
}
view.focus();
};
}, [view, mark, from, to, onRemoveLink]);
const isInternal = isInternalUrl(query);
const hasResults = !!results.length;
+10 -1
View File
@@ -184,7 +184,16 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
setItems(items);
setLoaded(true);
}
}, [t, actorId, loading, search, users, documents, maxResultsInSection]);
}, [
t,
actorId,
loading,
search,
users,
documents,
maxResultsInSection,
collections,
]);
const handleSelect = useCallback(
async (item: MentionItem) => {
+1 -1
View File
@@ -87,7 +87,7 @@ function useIsActive(state: EditorState) {
const slice = selection.content();
const fragment = slice.content;
const nodes = (fragment as any).content;
const nodes = (fragment as unknown).content;
return some(nodes, (n) => n.content.size);
}
+1 -1
View File
@@ -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;
}
+4 -35
View File
@@ -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
View File
@@ -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) {
-3
View File
@@ -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;
+2 -1
View File
@@ -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]
);
+6 -19
View File
@@ -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);
+30 -9
View File
@@ -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
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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();
+3 -3
View File
@@ -1,4 +1,4 @@
import { useState, useRef, useEffect } from "react";
import React, { useState, useRef, useEffect } from "react";
import { Trans, useTranslation } from "react-i18next";
import styled from "styled-components";
import Flex from "@shared/components/Flex";
@@ -132,10 +132,10 @@ function Authorize() {
{t("Required OAuth parameters are missing")}
<Pre>
{missingParams.map((param) => (
<>
<React.Fragment key={param}>
{param}
<br />
</>
</React.Fragment>
))}
</Pre>
</Text>
@@ -50,10 +50,10 @@ const ApiKeyListItem = ({ apiKey }: Props) => {
{apiKey.scope && (
<Tooltip
content={apiKey.scope.map((s) => (
<>
<React.Fragment key={s}>
{s}
<br />
</>
</React.Fragment>
))}
>
<Text type="tertiary">{t("Restricted scope")}</Text>
+6 -1
View File
@@ -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();
+8 -1
View File
@@ -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)
+1 -33
View File
@@ -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) => {
+7
View File
@@ -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;
+2 -2
View File
@@ -125,7 +125,7 @@ export type Action = {
* Perform the action note this should generally not be called directly, use `performAction`
* instead. Errors will be caught and displayed to the user as a toast message.
*/
perform?: (context: ActionContext) => any;
perform?: (context: ActionContext) => unknown;
to?: string | { url: string; target?: string };
children?: ((context: ActionContext) => Action[]) | Action[];
};
@@ -154,7 +154,7 @@ export type ActionV2 = BaseActionV2 & {
tooltip?:
| ((context: ActionContext) => React.ReactChild | undefined)
| React.ReactChild;
perform: (context: ActionContext) => any;
perform: (context: ActionContext) => unknown;
};
export type InternalLinkActionV2 = BaseActionV2 & {
+4 -4
View File
@@ -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
View File
@@ -13,7 +13,7 @@ type LogCategory =
| "plugins"
| "policies";
type Extra = Record<string, any>;
type Extra = Record<string, unknown>;
class Logger {
/**
+3 -3
View File
@@ -1,6 +1,6 @@
import * as React from "react";
type ComponentPromise<T extends React.ComponentType<any>> = Promise<{
type ComponentPromise<T extends React.ComponentType<unknown>> = Promise<{
default: T;
}>;
@@ -12,7 +12,7 @@ type ComponentPromise<T extends React.ComponentType<any>> = Promise<{
* @param interval The interval between retries in milliseconds, defaults to 1000.
* @returns A lazy component.
*/
export default function lazyWithRetry<T extends React.ComponentType<any>>(
export default function lazyWithRetry<T extends React.ComponentType<unknown>>(
component: () => ComponentPromise<T>,
retries?: number,
interval?: number
@@ -20,7 +20,7 @@ export default function lazyWithRetry<T extends React.ComponentType<any>>(
return React.lazy(() => retry(component, retries, interval));
}
function retry<T extends React.ComponentType<any>>(
function retry<T extends React.ComponentType<unknown>>(
fn: () => ComponentPromise<T>,
retriesLeft = 3,
interval = 1000
+21
View File
@@ -37,6 +37,17 @@ 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 +78,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;
}
+1 -1
View File
@@ -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=" : ""
}`;
}
+1 -1
View File
@@ -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
View File
@@ -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"
+50
View File
@@ -0,0 +1,50 @@
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>
);
}
+139
View File
@@ -0,0 +1,139 @@
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>{" "}
&middot;{" "}
<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,23 @@
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>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { createLazyComponent } from "~/components/LazyLoad";
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: createLazyComponent(() => import("./Settings")),
},
},
]);
+7
View File
@@ -0,0 +1,7 @@
{
"id": "gitlab",
"name": "GitLab",
"priority": 16,
"description": "Adds a GitLab integration for link unfurling and converting links to mentions.",
"after": "linear"
}
+99
View File
@@ -0,0 +1,99 @@
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;
+17
View File
@@ -0,0 +1,17 @@
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>;
+6
View File
@@ -0,0 +1,6 @@
import env from "@server/env";
export default {
GITLAB_CLIENT_ID: env.GITLAB_CLIENT_ID,
GITLAB_CLIENT_SECRET: env.GITLAB_CLIENT_SECRET,
};
+293
View File
@@ -0,0 +1,293 @@
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 { 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: string) => ({
name: label,
color: "#428BCA", // Default GitLab blue
}));
}
// Create the appropriate response based on the resource type
if (resourceType === UnfurlResourceType.Issue) {
return {
type: UnfurlResourceType.Issue,
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
},
createdAt: data.created_at,
};
} else {
return {
type: UnfurlResourceType.PR,
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: !!data.draft,
},
createdAt: data.created_at,
};
}
} 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,75 @@
import BaseTask from "@server/queues/tasks/BaseTask";
import { Integration } from "@server/models";
import { FileOperation } from "@server/models";
import fetch from "node-fetch";
import Logger from "@server/logging/Logger";
import {
FileOperationState,
FileOperationType,
FileOperationFormat,
} from "@shared/types";
import { v4 as uuidv4 } from "uuid";
type Props = {
integrationId: string;
avatarUrl: string;
};
// Define a type for GitLab settings
interface GitLabSettings {
gitlab: {
project?: {
avatar_url?: string;
[key: string]: unknown;
};
[key: string]: unknown;
};
}
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";
// Create a file operation with the correct parameters
const operation = await FileOperation.create({
type: FileOperationType.Import,
state: FileOperationState.Creating,
format: FileOperationFormat.JSON, // Use a valid FileOperationFormat
key: `uploads/${integration.teamId}/${uuidv4()}/${name}`,
userId: integration.userId,
teamId: integration.teamId,
size: buffer.length,
});
// Cast the settings to our GitLabSettings interface
const currentSettings = integration.settings as unknown as GitLabSettings;
// Update the integration settings with the avatar URL
await integration.update({
settings: {
...integration.settings,
gitlab: {
...currentSettings.gitlab,
project: {
...currentSettings.gitlab?.project,
avatar_url: operation.url,
},
},
} as Record<string, unknown>,
});
} catch (err: unknown) {
// 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.
const error = err instanceof Error ? err : new Error(String(err));
Logger.error("Failed to upload GitLab project avatar", error);
}
}
}
+51
View File
@@ -0,0 +1,51 @@
import * as 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)}`;
}
}
+9 -1
View File
@@ -55,7 +55,15 @@ export const Notion = observer(() => {
onClose: clearQueryParams,
});
}
}, [t, dialogs, oauthSuccess, service, clearQueryParams]);
}, [
t,
dialogs,
oauthSuccess,
service,
clearQueryParams,
handleSubmit,
integrationId,
]);
React.useEffect(() => {
if (!oauthError) {
@@ -52,7 +52,15 @@ export function ImportDialog({ integrationId, onSubmit }: Props) {
toast.error(err.message);
resetSubmitting();
}
}, [permission, onSubmit]);
}, [
permission,
onSubmit,
integrationId,
t,
imports,
resetSubmitting,
setSubmitting,
]);
return (
<Flex column gap={12}>
@@ -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(
+4 -2
View File
@@ -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,13 @@ export class OIDCStrategy extends Strategy {
}
}
authenticate(req: any, options: any) {
authenticate(req: Request, options?: Record<string, unknown>) {
options = options || {};
options.originalQuery = req.query;
super.authenticate(req, options);
}
authorizationParams(options: any) {
authorizationParams(options: Record<string, unknown>) {
return {
...options.originalQuery,
...super.authorizationParams?.(options),
+2 -2
View File
@@ -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 {
+1 -1
View File
@@ -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
View File
@@ -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");
},
};
-3
View File
@@ -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,
};
/**
+3 -11
View File
@@ -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)
)
);
-33
View File
@@ -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;
}
-1
View File
@@ -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,
};
}
+2 -2
View File
@@ -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.
+1 -1
View File
@@ -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> {
+27 -44
View File
@@ -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);
});
+1 -1
View File
@@ -573,7 +573,7 @@ router.post(
});
let document: Document | null;
let serializedDocument: Record<string, any> | undefined;
let serializedDocument: Record<string, unknown> | undefined;
let isPublic = false;
if (shareId) {
+9 -182
View File
@@ -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: {
@@ -274,20 +227,22 @@ describe("#groups.list", () => {
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.groups.length).toEqual(2);
expect(body.data.groups[0].id).toEqual(anotherGroup.id);
expect(body.data.groups[1].id).toEqual(group.id);
expect(body.data.groupMemberships.length).toEqual(2);
expect(body.data.groupMemberships[0].groupId).toEqual(group.id);
expect(body.data.groupMemberships[1].groupId).toEqual(group.id);
expect(
body.data.groupMemberships.map((u: any) => u.user.id).includes(user.id)
body.data.groupMemberships
.map((u: { user: { id: string }; groupId: string }) => u.user.id)
.includes(user.id)
).toBe(true);
expect(
body.data.groupMemberships
.map((u: any) => u.user.id)
.map((u: { user: { id: string }; groupId: string }) => u.user.id)
.includes(anotherUser.id)
).toBe(true);
expect(body.policies.length).toEqual(2);
@@ -306,11 +261,13 @@ describe("#groups.list", () => {
expect(anotherBody.data.groupMemberships[0].groupId).toEqual(group.id);
expect(anotherBody.data.groupMemberships[1].groupId).toEqual(group.id);
expect(
body.data.groupMemberships.map((u: any) => u.user.id).includes(user.id)
body.data.groupMemberships
.map((u: { user: { id: string }; groupId: string }) => u.user.id)
.includes(user.id)
).toBe(true);
expect(
body.data.groupMemberships
.map((u: any) => u.user.id)
.map((u: { user: { id: string }; groupId: string }) => u.user.id)
.includes(anotherUser.id)
).toBe(true);
});
@@ -601,27 +558,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 +672,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);
});
});
+1 -49
View File
@@ -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;
-13
View File
@@ -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 () => {
+1 -1
View File
@@ -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");
+1 -1
View File
@@ -14,5 +14,5 @@ export class MutexLock {
};
}
private static redlock: any;
private static redlock: unknown;
}
@@ -0,0 +1,31 @@
import * as React from "react";
import { BaseIconProps } from ".";
export function GitLabIssueStatusIcon(props: BaseIconProps) {
const { state, className, size = 16 } = props;
const isOpen = state.name === "opened";
const color = state.color || (isOpen ? "#1aaa55" : "#db3b21"); // Green for open, red for closed
return (
<svg
viewBox="0 0 16 16"
width={size}
height={size}
fill="none"
className={className}
>
<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"
/>
)}
{"draft" in state && 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} />;
}
}
+2 -2
View File
@@ -10,10 +10,10 @@ export type CommandFactory = (attrs?: Record<string, Primitive>) => Command;
export type WidgetProps = { rtl: boolean; readOnly: boolean | undefined };
export default class Extension {
options: any;
options: Record<string, unknown>;
editor: Editor;
constructor(options: Record<string, any> = {}) {
constructor(options: Record<string, unknown> = {}) {
this.options = {
...this.defaultOptions,
...options,
-1
View File
@@ -120,7 +120,6 @@ export default class Mention extends Node {
toPlainText(node),
],
toPlainText,
leafText: toPlainText,
};
}
+1 -18
View File
@@ -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",
+13 -1
View File
@@ -126,6 +126,7 @@ export enum IntegrationService {
Umami = "umami",
GitHub = "github",
Linear = "linear",
GitLab = "gitlab",
Notion = "notion",
}
@@ -140,12 +141,15 @@ 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 +193,14 @@ 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 }
+3 -3
View File
@@ -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)
) {
@@ -22,10 +22,10 @@ function getSortByField<T extends Record<string, any>>(
typeof keyOrCallback === "string"
? item[keyOrCallback]
: keyOrCallback(item);
return cleanValue(field);
return cleanValue(String(field));
}
function naturalSortBy<T extends Record<string, any>>(
function naturalSortBy<T extends Record<string, unknown>>(
items: T[],
key: string | ((item: T) => string),
sortOptions?: NaturalSortOptions
+440 -445
View File
File diff suppressed because it is too large Load Diff