mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a8c2612734 | |||
| 8c11b6cfc8 | |||
| d858289159 | |||
| 52d420bd98 | |||
| 386eebb117 | |||
| d0993c3393 | |||
| 54d17503bf | |||
| 0de2a3dc98 | |||
| 73ac18bbde | |||
| 18dcef8ce4 | |||
| 7458228df0 | |||
| 7c93f8a039 | |||
| d6a126d974 | |||
| 779fb1d568 | |||
| a0ce14f2a2 | |||
| 091abf0b9d | |||
| 342c42194e | |||
| 8383a0ee1e | |||
| 19a696942e | |||
| f1a5e95f77 | |||
| 99fedfa354 | |||
| 9da73202c7 | |||
| 30db7bc554 | |||
| b40eaf4184 | |||
| 3aff344501 | |||
| 0f812d70c1 | |||
| 125e9c2e0b | |||
| 95402b4b52 | |||
| d01e3ad09c | |||
| edb6d44bdc |
@@ -26,3 +26,6 @@ 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
|
||||
- run: yarn lint --quiet
|
||||
|
||||
types:
|
||||
needs: build
|
||||
|
||||
@@ -3,7 +3,6 @@ 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";
|
||||
|
||||
@@ -50,16 +49,6 @@ 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"));
|
||||
},
|
||||
@@ -82,16 +71,6 @@ 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,9 +11,15 @@ 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[] = [];
|
||||
|
||||
@@ -39,6 +45,11 @@ class DocumentContext {
|
||||
this.isEditorInitialized = initialized;
|
||||
};
|
||||
|
||||
@action
|
||||
setFocusedCommentId = (commentId: string | null) => {
|
||||
this.focusedCommentId = commentId;
|
||||
};
|
||||
|
||||
@action
|
||||
updateState = () => {
|
||||
this.updateHeadings();
|
||||
|
||||
@@ -57,7 +57,7 @@ export default function useEditorClickHandlers({ shareId }: Params) {
|
||||
}
|
||||
|
||||
if (isDocumentUrl(navigateTo)) {
|
||||
const document = documents.getByUrl(navigateTo);
|
||||
const document = documents.get(navigateTo);
|
||||
if (document) {
|
||||
navigateTo = document.path;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,44 @@
|
||||
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";
|
||||
|
||||
export default function useFocusedComment() {
|
||||
/**
|
||||
* 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() {
|
||||
const { comments } = useStores();
|
||||
const location = useLocation<{ commentId?: string }>();
|
||||
const context = useDocumentContext();
|
||||
const query = useQuery();
|
||||
const focusedCommentId = location.state?.commentId || query.get("commentId");
|
||||
const focusedCommentId = context.focusedCommentId || 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,9 +1,10 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useState, useCallback, useEffect } 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 */
|
||||
@@ -41,6 +42,7 @@ 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;
|
||||
@@ -65,6 +67,13 @@ 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,6 +22,9 @@ 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,8 +75,7 @@ const CollectionScene = observer(function _CollectionScene() {
|
||||
const id = params.id || "";
|
||||
const urlId = id.split("-").pop() ?? "";
|
||||
|
||||
const collection: Collection | null | undefined =
|
||||
collections.getByUrl(id) || collections.get(id);
|
||||
const collection: Collection | null | undefined = collections.get(id);
|
||||
const can = usePolicy(collection);
|
||||
|
||||
const { pins, count } = usePinnedDocuments(urlId, collection?.id);
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
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";
|
||||
|
||||
const CommentSortMenu = () => {
|
||||
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 { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const sidebarContext = useLocationSidebarContext();
|
||||
const history = useHistory();
|
||||
const user = useCurrentUser();
|
||||
const params = useQuery();
|
||||
|
||||
const preferredSortType = user.getPreference(
|
||||
UserPreference.SortCommentsByOrderInDocument
|
||||
@@ -25,42 +24,23 @@ const CommentSortMenu = () => {
|
||||
? CommentSortType.OrderInDocument
|
||||
: CommentSortType.MostRecent;
|
||||
|
||||
const viewingResolved = params.get("resolved") === "";
|
||||
const value = viewingResolved ? "resolved" : preferredSortType;
|
||||
|
||||
const handleChange = React.useCallback(
|
||||
(val: string) => {
|
||||
if (val === "resolved") {
|
||||
history.push({
|
||||
search: queryString.stringify({
|
||||
...queryString.parse(location.search),
|
||||
resolved: "",
|
||||
}),
|
||||
pathname: location.pathname,
|
||||
state: { sidebarContext },
|
||||
});
|
||||
return;
|
||||
(val: CommentSortType | "resolved") => {
|
||||
if (val !== "resolved") {
|
||||
if (val !== preferredSortType) {
|
||||
user.setPreference(
|
||||
UserPreference.SortCommentsByOrderInDocument,
|
||||
val === CommentSortType.OrderInDocument
|
||||
);
|
||||
void user.save();
|
||||
}
|
||||
}
|
||||
|
||||
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 },
|
||||
});
|
||||
onChange?.(val);
|
||||
},
|
||||
[history, location, sidebarContext, user, preferredSortType]
|
||||
[user, onChange, preferredSortType]
|
||||
);
|
||||
|
||||
const options: Option[] = React.useMemo(
|
||||
|
||||
@@ -2,7 +2,6 @@ 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";
|
||||
@@ -17,7 +16,6 @@ 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";
|
||||
@@ -51,14 +49,11 @@ function CommentThread({
|
||||
collapseNumDisplayed = 3,
|
||||
}: Props) {
|
||||
const [scrollOnMount] = React.useState(focused && !window.location.hash);
|
||||
const { editor } = useDocumentContext();
|
||||
const { editor, setFocusedCommentId } = 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();
|
||||
|
||||
@@ -102,14 +97,7 @@ function CommentThread({
|
||||
!(event.target as HTMLElement).classList.contains("comment") &&
|
||||
event.defaultPrevented === false
|
||||
) {
|
||||
history.replace({
|
||||
search: location.search,
|
||||
pathname: location.pathname,
|
||||
state: {
|
||||
commentId: undefined,
|
||||
sidebarContext,
|
||||
},
|
||||
});
|
||||
setFocusedCommentId(null);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -118,15 +106,7 @@ function CommentThread({
|
||||
}, [editor, thread.id]);
|
||||
|
||||
const handleClickThread = () => {
|
||||
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,
|
||||
},
|
||||
});
|
||||
setFocusedCommentId(thread.id);
|
||||
};
|
||||
|
||||
const handleClickExpand = (ev: React.SyntheticEvent) => {
|
||||
|
||||
@@ -30,6 +30,7 @@ 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
|
||||
@@ -111,6 +112,7 @@ function CommentThreadItem({
|
||||
onEditStart,
|
||||
onEditEnd,
|
||||
}: Props) {
|
||||
const { setFocusedCommentId } = useDocumentContext();
|
||||
const { t } = useTranslation();
|
||||
const user = useCurrentUser();
|
||||
const [data, setData] = React.useState(comment.data);
|
||||
@@ -154,6 +156,9 @@ 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,11 +31,13 @@ function Comments() {
|
||||
const { editor, isEditorInitialized } = useDocumentContext();
|
||||
const { t } = useTranslation();
|
||||
const match = useRouteMatch<{ documentSlug: string }>();
|
||||
const params = useQuery();
|
||||
const document = documents.getByUrl(match.params.documentSlug);
|
||||
const document = documents.get(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);
|
||||
@@ -43,6 +45,13 @@ 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
|
||||
@@ -57,7 +66,6 @@ function Comments() {
|
||||
}
|
||||
: { type: CommentSortType.MostRecent };
|
||||
|
||||
const viewingResolved = params.get("resolved") === "";
|
||||
const threads = !document
|
||||
? []
|
||||
: viewingResolved
|
||||
@@ -124,7 +132,12 @@ function Comments() {
|
||||
title={
|
||||
<Flex align="center" justify="space-between" auto>
|
||||
<span>{t("Comments")}</span>
|
||||
<CommentSortMenu />
|
||||
<CommentSortMenu
|
||||
viewingResolved={viewingResolved}
|
||||
onChange={(val) => {
|
||||
setViewingResolved(val === "resolved");
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
}
|
||||
onClose={() => ui.set({ commentsExpanded: false })}
|
||||
|
||||
@@ -67,9 +67,7 @@ function DataLoader({ match, children }: Props) {
|
||||
const { revisionId, documentSlug } = match.params;
|
||||
|
||||
// Allows loading by /doc/slug-<urlId> or /doc/<id>
|
||||
const document =
|
||||
documents.getByUrl(match.params.documentSlug) ??
|
||||
documents.get(match.params.documentSlug);
|
||||
const document = 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 { useHistory, useRouteMatch } from "react-router-dom";
|
||||
import { 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";
|
||||
@@ -59,11 +59,11 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
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,18 +95,11 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
(focusedComment.isResolved && !viewingResolved) ||
|
||||
(!focusedComment.isResolved && viewingResolved)
|
||||
) {
|
||||
history.replace({
|
||||
search: focusedComment.isResolved ? "resolved=" : "",
|
||||
pathname: location.pathname,
|
||||
state: {
|
||||
commentId: focusedComment.id,
|
||||
sidebarContext,
|
||||
},
|
||||
});
|
||||
setFocusedCommentId(focusedComment.id);
|
||||
}
|
||||
ui.set({ commentsExpanded: true });
|
||||
}
|
||||
}, [focusedComment, ui, document.id, history, params, sidebarContext]);
|
||||
}, [focusedComment, ui, document.id, params]);
|
||||
|
||||
// Save document when blurring title, but delay so that if clicking on a
|
||||
// button this is allowed to execute first.
|
||||
@@ -127,16 +120,6 @@ 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(
|
||||
@@ -156,13 +139,9 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
);
|
||||
comment.id = commentId;
|
||||
comments.add(comment);
|
||||
|
||||
history.replace({
|
||||
pathname: window.location.pathname.replace(/\/history$/, ""),
|
||||
state: { commentId, sidebarContext },
|
||||
});
|
||||
setFocusedCommentId(commentId);
|
||||
},
|
||||
[comments, user?.id, props.id, history, sidebarContext]
|
||||
[comments, user?.id, props.id]
|
||||
);
|
||||
|
||||
// Soft delete the Comment model when associated mark is totally removed.
|
||||
@@ -258,7 +237,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
userId={user?.id}
|
||||
focusedCommentId={focusedComment?.id}
|
||||
onClickCommentMark={
|
||||
commentingEnabled && can.comment ? handleClickComment : undefined
|
||||
commentingEnabled && can.comment ? setFocusedCommentId : undefined
|
||||
}
|
||||
onCreateCommentMark={
|
||||
commentingEnabled && can.comment ? handleDraftComment : undefined
|
||||
|
||||
@@ -166,7 +166,7 @@ function DocumentHeader({
|
||||
);
|
||||
|
||||
useKeyDown(
|
||||
(event) => event.ctrlKey && event.altKey && event.key === "˙",
|
||||
(event) => event.ctrlKey && event.altKey && event.code === "KeyH",
|
||||
handleToggle,
|
||||
{
|
||||
allowInInput: true,
|
||||
|
||||
@@ -34,7 +34,7 @@ function History() {
|
||||
const match = useRouteMatch<{ documentSlug: string }>();
|
||||
const history = useHistory();
|
||||
const sidebarContext = useLocationSidebarContext();
|
||||
const document = documents.getByUrl(match.params.documentSlug);
|
||||
const document = documents.get(match.params.documentSlug);
|
||||
const [revisionsOffset, setRevisionsOffset] = React.useState(0);
|
||||
const [eventsOffset, setEventsOffset] = React.useState(0);
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import invariant from "invariant";
|
||||
import find from "lodash/find";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import orderBy from "lodash/orderBy";
|
||||
import sortBy from "lodash/sortBy";
|
||||
@@ -186,7 +185,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))
|
||||
@@ -242,10 +241,6 @@ 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,7 +1,6 @@
|
||||
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";
|
||||
@@ -460,7 +459,7 @@ export default class DocumentsStore extends Store<Document> {
|
||||
|
||||
@action
|
||||
prefetchDocument = async (id: string) => {
|
||||
if (!this.data.get(id) && !this.getByUrl(id)) {
|
||||
if (!this.get(id)) {
|
||||
return this.fetch(id, {
|
||||
prefetch: true,
|
||||
});
|
||||
@@ -746,12 +745,6 @@ 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,10 +43,19 @@ export default class GroupUsersStore extends Store<GroupUser> {
|
||||
};
|
||||
|
||||
@action
|
||||
async create({ groupId, userId }: { groupId: string; userId: string }) {
|
||||
async create({
|
||||
groupId,
|
||||
userId,
|
||||
isAdmin = false,
|
||||
}: {
|
||||
groupId: string;
|
||||
userId: string;
|
||||
isAdmin?: boolean;
|
||||
}) {
|
||||
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);
|
||||
@@ -70,6 +79,29 @@ 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) => {
|
||||
|
||||
@@ -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=" : ""
|
||||
comment.isResolved ? "&resolved=1" : ""
|
||||
}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ export default {
|
||||
// TypeScript files
|
||||
"**/*.[tj]s?(x)": [
|
||||
(f) => `prettier --write ${f.join(" ")}`,
|
||||
(f) => (f.length > 20 ? `yarn lint` : `oxlint ${f.join(" ")}`),
|
||||
(f) => (f.length > 20 ? `yarn lint --fix` : `oxlint ${f.join(" ")} --fix`),
|
||||
() => `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.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",
|
||||
"@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",
|
||||
"@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.1",
|
||||
"@babel/preset-env": "^7.28.0",
|
||||
"@babel/plugin-transform-regenerator": "^7.28.3",
|
||||
"@babel/preset-env": "^7.28.3",
|
||||
"@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.14.3",
|
||||
"@css-inline/css-inline-wasm": "^0.17.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^6.0.1",
|
||||
"@dnd-kit/sortable": "^7.0.2",
|
||||
"@dotenvx/dotenvx": "^1.48.4",
|
||||
"@dotenvx/dotenvx": "^1.49.0",
|
||||
"@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.3",
|
||||
"@fortawesome/react-fontawesome": "^0.2.6",
|
||||
"@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.0.0",
|
||||
"@linear/sdk": "^39.2.1",
|
||||
"@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.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-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-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.37.0",
|
||||
"core-js": "^3.45.1",
|
||||
"crypto-js": "^4.2.0",
|
||||
"datadog-metrics": "^0.12.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"dd-trace": "^5.62.0",
|
||||
"dd-trace": "^5.63.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.9.0",
|
||||
"mermaid": "11.10.1",
|
||||
"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.4",
|
||||
"tmp": "^0.2.5",
|
||||
"tunnel-agent": "^0.6.0",
|
||||
"turndown": "^7.2.0",
|
||||
"ukkonen": "^2.1.0",
|
||||
"ukkonen": "^2.2.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.2",
|
||||
"vite-plugin-pwa": "1.0.3",
|
||||
"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.0",
|
||||
"@babel/cli": "^7.28.3",
|
||||
"@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.37.119",
|
||||
"discord-api-types": "^0.38.20",
|
||||
"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"
|
||||
|
||||
@@ -21,6 +21,11 @@ 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.
|
||||
@@ -138,8 +143,8 @@ export default class NotionAPIImportTask extends APIImportTask<IntegrationServic
|
||||
if (
|
||||
error.code === APIErrorCode.ObjectNotFound ||
|
||||
error.code === APIErrorCode.Unauthorized ||
|
||||
error.message.includes(
|
||||
"Database retrievals do not support linked databases"
|
||||
this.skippableErrorMessages.some((errorMsg) =>
|
||||
error.message.includes(errorMsg)
|
||||
)
|
||||
) {
|
||||
Logger.warn(
|
||||
|
||||
+2
-2
@@ -78,7 +78,7 @@ export class Environment {
|
||||
/**
|
||||
* The url of the database.
|
||||
*/
|
||||
@IsNotEmpty()
|
||||
@IsOptional()
|
||||
@IsUrl({
|
||||
require_tld: false,
|
||||
allow_underscores: true,
|
||||
@@ -91,7 +91,7 @@ export class Environment {
|
||||
"DATABASE_USER",
|
||||
"DATABASE_PASSWORD",
|
||||
])
|
||||
public DATABASE_URL = environment.DATABASE_URL ?? "";
|
||||
public DATABASE_URL = this.toOptionalString(environment.DATABASE_URL);
|
||||
|
||||
/**
|
||||
* Database host for individual component configuration.
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
"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,6 +65,9 @@ class GroupUser extends Model<
|
||||
@Column(DataType.UUID)
|
||||
createdById: string;
|
||||
|
||||
@Column(DataType.BOOLEAN)
|
||||
isAdmin: boolean;
|
||||
|
||||
get modelId() {
|
||||
return this.groupId;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ 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 } from "./utils";
|
||||
import { and, isTeamAdmin, isTeamModel, isTeamMutable, isGroupAdmin } from "./utils";
|
||||
|
||||
allow(User, "createGroup", Team, (actor, team) =>
|
||||
and(
|
||||
@@ -26,10 +26,18 @@ allow(User, "read", Group, (actor, team) =>
|
||||
)
|
||||
);
|
||||
|
||||
allow(User, ["update", "delete"], Group, (actor, team) =>
|
||||
allow(User, "update", Group, async (actor, group) => {
|
||||
return and(
|
||||
//
|
||||
await isGroupAdmin(actor, group),
|
||||
isTeamMutable(actor)
|
||||
);
|
||||
});
|
||||
|
||||
allow(User, "delete", Group, (actor, group) =>
|
||||
and(
|
||||
//
|
||||
isTeamAdmin(actor, team),
|
||||
isTeamAdmin(actor, group),
|
||||
isTeamMutable(actor)
|
||||
)
|
||||
);
|
||||
|
||||
@@ -100,3 +100,36 @@ 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,6 +9,7 @@ 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -72,6 +72,35 @@ 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, {
|
||||
@@ -82,7 +111,7 @@ export default class ExportJSONTask extends ExportTask {
|
||||
continue;
|
||||
}
|
||||
|
||||
const attachments = includeAttachments
|
||||
const documentAttachments = includeAttachments
|
||||
? await Attachment.findAll({
|
||||
where: {
|
||||
teamId: document.teamId,
|
||||
@@ -93,32 +122,7 @@ export default class ExportJSONTask extends ExportTask {
|
||||
})
|
||||
: [];
|
||||
|
||||
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,
|
||||
};
|
||||
})
|
||||
);
|
||||
await addAttachments(documentAttachments);
|
||||
|
||||
output.documents[document.id] = {
|
||||
id: document.id,
|
||||
@@ -146,6 +150,19 @@ 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);
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ describe("#groups.update", () => {
|
||||
});
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
|
||||
describe("when user is admin", () => {
|
||||
let user: User, group: Group;
|
||||
beforeEach(async () => {
|
||||
@@ -91,7 +92,53 @@ 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: {
|
||||
@@ -554,6 +601,27 @@ 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);
|
||||
@@ -668,3 +736,112 @@ 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 } = ctx.input.body;
|
||||
const { id, userId, isAdmin } = ctx.input.body;
|
||||
const actor = ctx.state.auth.user;
|
||||
const { transaction } = ctx.state;
|
||||
|
||||
@@ -270,11 +270,17 @@ 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 = {
|
||||
@@ -322,4 +328,46 @@ 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,6 +85,8 @@ 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),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -98,3 +100,14 @@ 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>;
|
||||
|
||||
@@ -120,6 +120,7 @@ export default class Mention extends Node {
|
||||
toPlainText(node),
|
||||
],
|
||||
toPlainText,
|
||||
leafText: toPlainText,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ 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() {
|
||||
@@ -13,7 +14,23 @@ export default class Paragraph extends Node {
|
||||
return {
|
||||
content: "inline*",
|
||||
group: "block",
|
||||
parseDOM: [{ tag: "p" }],
|
||||
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 {};
|
||||
},
|
||||
},
|
||||
],
|
||||
toDOM: () => ["p", { dir: "auto" }, 0],
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user