Initial commit: Plane
Some checks failed
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Admin Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Codespell / Check for spelling errors (push) Has been cancelled
Sync Repositories / sync_changes (push) Has been cancelled

Synced from upstream: 8853637e981ed7d8a6cff32bd98e7afe20f54362
This commit is contained in:
chuan
2025-11-07 00:00:52 +08:00
commit 8ebde8aa05
4886 changed files with 462270 additions and 0 deletions

View File

@@ -0,0 +1,87 @@
import { useEffect, useState } from "react";
import { observer } from "mobx-react";
import { usePathname } from "next/navigation";
import { Globe2, Lock } from "lucide-react";
// plane imports
import type { EditorRefApi } from "@plane/editor";
import { useHashScroll } from "@plane/hooks";
import { EIssueCommentAccessSpecifier } from "@plane/types";
import type { TCommentsOperations, TIssueComment } from "@plane/types";
import { cn } from "@plane/utils";
// components
import { LiteTextEditor } from "@/components/editor/lite-text";
// local imports
import { CommentReactions } from "../comment-reaction";
type Props = {
activityOperations: TCommentsOperations;
comment: TIssueComment;
disabled: boolean;
projectId?: string;
readOnlyEditorRef: React.RefObject<EditorRefApi>;
showAccessSpecifier: boolean;
workspaceId: string;
workspaceSlug: string;
};
export const CommentCardDisplay: React.FC<Props> = observer((props) => {
const {
activityOperations,
comment,
disabled,
projectId,
readOnlyEditorRef,
showAccessSpecifier,
workspaceId,
workspaceSlug,
} = props;
// states
const [highlightClassName, setHighlightClassName] = useState("");
// navigation
const pathname = usePathname();
// derived values
const commentBlockId = `comment-${comment?.id}`;
// scroll to comment
const { isHashMatch } = useHashScroll({
elementId: commentBlockId,
pathname,
});
useEffect(() => {
if (!isHashMatch) return;
setHighlightClassName("border-custom-primary-100");
const timeout = setTimeout(() => {
setHighlightClassName("");
}, 8000);
return () => clearTimeout(timeout);
}, [isHashMatch]);
return (
<div id={commentBlockId} className="relative flex flex-col gap-2">
{showAccessSpecifier && (
<div className="absolute right-2.5 top-2.5 z-[1] text-custom-text-300">
{comment.access === EIssueCommentAccessSpecifier.INTERNAL ? (
<Lock className="size-3" />
) : (
<Globe2 className="size-3" />
)}
</div>
)}
<LiteTextEditor
editable={false}
ref={readOnlyEditorRef}
id={comment.id}
initialValue={comment.comment_html ?? ""}
workspaceId={workspaceId}
workspaceSlug={workspaceSlug}
containerClassName={cn("!py-1 transition-[border-color] duration-500", highlightClassName)}
projectId={projectId?.toString()}
displayConfig={{
fontSize: "small-font",
}}
/>
<CommentReactions comment={comment} disabled={disabled} activityOperations={activityOperations} />
</div>
);
});

View File

@@ -0,0 +1,132 @@
import React, { useEffect, useRef } from "react";
import { observer } from "mobx-react";
import { useForm } from "react-hook-form";
import { Check } from "lucide-react";
import type { EditorRefApi } from "@plane/editor";
import { CloseIcon } from "@plane/propel/icons";
// plane imports
import type { TCommentsOperations, TIssueComment } from "@plane/types";
import { isCommentEmpty } from "@plane/utils";
// components
import { LiteTextEditor } from "@/components/editor/lite-text";
type Props = {
activityOperations: TCommentsOperations;
comment: TIssueComment;
isEditing: boolean;
projectId?: string;
readOnlyEditorRef: EditorRefApi | null;
setIsEditing: (isEditing: boolean) => void;
workspaceId: string;
workspaceSlug: string;
};
export const CommentCardEditForm: React.FC<Props> = observer((props) => {
const {
activityOperations,
comment,
isEditing,
projectId,
readOnlyEditorRef,
setIsEditing,
workspaceId,
workspaceSlug,
} = props;
// refs
const editorRef = useRef<EditorRefApi>(null);
// form info
const {
formState: { isSubmitting },
handleSubmit,
setFocus,
watch,
setValue,
} = useForm<Partial<TIssueComment>>({
defaultValues: { comment_html: comment?.comment_html },
});
const commentHTML = watch("comment_html");
const isEmpty = isCommentEmpty(commentHTML ?? undefined);
const isEditorReadyToDiscard = editorRef.current?.isEditorReadyToDiscard();
const isSubmitButtonDisabled = isSubmitting || !isEditorReadyToDiscard;
const isDisabled = isSubmitting || isEmpty || isSubmitButtonDisabled;
const onEnter = async (formData: Partial<TIssueComment>) => {
if (isSubmitting || !comment) return;
setIsEditing(false);
await activityOperations.updateComment(comment.id, formData);
editorRef.current?.setEditorValue(formData?.comment_html ?? "<p></p>");
readOnlyEditorRef?.setEditorValue(formData?.comment_html ?? "<p></p>");
};
useEffect(() => {
if (isEditing) {
setFocus("comment_html");
}
}, [isEditing, setFocus]);
return (
<form className="flex flex-col gap-2">
<div
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey && !isEmpty) handleSubmit(onEnter)(e);
}}
>
<LiteTextEditor
editable
workspaceId={workspaceId}
workspaceSlug={workspaceSlug}
ref={editorRef}
id={comment.id}
initialValue={commentHTML ?? ""}
value={null}
onChange={(_comment_json, comment_html) => setValue("comment_html", comment_html)}
onEnterKeyPress={(e) => {
if (!isEmpty && !isSubmitting) {
handleSubmit(onEnter)(e);
}
}}
showSubmitButton={false}
uploadFile={async (blockId, file) => {
const { asset_id } = await activityOperations.uploadCommentAsset(blockId, file, comment.id);
return asset_id;
}}
projectId={projectId}
parentClassName="p-2"
displayConfig={{
fontSize: "small-font",
}}
/>
</div>
<div className="flex gap-1 self-end">
{!isEmpty && (
<button
type="button"
onClick={handleSubmit(onEnter)}
disabled={isDisabled}
className={`group rounded border border-green-500 bg-green-500/20 p-2 shadow-md duration-300 ${
isEmpty ? "cursor-not-allowed bg-gray-200" : "hover:bg-green-500"
}`}
>
<Check
className={`h-3 w-3 text-green-500 duration-300 ${isEmpty ? "text-black" : "group-hover:text-white"}`}
/>
</button>
)}
<button
type="button"
className="group rounded border border-red-500 bg-red-500/20 p-2 shadow-md duration-300 hover:bg-red-500"
onClick={() => {
setIsEditing(false);
editorRef.current?.setEditorValue(comment.comment_html ?? "<p></p>");
}}
>
<CloseIcon className="size-3 text-red-500 duration-300 group-hover:text-white" />
</button>
</div>
</form>
);
});

View File

@@ -0,0 +1,88 @@
"use client";
import type { FC } from "react";
import { useRef, useState } from "react";
import { observer } from "mobx-react";
// plane imports
import type { EditorRefApi } from "@plane/editor";
import type { TIssueComment, TCommentsOperations } from "@plane/types";
// plane web imports
import { CommentBlock } from "@/plane-web/components/comments";
// local imports
import { CommentQuickActions } from "../quick-actions";
import { CommentCardDisplay } from "./display";
import { CommentCardEditForm } from "./edit-form";
type TCommentCard = {
workspaceSlug: string;
comment: TIssueComment | undefined;
activityOperations: TCommentsOperations;
ends: "top" | "bottom" | undefined;
showAccessSpecifier: boolean;
showCopyLinkOption: boolean;
disabled?: boolean;
projectId?: string;
};
export const CommentCard: FC<TCommentCard> = observer((props) => {
const {
workspaceSlug,
comment,
activityOperations,
ends,
showAccessSpecifier,
showCopyLinkOption,
disabled = false,
projectId,
} = props;
// states
const [isEditing, setIsEditing] = useState(false);
// refs
const readOnlyEditorRef = useRef<EditorRefApi>(null);
// derived values
const workspaceId = comment?.workspace;
if (!comment || !workspaceId) return null;
return (
<CommentBlock
comment={comment}
quickActions={
!disabled && (
<CommentQuickActions
activityOperations={activityOperations}
comment={comment}
setEditMode={() => setIsEditing(true)}
showAccessSpecifier={showAccessSpecifier}
showCopyLinkOption={showCopyLinkOption}
/>
)
}
ends={ends}
>
{isEditing ? (
<CommentCardEditForm
activityOperations={activityOperations}
comment={comment}
isEditing
readOnlyEditorRef={readOnlyEditorRef.current}
setIsEditing={setIsEditing}
projectId={projectId}
workspaceId={workspaceId}
workspaceSlug={workspaceSlug}
/>
) : (
<CommentCardDisplay
activityOperations={activityOperations}
comment={comment}
disabled={disabled}
projectId={projectId}
readOnlyEditorRef={readOnlyEditorRef}
showAccessSpecifier={showAccessSpecifier}
workspaceId={workspaceId}
workspaceSlug={workspaceSlug}
/>
)}
</CommentBlock>
);
});

View File

@@ -0,0 +1,148 @@
import type { FC } from "react";
import { useRef, useState } from "react";
import { observer } from "mobx-react";
import { useForm, Controller } from "react-hook-form";
// plane imports
import { EIssueCommentAccessSpecifier } from "@plane/constants";
import type { EditorRefApi } from "@plane/editor";
import type { TIssueComment, TCommentsOperations } from "@plane/types";
import { cn, isCommentEmpty } from "@plane/utils";
// components
import { LiteTextEditor } from "@/components/editor/lite-text";
// hooks
import { useWorkspace } from "@/hooks/store/use-workspace";
// services
import { FileService } from "@/services/file.service";
type TCommentCreate = {
entityId: string;
workspaceSlug: string;
activityOperations: TCommentsOperations;
showToolbarInitially?: boolean;
projectId?: string;
onSubmitCallback?: (elementId: string) => void;
};
// services
const fileService = new FileService();
export const CommentCreate: FC<TCommentCreate> = observer((props) => {
const {
workspaceSlug,
entityId,
activityOperations,
showToolbarInitially = false,
projectId,
onSubmitCallback,
} = props;
// states
const [uploadedAssetIds, setUploadedAssetIds] = useState<string[]>([]);
// refs
const editorRef = useRef<EditorRefApi>(null);
// store hooks
const workspaceStore = useWorkspace();
// derived values
const workspaceId = workspaceStore.getWorkspaceBySlug(workspaceSlug as string)?.id as string;
// form info
const {
handleSubmit,
control,
watch,
formState: { isSubmitting },
reset,
} = useForm<Partial<TIssueComment>>({
defaultValues: {
comment_html: "<p></p>",
},
});
const onSubmit = async (formData: Partial<TIssueComment>) => {
try {
const comment = await activityOperations.createComment(formData);
if (comment?.id) onSubmitCallback?.(comment.id);
if (uploadedAssetIds.length > 0) {
if (projectId) {
await fileService.updateBulkProjectAssetsUploadStatus(workspaceSlug, projectId.toString(), entityId, {
asset_ids: uploadedAssetIds,
});
} else {
await fileService.updateBulkWorkspaceAssetsUploadStatus(workspaceSlug, entityId, {
asset_ids: uploadedAssetIds,
});
}
setUploadedAssetIds([]);
}
} catch (error) {
console.error(error);
} finally {
reset({
comment_html: "<p></p>",
});
editorRef.current?.clearEditor();
}
};
const commentHTML = watch("comment_html");
const isEmpty = isCommentEmpty(commentHTML ?? undefined);
return (
<div
className={cn("sticky bottom-0 z-[4] bg-custom-background-100 sm:static")}
onKeyDown={(e) => {
if (
e.key === "Enter" &&
!e.shiftKey &&
!e.ctrlKey &&
!e.metaKey &&
!isEmpty &&
!isSubmitting &&
editorRef.current?.isEditorReadyToDiscard()
)
handleSubmit(onSubmit)(e);
}}
>
<Controller
name="access"
control={control}
render={({ field: { onChange: onAccessChange, value: accessValue } }) => (
<Controller
name="comment_html"
control={control}
render={({ field: { value, onChange } }) => (
<LiteTextEditor
editable
workspaceId={workspaceId}
id={"add_comment_" + entityId}
value={"<p></p>"}
workspaceSlug={workspaceSlug}
projectId={projectId}
onEnterKeyPress={(e) => {
if (!isEmpty && !isSubmitting) {
handleSubmit(onSubmit)(e);
}
}}
ref={editorRef}
initialValue={value ?? "<p></p>"}
containerClassName="min-h-min"
onChange={(comment_json, comment_html) => onChange(comment_html)}
accessSpecifier={accessValue ?? EIssueCommentAccessSpecifier.INTERNAL}
handleAccessChange={onAccessChange}
isSubmitting={isSubmitting}
uploadFile={async (blockId, file) => {
const { asset_id } = await activityOperations.uploadCommentAsset(blockId, file);
setUploadedAssetIds((prev) => [...prev, asset_id]);
return asset_id;
}}
showToolbarInitially={showToolbarInitially}
parentClassName="p-2"
displayConfig={{
fontSize: "small-font",
}}
/>
)}
/>
)}
/>
</div>
);
});

View File

@@ -0,0 +1,85 @@
"use client";
import type { FC } from "react";
import { useMemo, useState } from "react";
import { observer } from "mobx-react";
// plane imports
import { stringToEmoji } from "@plane/propel/emoji-icon-picker";
import { EmojiReactionGroup, EmojiReactionPicker } from "@plane/propel/emoji-reaction";
import type { EmojiReactionType } from "@plane/propel/emoji-reaction";
import type { TCommentsOperations, TIssueComment } from "@plane/types";
import { cn } from "@plane/utils";
// helpers
// local imports
export type TProps = {
comment: TIssueComment;
disabled?: boolean;
activityOperations: TCommentsOperations;
};
export const CommentReactions: FC<TProps> = observer((props) => {
const { comment, activityOperations, disabled = false } = props;
// state
const [isPickerOpen, setIsPickerOpen] = useState(false);
const userReactions = activityOperations.userReactions(comment.id);
const reactionIds = activityOperations.reactionIds(comment.id);
// Transform reactions data to Propel EmojiReactionType format
const reactions: EmojiReactionType[] = useMemo(() => {
if (!reactionIds) return [];
return Object.keys(reactionIds)
.filter((reaction) => reactionIds[reaction]?.length > 0)
.map((reaction) => {
// Get user names for this reaction
const tooltipContent = activityOperations.getReactionUsers(reaction, reactionIds);
// Parse the tooltip content string to extract user names
const users = tooltipContent ? tooltipContent.split(", ") : [];
return {
emoji: stringToEmoji(reaction),
count: reactionIds[reaction].length,
reacted: userReactions?.includes(reaction) || false,
users: users,
};
});
}, [reactionIds, userReactions, activityOperations]);
const handleReactionClick = (emoji: string) => {
if (disabled || !userReactions) return;
// Convert emoji back to decimal string format for the API
const emojiCodePoints = Array.from(emoji).map((char) => char.codePointAt(0));
const reactionString = emojiCodePoints.join("-");
activityOperations.react(comment.id, reactionString, userReactions);
};
const handleEmojiSelect = (emoji: string) => {
if (!userReactions) return;
// emoji is already in decimal string format from EmojiReactionPicker
activityOperations.react(comment.id, emoji, userReactions);
};
if (!userReactions) return null;
return (
<div className="relative">
<EmojiReactionPicker
isOpen={isPickerOpen}
handleToggle={setIsPickerOpen}
onChange={handleEmojiSelect}
disabled={disabled}
label={
<EmojiReactionGroup
reactions={reactions}
onReactionClick={handleReactionClick}
showAddButton={!disabled}
onAddReaction={() => setIsPickerOpen(true)}
/>
}
placement="bottom-start"
/>
</div>
);
});

View File

@@ -0,0 +1,83 @@
"use client";
import type { FC } from "react";
import React, { useMemo } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import type { E_SORT_ORDER } from "@plane/constants";
import type { TCommentsOperations, TIssueComment } from "@plane/types";
// local components
import { CommentCard } from "./card/root";
import { CommentCreate } from "./comment-create";
type TCommentsWrapper = {
projectId?: string;
entityId: string;
isEditingAllowed?: boolean;
activityOperations: TCommentsOperations;
comments: TIssueComment[] | string[];
sortOrder?: E_SORT_ORDER;
getCommentById?: (activityId: string) => TIssueComment | undefined;
showAccessSpecifier?: boolean;
showCopyLinkOption?: boolean;
};
export const CommentsWrapper: FC<TCommentsWrapper> = observer((props) => {
const {
entityId,
activityOperations,
comments,
getCommentById,
isEditingAllowed = true,
projectId,
showAccessSpecifier = false,
showCopyLinkOption = false,
} = props;
// router
const { workspaceSlug: routerWorkspaceSlug } = useParams();
const workspaceSlug = routerWorkspaceSlug?.toString();
const renderCommentCreate = useMemo(
() =>
isEditingAllowed && (
<CommentCreate
workspaceSlug={workspaceSlug}
entityId={entityId}
activityOperations={activityOperations}
projectId={projectId}
/>
),
[isEditingAllowed, workspaceSlug, entityId, activityOperations, projectId]
);
return (
<div className="relative flex flex-col gap-y-2 h-full overflow-hidden">
{renderCommentCreate}
<div className="flex-grow py-4 overflow-y-auto">
{comments?.map((data, index) => {
let comment;
if (typeof data === "string") {
comment = getCommentById?.(data);
} else {
comment = data;
}
if (!comment) return null;
return (
<CommentCard
key={comment.id}
workspaceSlug={workspaceSlug}
comment={comment as TIssueComment}
activityOperations={activityOperations}
disabled={!isEditingAllowed}
ends={index === 0 ? "top" : index === comments.length - 1 ? "bottom" : undefined}
projectId={projectId}
showAccessSpecifier={showAccessSpecifier}
showCopyLinkOption={showCopyLinkOption}
/>
);
})}
</div>
</div>
);
});

View File

@@ -0,0 +1 @@
export * from "./comments";

View File

@@ -0,0 +1,115 @@
"use client";
import type { FC } from "react";
import { useMemo } from "react";
import { observer } from "mobx-react";
import { Globe2, Link, Lock, Pencil, Trash2 } from "lucide-react";
// plane imports
import { EIssueCommentAccessSpecifier } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import type { TIssueComment, TCommentsOperations } from "@plane/types";
import type { TContextMenuItem } from "@plane/ui";
import { CustomMenu } from "@plane/ui";
import { cn } from "@plane/utils";
// hooks
import { useUser } from "@/hooks/store/user";
type TCommentCard = {
activityOperations: TCommentsOperations;
comment: TIssueComment;
setEditMode: () => void;
showAccessSpecifier: boolean;
showCopyLinkOption: boolean;
};
export const CommentQuickActions: FC<TCommentCard> = observer((props) => {
const { activityOperations, comment, setEditMode, showAccessSpecifier, showCopyLinkOption } = props;
// store hooks
const { data: currentUser } = useUser();
// derived values
const isAuthor = currentUser?.id === comment.actor;
const canEdit = isAuthor;
const canDelete = isAuthor;
// translation
const { t } = useTranslation();
const MENU_ITEMS: TContextMenuItem[] = useMemo(
() => [
{
key: "edit",
action: setEditMode,
title: t("common.actions.edit"),
icon: Pencil,
shouldRender: canEdit,
},
{
key: "copy_link",
action: () => activityOperations.copyCommentLink(comment.id),
title: t("common.actions.copy_link"),
icon: Link,
shouldRender: showCopyLinkOption,
},
{
key: "access_specifier",
action: () =>
activityOperations.updateComment(comment.id, {
access:
comment.access === EIssueCommentAccessSpecifier.INTERNAL
? EIssueCommentAccessSpecifier.EXTERNAL
: EIssueCommentAccessSpecifier.INTERNAL,
}),
title:
comment.access === EIssueCommentAccessSpecifier.INTERNAL
? t("issue.comments.switch.public")
: t("issue.comments.switch.private"),
icon: comment.access === EIssueCommentAccessSpecifier.INTERNAL ? Globe2 : Lock,
shouldRender: showAccessSpecifier,
},
{
key: "delete",
action: () => activityOperations.removeComment(comment.id),
title: t("common.actions.delete"),
icon: Trash2,
shouldRender: canDelete,
},
],
[activityOperations, canDelete, canEdit, comment, setEditMode, showAccessSpecifier, showCopyLinkOption]
);
return (
<CustomMenu ellipsis closeOnSelect>
{MENU_ITEMS.map((item) => {
if (item.shouldRender === false) return null;
return (
<CustomMenu.MenuItem
key={item.key}
onClick={() => item.action()}
className={cn(
"flex items-center gap-2",
{
"text-custom-text-400": item.disabled,
},
item.className
)}
disabled={item.disabled}
>
{item.icon && <item.icon className={cn("shrink-0 size-3", item.iconClassName)} />}
<div>
<h5>{item.title}</h5>
{item.description && (
<p
className={cn("text-custom-text-300 whitespace-pre-line", {
"text-custom-text-400": item.disabled,
})}
>
{item.description}
</p>
)}
</div>
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
);
});