[PE-242, 243] refactor: editor file handling, image upload status (#6442)

* refactor: editor file handling

* refactor: asset store

* refactor: space app file handlers

* fix: separate webhook connection params

* chore: handle undefined status

* chore: add type to upload status

* chore: added transition for upload status update
This commit is contained in:
Aaryan Khandelwal 2025-02-19 15:18:01 +05:30 committed by GitHub
parent b7198234de
commit 214692f5b2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
53 changed files with 602 additions and 315 deletions

View file

@ -6,12 +6,15 @@ import { Controller, useForm } from "react-hook-form"; // services
import { usePopper } from "react-popper";
import { AlertCircle } from "lucide-react";
import { Popover, Transition } from "@headlessui/react";
// plane editor
import { EditorReadOnlyRefApi } from "@plane/editor";
// ui
import { Button, Input, TOAST_TYPE, setToast } from "@plane/ui";
// components
import { RichTextReadOnlyEditor } from "@/components/editor/rich-text-editor/rich-text-read-only-editor";
// services
import { AIService } from "@/services/ai.service";
const aiService = new AIService();
type Props = {
isOpen: boolean;
@ -22,6 +25,7 @@ type Props = {
prompt?: string;
button: JSX.Element;
className?: string;
workspaceId: string;
workspaceSlug: string;
projectId: string;
};
@ -31,8 +35,6 @@ type FormData = {
task: string;
};
const aiService = new AIService();
export const GptAssistantPopover: React.FC<Props> = (props) => {
const {
isOpen,
@ -43,6 +45,7 @@ export const GptAssistantPopover: React.FC<Props> = (props) => {
prompt,
button,
className = "",
workspaceId,
workspaceSlug,
projectId,
} = props;
@ -51,7 +54,8 @@ export const GptAssistantPopover: React.FC<Props> = (props) => {
const [invalidResponse, setInvalidResponse] = useState(false);
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
const editorRef = useRef<any>(null);
// refs
const editorRef = useRef<EditorReadOnlyRefApi>(null);
const responseRef = useRef<any>(null);
// popper
const { styles, attributes } = usePopper(referenceElement, popperElement, {
@ -218,6 +222,7 @@ export const GptAssistantPopover: React.FC<Props> = (props) => {
initialValue={prompt}
containerClassName="-m-3"
ref={editorRef}
workspaceId={workspaceId}
workspaceSlug={workspaceSlug}
projectId={projectId}
/>
@ -230,6 +235,7 @@ export const GptAssistantPopover: React.FC<Props> = (props) => {
id="ai-assistant-response"
initialValue={`<p>${response}</p>`}
ref={responseRef}
workspaceId={workspaceId}
workspaceSlug={workspaceSlug}
projectId={projectId}
/>

View file

@ -2,20 +2,18 @@ import React, { useState } from "react";
// plane constants
import { EIssueCommentAccessSpecifier } from "@plane/constants";
// plane editor
import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef } from "@plane/editor";
import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef, TFileHandler } from "@plane/editor";
// i18n
import { useTranslation } from "@plane/i18n";
// components
import { EditorMentionsRoot, IssueCommentToolbar } from "@/components/editor";
// helpers
import { cn } from "@/helpers/common.helper";
import { getEditorFileHandlers } from "@/helpers/editor.helper";
import { isCommentEmpty } from "@/helpers/string.helper";
// hooks
import { useEditorMention } from "@/hooks/use-editor-mention";
import { useEditorConfig, useEditorMention } from "@/hooks/editor";
// plane web hooks
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
import { useFileSize } from "@/plane-web/hooks/use-file-size";
// plane web services
import { WorkspaceService } from "@/plane-web/services";
const workspaceService = new WorkspaceService();
@ -31,7 +29,7 @@ interface LiteTextEditorWrapperProps
showSubmitButton?: boolean;
isSubmitting?: boolean;
showToolbarInitially?: boolean;
uploadFile: (file: File) => Promise<string>;
uploadFile: TFileHandler["upload"];
issue_id?: string;
}
@ -66,8 +64,8 @@ export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapp
issue_id: issue_id,
}),
});
// file size
const { maxFileSize } = useFileSize();
// editor config
const { getEditorFileHandlers } = useEditorConfig();
function isMutableRefObject<T>(ref: React.ForwardedRef<T>): ref is React.MutableRefObject<T | null> {
return !!ref && typeof ref === "object" && "current" in ref;
}
@ -85,7 +83,6 @@ export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapp
ref={ref}
disabledExtensions={disabledExtensions}
fileHandler={getEditorFileHandlers({
maxFileSize,
projectId,
uploadFile,
workspaceId,

View file

@ -5,7 +5,8 @@ import { EditorReadOnlyRefApi, ILiteTextReadOnlyEditor, LiteTextReadOnlyEditorWi
import { EditorMentionsRoot } from "@/components/editor";
// helpers
import { cn } from "@/helpers/common.helper";
import { getReadOnlyEditorFileHandlers } from "@/helpers/editor.helper";
// hooks
import { useEditorConfig } from "@/hooks/editor";
// plane web hooks
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
@ -13,14 +14,17 @@ type LiteTextReadOnlyEditorWrapperProps = Omit<
ILiteTextReadOnlyEditor,
"disabledExtensions" | "fileHandler" | "mentionHandler"
> & {
workspaceId: string;
workspaceSlug: string;
projectId: string;
};
export const LiteTextReadOnlyEditor = React.forwardRef<EditorReadOnlyRefApi, LiteTextReadOnlyEditorWrapperProps>(
({ workspaceSlug, projectId, ...props }, ref) => {
({ workspaceId, workspaceSlug, projectId, ...props }, ref) => {
// editor flaggings
const { liteTextEditor: disabledExtensions } = useEditorFlagging(workspaceSlug?.toString());
// editor config
const { getReadOnlyEditorFileHandlers } = useEditorConfig();
return (
<LiteTextReadOnlyEditorWithRef
@ -28,6 +32,7 @@ export const LiteTextReadOnlyEditor = React.forwardRef<EditorReadOnlyRefApi, Lit
disabledExtensions={disabledExtensions}
fileHandler={getReadOnlyEditorFileHandlers({
projectId,
workspaceId,
workspaceSlug,
})}
mentionHandler={{

View file

@ -1,18 +1,16 @@
import React, { forwardRef } from "react";
// editor
import { EditorRefApi, IRichTextEditor, RichTextEditorWithRef } from "@plane/editor";
import { EditorRefApi, IRichTextEditor, RichTextEditorWithRef, TFileHandler } from "@plane/editor";
// plane types
import { TSearchEntityRequestPayload, TSearchResponse } from "@plane/types";
// components
import { EditorMentionsRoot } from "@/components/editor";
// helpers
import { cn } from "@/helpers/common.helper";
import { getEditorFileHandlers } from "@/helpers/editor.helper";
// hooks
import { useEditorMention } from "@/hooks/use-editor-mention";
import { useEditorConfig, useEditorMention } from "@/hooks/editor";
// plane web hooks
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
import { useFileSize } from "@/plane-web/hooks/use-file-size";
interface RichTextEditorWrapperProps
extends Omit<IRichTextEditor, "disabledExtensions" | "fileHandler" | "mentionHandler"> {
@ -20,7 +18,7 @@ interface RichTextEditorWrapperProps
workspaceSlug: string;
workspaceId: string;
projectId?: string;
uploadFile: (file: File) => Promise<string>;
uploadFile: TFileHandler["upload"];
}
export const RichTextEditor = forwardRef<EditorRefApi, RichTextEditorWrapperProps>((props, ref) => {
@ -32,15 +30,14 @@ export const RichTextEditor = forwardRef<EditorRefApi, RichTextEditorWrapperProp
const { fetchMentions } = useEditorMention({
searchEntity: async (payload) => await searchMentionCallback(payload),
});
// file size
const { maxFileSize } = useFileSize();
// editor config
const { getEditorFileHandlers } = useEditorConfig();
return (
<RichTextEditorWithRef
ref={ref}
disabledExtensions={disabledExtensions}
fileHandler={getEditorFileHandlers({
maxFileSize,
projectId,
uploadFile,
workspaceId,

View file

@ -5,7 +5,8 @@ import { EditorReadOnlyRefApi, IRichTextReadOnlyEditor, RichTextReadOnlyEditorWi
import { EditorMentionsRoot } from "@/components/editor";
// helpers
import { cn } from "@/helpers/common.helper";
import { getReadOnlyEditorFileHandlers } from "@/helpers/editor.helper";
// hooks
import { useEditorConfig } from "@/hooks/editor";
// plane web hooks
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
@ -13,14 +14,17 @@ type RichTextReadOnlyEditorWrapperProps = Omit<
IRichTextReadOnlyEditor,
"disabledExtensions" | "fileHandler" | "mentionHandler"
> & {
workspaceId: string;
workspaceSlug: string;
projectId?: string;
};
export const RichTextReadOnlyEditor = React.forwardRef<EditorReadOnlyRefApi, RichTextReadOnlyEditorWrapperProps>(
({ workspaceSlug, projectId, ...props }, ref) => {
({ workspaceId, workspaceSlug, projectId, ...props }, ref) => {
// editor flaggings
const { richTextEditor: disabledExtensions } = useEditorFlagging(workspaceSlug?.toString());
// editor config
const { getReadOnlyEditorFileHandlers } = useEditorConfig();
return (
<RichTextReadOnlyEditorWithRef
@ -28,6 +32,7 @@ export const RichTextReadOnlyEditor = React.forwardRef<EditorReadOnlyRefApi, Ric
disabledExtensions={disabledExtensions}
fileHandler={getReadOnlyEditorFileHandlers({
projectId,
workspaceId,
workspaceSlug,
})}
mentionHandler={{

View file

@ -2,15 +2,15 @@ import React, { useState } from "react";
// plane constants
import { EIssueCommentAccessSpecifier } from "@plane/constants";
// plane editor
import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef } from "@plane/editor";
// plane types
import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef, TFileHandler } from "@plane/editor";
// components
import { TSticky } from "@plane/types";
// helpers
import { cn } from "@/helpers/common.helper";
import { getEditorFileHandlers } from "@/helpers/editor.helper";
// hooks
import { useEditorConfig } from "@/hooks/editor";
// plane web hooks
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
import { useFileSize } from "@/plane-web/hooks/use-file-size";
import { StickyEditorToolbar } from "./toolbar";
interface StickyEditorWrapperProps
@ -25,7 +25,7 @@ interface StickyEditorWrapperProps
isSubmitting?: boolean;
showToolbarInitially?: boolean;
showToolbar?: boolean;
uploadFile: (file: File) => Promise<string>;
uploadFile: TFileHandler["upload"];
parentClassName?: string;
handleColorChange: (data: Partial<TSticky>) => Promise<void>;
handleDelete: () => void;
@ -49,8 +49,8 @@ export const StickyEditor = React.forwardRef<EditorRefApi, StickyEditorWrapperPr
const [isFocused, setIsFocused] = useState(showToolbarInitially);
// editor flaggings
const { liteTextEditor: disabledExtensions } = useEditorFlagging(workspaceSlug?.toString());
// file size
const { maxFileSize } = useFileSize();
// editor config
const { getEditorFileHandlers } = useEditorConfig();
function isMutableRefObject<T>(ref: React.ForwardedRef<T>): ref is React.MutableRefObject<T | null> {
return !!ref && typeof ref === "object" && "current" in ref;
}
@ -67,7 +67,6 @@ export const StickyEditor = React.forwardRef<EditorRefApi, StickyEditorWrapperPr
ref={ref}
disabledExtensions={[...disabledExtensions, "enter-key"]}
fileHandler={getEditorFileHandlers({
maxFileSize,
projectId,
uploadFile,
workspaceId,

View file

@ -19,13 +19,10 @@ import { RichTextEditor } from "@/components/editor/rich-text-editor/rich-text-e
import { getDescriptionPlaceholderI18n } from "@/helpers/issue.helper";
import { getTabIndex } from "@/helpers/tab-indices.helper";
// hooks
import { useProjectInbox } from "@/hooks/store";
import { useEditorAsset, useProjectInbox } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web services
import { WorkspaceService } from "@/plane-web/services";
// services
import { FileService } from "@/services/file.service";
const fileService = new FileService();
const workspaceService = new WorkspaceService();
type TInboxIssueDescription = {
@ -53,11 +50,10 @@ export const InboxIssueDescription: FC<TInboxIssueDescription> = observer((props
onEnterKeyPress,
onAssetUpload,
} = props;
// i18n
const { t } = useTranslation();
// hooks
// store hooks
const { uploadEditorAsset } = useEditorAsset();
const { loader } = useProjectInbox();
const { isMobile } = usePlatformOS();
@ -90,17 +86,18 @@ export const InboxIssueDescription: FC<TInboxIssueDescription> = observer((props
containerClassName={containerClassName}
onEnterKeyPress={onEnterKeyPress}
tabIndex={getIndex("description_html")}
uploadFile={async (file) => {
uploadFile={async (blockId, file) => {
try {
const { asset_id } = await fileService.uploadProjectAsset(
workspaceSlug,
projectId,
{
const { asset_id } = await uploadEditorAsset({
blockId,
data: {
entity_identifier: data.id ?? "",
entity_type: EFileAssetType.ISSUE_DESCRIPTION,
},
file
);
file,
projectId,
workspaceSlug,
});
onAssetUpload?.(asset_id);
return asset_id;
} catch (error) {

View file

@ -17,13 +17,10 @@ import { TIssueOperations } from "@/components/issues/issue-detail";
// helpers
import { getDescriptionPlaceholderI18n } from "@/helpers/issue.helper";
// hooks
import { useWorkspace } from "@/hooks/store";
import { useEditorAsset, useWorkspace } from "@/hooks/store";
// plane web services
import { WorkspaceService } from "@/plane-web/services";
// services
import { FileService } from "@/services/file.service";
const workspaceService = new WorkspaceService();
const fileService = new FileService();
export type IssueDescriptionInputProps = {
containerClassName?: string;
@ -51,6 +48,14 @@ export const IssueDescriptionInput: FC<IssueDescriptionInputProps> = observer((p
setIsSubmitting,
placeholder,
} = props;
// states
const [localIssueDescription, setLocalIssueDescription] = useState({
id: issueId,
description_html: initialValue,
});
// store hooks
const { uploadEditorAsset } = useEditorAsset();
// form info
// i18n
const { t } = useTranslation();
@ -61,11 +66,6 @@ export const IssueDescriptionInput: FC<IssueDescriptionInputProps> = observer((p
},
});
const [localIssueDescription, setLocalIssueDescription] = useState({
id: issueId,
description_html: initialValue,
});
const handleDescriptionFormSubmit = useCallback(
async (formData: Partial<TIssue>) => {
await issueOperations.update(workspaceSlug, projectId, issueId, {
@ -136,17 +136,18 @@ export const IssueDescriptionInput: FC<IssueDescriptionInputProps> = observer((p
})
}
containerClassName={containerClassName}
uploadFile={async (file) => {
uploadFile={async (blockId, file) => {
try {
const { asset_id } = await fileService.uploadProjectAsset(
workspaceSlug,
projectId,
{
const { asset_id } = await uploadEditorAsset({
blockId,
data: {
entity_identifier: issueId,
entity_type: EFileAssetType.ISSUE_DESCRIPTION,
},
file
);
file,
projectId,
workspaceSlug,
});
return asset_id;
} catch (error) {
console.log("Error in uploading work item asset:", error);
@ -159,6 +160,7 @@ export const IssueDescriptionInput: FC<IssueDescriptionInputProps> = observer((p
id={issueId}
initialValue={localIssueDescription.description_html ?? ""}
containerClassName={containerClassName}
workspaceId={workspaceId}
workspaceSlug={workspaceSlug}
projectId={projectId}
/>

View file

@ -170,8 +170,8 @@ export const IssueCommentCard: FC<TIssueCommentCard> = observer((props) => {
}
}}
showSubmitButton={false}
uploadFile={async (file) => {
const { asset_id } = await activityOperations.uploadCommentAsset(file, comment.id);
uploadFile={async (blockId, file) => {
const { asset_id } = await activityOperations.uploadCommentAsset(blockId, file, comment.id);
return asset_id;
}}
/>
@ -215,6 +215,7 @@ export const IssueCommentCard: FC<TIssueCommentCard> = observer((props) => {
ref={showEditorRef}
id={comment.id}
initialValue={comment.comment_html ?? ""}
workspaceId={workspaceId}
workspaceSlug={workspaceSlug}
projectId={projectId}
/>

View file

@ -110,8 +110,8 @@ export const IssueCommentCreate: FC<TIssueCommentCreate> = (props) => {
handleAccessChange={onAccessChange}
showAccessSpecifier={showAccessSpecifier}
isSubmitting={isSubmitting}
uploadFile={async (file) => {
const { asset_id } = await activityOperations.uploadCommentAsset(file);
uploadFile={async (blockId, file) => {
const { asset_id } = await activityOperations.uploadCommentAsset(blockId, file);
setUploadedAssetIds((prev) => [...prev, asset_id]);
return asset_id;
}}

View file

@ -3,7 +3,7 @@
import { FC, useMemo } from "react";
import { observer } from "mobx-react";
// plane package imports
import { E_SORT_ORDER, TActivityFilters, defaultActivityFilters,EUserPermissions } from "@plane/constants";
import { E_SORT_ORDER, TActivityFilters, defaultActivityFilters, EUserPermissions } from "@plane/constants";
import { useLocalStorage } from "@plane/hooks";
// i18n
import { useTranslation } from "@plane/i18n";
@ -16,12 +16,9 @@ import { IssueCommentCreate } from "@/components/issues";
import { ActivitySortRoot, IssueActivityCommentRoot } from "@/components/issues/issue-detail";
// constants
// hooks
import { useIssueDetail, useProject, useUser, useUserPermissions } from "@/hooks/store";
import { useEditorAsset, useIssueDetail, useProject, useUser, useUserPermissions } from "@/hooks/store";
// plane web components
import { ActivityFilterRoot, IssueActivityWorklogCreateButton } from "@/plane-web/components/issues/worklog";
// services
import { FileService } from "@/services/file.service";
const fileService = new FileService();
type TIssueActivity = {
workspaceSlug: string;
@ -35,7 +32,7 @@ export type TActivityOperations = {
createComment: (data: Partial<TIssueComment>) => Promise<TIssueComment>;
updateComment: (commentId: string, data: Partial<TIssueComment>) => Promise<void>;
removeComment: (commentId: string) => Promise<void>;
uploadCommentAsset: (file: File, commentId?: string) => Promise<TFileSignedURLResponse>;
uploadCommentAsset: (blockId: string, file: File, commentId?: string) => Promise<TFileSignedURLResponse>;
};
export const IssueActivity: FC<TIssueActivity> = observer((props) => {
@ -48,6 +45,7 @@ export const IssueActivity: FC<TIssueActivity> = observer((props) => {
defaultActivityFilters
);
const { setValue: setSortOrder, storedValue: sortOrder } = useLocalStorage("activity_sort_order", E_SORT_ORDER.ASC);
// store hooks
const {
issue: { getIssueById },
createComment,
@ -57,7 +55,8 @@ export const IssueActivity: FC<TIssueActivity> = observer((props) => {
const { projectPermissionsByWorkspaceSlugAndProjectId } = useUserPermissions();
const { getProjectById } = useProject();
const { data: currentUser } = useUser();
//derived values
const { uploadEditorAsset } = useEditorAsset();
// derived values
const issue = issueId ? getIssueById(issueId) : undefined;
const currentUserProjectRole = projectPermissionsByWorkspaceSlugAndProjectId(workspaceSlug, projectId);
const isAdmin = (currentUserProjectRole ?? EUserPermissions.GUEST) === EUserPermissions.ADMIN;
@ -94,7 +93,7 @@ export const IssueActivity: FC<TIssueActivity> = observer((props) => {
message: t("issue.comments.create.success"),
});
return comment;
} catch (error) {
} catch {
setToast({
title: t("common.error.label"),
type: TOAST_TYPE.ERROR,
@ -111,7 +110,7 @@ export const IssueActivity: FC<TIssueActivity> = observer((props) => {
type: TOAST_TYPE.SUCCESS,
message: t("issue.comments.update.success"),
});
} catch (error) {
} catch {
setToast({
title: t("common.error.label"),
type: TOAST_TYPE.ERROR,
@ -128,7 +127,7 @@ export const IssueActivity: FC<TIssueActivity> = observer((props) => {
type: TOAST_TYPE.SUCCESS,
message: t("issue.comments.remove.success"),
});
} catch (error) {
} catch {
setToast({
title: t("common.error.label"),
type: TOAST_TYPE.ERROR,
@ -136,18 +135,19 @@ export const IssueActivity: FC<TIssueActivity> = observer((props) => {
});
}
},
uploadCommentAsset: async (file, commentId) => {
uploadCommentAsset: async (blockId, file, commentId) => {
try {
if (!workspaceSlug || !projectId) throw new Error("Missing fields");
const res = await fileService.uploadProjectAsset(
workspaceSlug,
projectId,
{
const res = await uploadEditorAsset({
blockId,
data: {
entity_identifier: commentId ?? "",
entity_type: EFileAssetType.COMMENT_DESCRIPTION,
},
file
);
file,
projectId,
workspaceSlug,
});
return res;
} catch (error) {
console.log("Error in uploading comment asset:", error);
@ -155,7 +155,7 @@ export const IssueActivity: FC<TIssueActivity> = observer((props) => {
}
},
}),
[workspaceSlug, projectId, issueId, createComment, updateComment, removeComment]
[workspaceSlug, projectId, issueId, createComment, updateComment, uploadEditorAsset, removeComment]
);
const project = getProjectById(projectId);

View file

@ -22,14 +22,15 @@ import { RichTextEditor } from "@/components/editor";
import { getDescriptionPlaceholderI18n } from "@/helpers/issue.helper";
import { getTabIndex } from "@/helpers/tab-indices.helper";
// hooks
import { useInstance, useWorkspace } from "@/hooks/store";
import { useEditorAsset, useInstance, useWorkspace } from "@/hooks/store";
import useKeypress from "@/hooks/use-keypress";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web services
import { WorkspaceService } from "@/plane-web/services";
// services
import { AIService } from "@/services/ai.service";
import { FileService } from "@/services/file.service";
const workspaceService = new WorkspaceService();
const aiService = new AIService();
type TIssueDescriptionEditorProps = {
control: Control<TIssue>;
@ -50,11 +51,6 @@ type TIssueDescriptionEditorProps = {
onClose: () => void;
};
// services
const workspaceService = new WorkspaceService();
const aiService = new AIService();
const fileService = new FileService();
export const IssueDescriptionEditor: React.FC<TIssueDescriptionEditorProps> = observer((props) => {
const {
control,
@ -80,8 +76,10 @@ export const IssueDescriptionEditor: React.FC<TIssueDescriptionEditorProps> = ob
const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false);
// store hooks
const { getWorkspaceBySlug } = useWorkspace();
const workspaceId = getWorkspaceBySlug(workspaceSlug?.toString())?.id as string;
const workspaceId = getWorkspaceBySlug(workspaceSlug?.toString())?.id ?? "";
const { config } = useInstance();
const { uploadEditorAsset } = useEditorAsset();
// platform
const { isMobile } = usePlatformOS();
const { getIndex } = getTabIndex(ETabIndices.ISSUE_FORM, isMobile);
@ -202,19 +200,20 @@ export const IssueDescriptionEditor: React.FC<TIssueDescriptionEditorProps> = ob
})
}
containerClassName="pt-3 min-h-[120px]"
uploadFile={async (file) => {
uploadFile={async (blockId, file) => {
try {
const { asset_id } = await fileService.uploadProjectAsset(
workspaceSlug,
projectId,
{
const { asset_id } = await uploadEditorAsset({
blockId,
data: {
entity_identifier: issueId ?? "",
entity_type: isDraft
? EFileAssetType.DRAFT_ISSUE_DESCRIPTION
: EFileAssetType.ISSUE_DESCRIPTION,
},
file
);
file,
projectId,
workspaceSlug,
});
onAssetUpload(asset_id);
return asset_id;
} catch (error) {
@ -268,6 +267,7 @@ export const IssueDescriptionEditor: React.FC<TIssueDescriptionEditorProps> = ob
AI
</button>
}
workspaceId={workspaceId}
workspaceSlug={workspaceSlug}
projectId={projectId}
/>

View file

@ -21,8 +21,8 @@ import { PageContentBrowser, PageContentLoader, PageEditorTitle } from "@/compon
import { cn, LIVE_BASE_PATH, LIVE_BASE_URL } from "@/helpers/common.helper";
import { generateRandomColor } from "@/helpers/string.helper";
// hooks
import { useUser } from "@/hooks/store";
import { useEditorMention } from "@/hooks/use-editor-mention";
import { useEditorMention } from "@/hooks/editor";
import { useUser, useWorkspace } from "@/hooks/store";
import { usePageFilters } from "@/hooks/use-page-filters";
// plane web components
import { EditorAIMenu } from "@/plane-web/components/pages";
@ -34,7 +34,6 @@ import { TPageInstance } from "@/store/pages/base-page";
export type TEditorBodyConfig = {
fileHandler: TFileHandler;
webhookConnectionParams: TWebhookConnectionQueryParams;
};
export type TEditorBodyHandlers = {
@ -50,6 +49,7 @@ type Props = {
handlers: TEditorBodyHandlers;
page: TPageInstance;
sidePeekVisible: boolean;
webhookConnectionParams: TWebhookConnectionQueryParams;
workspaceSlug: string;
};
@ -62,12 +62,15 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
handlers,
page,
sidePeekVisible,
webhookConnectionParams,
workspaceSlug,
} = props;
// store hooks
const { data: currentUser } = useUser();
const { getWorkspaceBySlug } = useWorkspace();
// derived values
const { id: pageId, name: pageTitle, isContentEditable, updateTitle } = page;
const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id ?? "";
// issue-embed
const { issueEmbedProps } = useIssueEmbed({
fetchEmbedSuggestions: handlers.fetchEntity,
@ -96,10 +99,11 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
editorRef={editorRef}
isOpen={isOpen}
onClose={onClose}
workspaceId={workspaceId}
workspaceSlug={workspaceSlug?.toString() ?? ""}
/>
),
[editorRef, workspaceSlug]
[editorRef, workspaceId, workspaceSlug]
);
const handleServerConnect = useCallback(() => {
@ -129,13 +133,13 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
// Construct realtime config
return {
url: WS_LIVE_URL.toString(),
queryParams: config.webhookConnectionParams,
queryParams: webhookConnectionParams,
};
} catch (error) {
console.error("Error creating realtime config", error);
return undefined;
}
}, [config.webhookConnectionParams]);
}, [webhookConnectionParams]);
const userConfig = useMemo(
() => ({

View file

@ -4,7 +4,7 @@ import { useSearchParams } from "next/navigation";
// editor
import { EditorRefApi } from "@plane/editor";
// types
import { TDocumentPayload, TPage, TPageVersion } from "@plane/types";
import { TDocumentPayload, TPage, TPageVersion, TWebhookConnectionQueryParams } from "@plane/types";
// components
import {
PageEditorHeaderRoot,
@ -36,11 +36,12 @@ type TPageRootProps = {
config: TPageRootConfig;
handlers: TPageRootHandlers;
page: TPageInstance;
webhookConnectionParams: TWebhookConnectionQueryParams;
workspaceSlug: string;
};
export const PageRoot = observer((props: TPageRootProps) => {
const { config, handlers, page, workspaceSlug } = props;
const { config, handlers, page, webhookConnectionParams, workspaceSlug } = props;
// states
const [editorReady, setEditorReady] = useState(false);
const [hasConnectionFailed, setHasConnectionFailed] = useState(false);
@ -116,6 +117,7 @@ export const PageRoot = observer((props: TPageRootProps) => {
handlers={handlers}
page={page}
sidePeekVisible={sidePeekVisible}
webhookConnectionParams={webhookConnectionParams}
workspaceSlug={workspaceSlug}
/>
</>

View file

@ -8,9 +8,9 @@ import { TPageVersion } from "@plane/types";
import { Loader } from "@plane/ui";
// components
import { EditorMentionsRoot } from "@/components/editor";
// helpers
import { getReadOnlyEditorFileHandlers } from "@/helpers/editor.helper";
// hooks
import { useEditorConfig } from "@/hooks/editor";
import { useWorkspace } from "@/hooks/store";
import { usePageFilters } from "@/hooks/use-page-filters";
// plane web hooks
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
@ -27,8 +27,14 @@ export const PagesVersionEditor: React.FC<TVersionEditorProps> = observer((props
const { activeVersion, currentVersionDescription, isCurrentVersionActive, versionDetails } = props;
// params
const { workspaceSlug, projectId } = useParams();
// store hooks
const { getWorkspaceBySlug } = useWorkspace();
// derived values
const workspaceDetails = getWorkspaceBySlug(workspaceSlug?.toString() ?? "");
// editor flaggings
const { documentEditor: disabledExtensions } = useEditorFlagging(workspaceSlug?.toString() ?? "");
// editor config
const { getReadOnlyEditorFileHandlers } = useEditorConfig();
// issue-embed
const { issueEmbedProps } = useIssueEmbed({
projectId: projectId?.toString() ?? "",
@ -97,6 +103,7 @@ export const PagesVersionEditor: React.FC<TVersionEditorProps> = observer((props
editorClassName="pl-10"
fileHandler={getReadOnlyEditorFileHandlers({
projectId: projectId?.toString() ?? "",
workspaceId: workspaceDetails?.id ?? "",
workspaceSlug: workspaceSlug?.toString() ?? "",
})}
mentionHandler={{

View file

@ -15,7 +15,7 @@ import { ActivitySettingsLoader } from "@/components/ui";
import { calculateTimeAgo } from "@/helpers/date-time.helper";
import { getFileURL } from "@/helpers/file.helper";
// hooks
import { useUser } from "@/hooks/store";
import { useUser, useWorkspace } from "@/hooks/store";
type Props = {
activity: IUserActivityResponse | undefined;
@ -27,6 +27,9 @@ export const ActivityList: React.FC<Props> = observer((props) => {
const { workspaceSlug } = useParams();
// store hooks
const { data: currentUser } = useUser();
const { getWorkspaceBySlug } = useWorkspace();
// derived values
const workspaceId = getWorkspaceBySlug(workspaceSlug?.toString() ?? "")?.id ?? "";
// TODO: refactor this component
return (
@ -79,6 +82,7 @@ export const ActivityList: React.FC<Props> = observer((props) => {
: (activityItem.old_value?.toString() as string)
}
containerClassName="text-xs bg-custom-background-100"
workspaceId={workspaceId}
workspaceSlug={workspaceSlug?.toString() ?? ""}
projectId={activityItem.project}
/>

View file

@ -103,7 +103,8 @@ export const ProfileActivityListPage: React.FC<Props> = observer((props) => {
activityItem?.new_value !== "" ? activityItem.new_value : activityItem.old_value
}
containerClassName="text-xs bg-custom-background-100"
workspaceSlug={activityItem?.workspace_detail?.slug.toString() ?? ""}
workspaceId={activityItem?.workspace_detail?.id?.toString() ?? ""}
workspaceSlug={activityItem?.workspace_detail?.slug?.toString() ?? ""}
projectId={activityItem.project ?? ""}
/>
</div>

View file

@ -0,0 +1,2 @@
export * from "./use-editor-config";
export * from "./use-editor-mention";

View file

@ -0,0 +1,96 @@
import { useCallback } from "react";
// plane editor
import { TFileHandler, TReadOnlyFileHandler } from "@plane/editor";
// helpers
import { getEditorAssetSrc } from "@/helpers/editor.helper";
// hooks
import { useEditorAsset } from "@/hooks/store";
// plane web hooks
import { useFileSize } from "@/plane-web/hooks/use-file-size";
// services
import { FileService } from "@/services/file.service";
const fileService = new FileService();
type TArgs = {
projectId?: string;
uploadFile: TFileHandler["upload"];
workspaceId: string;
workspaceSlug: string;
};
export const useEditorConfig = () => {
// store hooks
const { assetsUploadPercentage } = useEditorAsset();
// file size
const { maxFileSize } = useFileSize();
const getReadOnlyEditorFileHandlers = useCallback(
(args: Pick<TArgs, "projectId" | "workspaceId" | "workspaceSlug">): TReadOnlyFileHandler => {
const { projectId, workspaceId, workspaceSlug } = args;
return {
getAssetSrc: async (path) => {
if (!path) return "";
if (path?.startsWith("http")) {
return path;
} else {
return (
getEditorAssetSrc({
assetId: path,
projectId,
workspaceSlug,
}) ?? ""
);
}
},
restore: async (src: string) => {
if (src?.startsWith("http")) {
await fileService.restoreOldEditorAsset(workspaceId, src);
} else {
await fileService.restoreNewAsset(workspaceSlug, src);
}
},
};
},
[]
);
const getEditorFileHandlers = useCallback(
(args: TArgs): TFileHandler => {
const { projectId, uploadFile, workspaceId, workspaceSlug } = args;
return {
...getReadOnlyEditorFileHandlers({
projectId,
workspaceId,
workspaceSlug,
}),
assetsUploadStatus: assetsUploadPercentage,
upload: uploadFile,
delete: async (src: string) => {
if (src?.startsWith("http")) {
await fileService.deleteOldWorkspaceAsset(workspaceId, src);
} else {
await fileService.deleteNewAsset(
getEditorAssetSrc({
assetId: src,
projectId,
workspaceSlug,
}) ?? ""
);
}
},
cancel: fileService.cancelUpload,
validation: {
maxFileSize,
},
};
},
[assetsUploadPercentage, getReadOnlyEditorFileHandlers, maxFileSize]
);
return {
getEditorFileHandlers,
getReadOnlyEditorFileHandlers,
};
};

View file

@ -7,6 +7,7 @@ export * from "./use-command-palette";
export * from "./use-cycle";
export * from "./use-cycle-filter";
export * from "./use-dashboard";
export * from "./use-editor-asset";
export * from "./use-event-tracker";
export * from "./use-global-view";
export * from "./use-inbox-issues";

View file

@ -0,0 +1,10 @@
import { useContext } from "react";
// store
import { StoreContext } from "@/lib/store-context";
import { IEditorAssetStore } from "@/store/editor/asset.store";
export const useEditorAsset = (): IEditorAssetStore => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useEditorAsset must be used within StoreProvider");
return context.editorAssetStore;
};

View file

@ -1,3 +1,4 @@
import { AxiosRequestConfig } from "axios";
// plane types
import { TFileEntityInfo, TFileSignedURLResponse } from "@plane/types";
// helpers
@ -64,7 +65,8 @@ export class FileService extends APIService {
async uploadWorkspaceAsset(
workspaceSlug: string,
data: TFileEntityInfo,
file: File
file: File,
uploadProgressHandler?: AxiosRequestConfig["onUploadProgress"]
): Promise<TFileSignedURLResponse> {
const fileMetaData = getFileMetaDataForUpload(file);
return this.post(`/api/assets/v2/workspaces/${workspaceSlug}/`, {
@ -74,7 +76,11 @@ export class FileService extends APIService {
.then(async (response) => {
const signedURLResponse: TFileSignedURLResponse = response?.data;
const fileUploadPayload = generateFileUploadPayload(signedURLResponse, file);
await this.fileUploadService.uploadFile(signedURLResponse.upload_data.url, fileUploadPayload);
await this.fileUploadService.uploadFile(
signedURLResponse.upload_data.url,
fileUploadPayload,
uploadProgressHandler
);
await this.updateWorkspaceAssetUploadStatus(workspaceSlug.toString(), signedURLResponse.asset_id);
return signedURLResponse;
})
@ -122,7 +128,8 @@ export class FileService extends APIService {
workspaceSlug: string,
projectId: string,
data: TFileEntityInfo,
file: File
file: File,
uploadProgressHandler?: AxiosRequestConfig["onUploadProgress"]
): Promise<TFileSignedURLResponse> {
const fileMetaData = getFileMetaDataForUpload(file);
return this.post(`/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/`, {
@ -132,7 +139,11 @@ export class FileService extends APIService {
.then(async (response) => {
const signedURLResponse: TFileSignedURLResponse = response?.data;
const fileUploadPayload = generateFileUploadPayload(signedURLResponse, file);
await this.fileUploadService.uploadFile(signedURLResponse.upload_data.url, fileUploadPayload);
await this.fileUploadService.uploadFile(
signedURLResponse.upload_data.url,
fileUploadPayload,
uploadProgressHandler
);
await this.updateProjectAssetUploadStatus(workspaceSlug, projectId, signedURLResponse.asset_id);
return signedURLResponse;
})

View file

@ -0,0 +1,121 @@
import debounce from "lodash/debounce";
import set from "lodash/set";
import { action, computed, makeObservable, observable, runInAction } from "mobx";
import { computedFn } from "mobx-utils";
import { v4 as uuidv4 } from "uuid";
// plane types
import { TFileEntityInfo, TFileSignedURLResponse } from "@plane/types";
// services
import { FileService } from "@/services/file.service";
import { TAttachmentUploadStatus } from "../issue/issue-details/attachment.store";
export interface IEditorAssetStore {
// computed
assetsUploadPercentage: Record<string, number>;
// helper methods
getAssetUploadStatusByEditorBlockId: (blockId: string) => TAttachmentUploadStatus | undefined;
// actions
uploadEditorAsset: ({
blockId,
data,
file,
projectId,
workspaceSlug,
}: {
blockId: string;
data: TFileEntityInfo;
file: File;
projectId?: string;
workspaceSlug: string;
}) => Promise<TFileSignedURLResponse>;
}
export class EditorAssetStore implements IEditorAssetStore {
// observables
assetsUploadStatus: Record<string, TAttachmentUploadStatus> = {};
// services
fileService: FileService;
constructor() {
makeObservable(this, {
// observables
assetsUploadStatus: observable,
// computed
assetsUploadPercentage: computed,
// actions
uploadEditorAsset: action,
});
// services
this.fileService = new FileService();
}
get assetsUploadPercentage() {
const assetsStatus = this.assetsUploadStatus;
const assetsPercentage: Record<string, number> = {};
Object.keys(assetsStatus).forEach((blockId) => {
const asset = assetsStatus[blockId];
if (asset) assetsPercentage[blockId] = asset.progress;
});
return assetsPercentage;
}
// helper methods
getAssetUploadStatusByEditorBlockId: IEditorAssetStore["getAssetUploadStatusByEditorBlockId"] = computedFn(
(blockId) => {
const blockDetails = this.assetsUploadStatus[blockId];
if (!blockDetails) return undefined;
return blockDetails;
}
);
// actions
private debouncedUpdateProgress = debounce((blockId: string, progress: number) => {
runInAction(() => {
set(this.assetsUploadStatus, [blockId, "progress"], progress);
});
}, 16);
uploadEditorAsset: IEditorAssetStore["uploadEditorAsset"] = async (args) => {
const { blockId, data, file, projectId, workspaceSlug } = args;
const tempId = uuidv4();
try {
// update attachment upload status
runInAction(() => {
set(this.assetsUploadStatus, [blockId], {
id: tempId,
name: file.name,
progress: 0,
size: file.size,
type: file.type,
});
});
if (projectId) {
const response = await this.fileService.uploadProjectAsset(
workspaceSlug,
projectId,
data,
file,
(progressEvent) => {
const progressPercentage = Math.round((progressEvent.progress ?? 0) * 100);
this.debouncedUpdateProgress(blockId, progressPercentage);
}
);
return response;
} else {
const response = await this.fileService.uploadWorkspaceAsset(workspaceSlug, data, file, (progressEvent) => {
const progressPercentage = Math.round((progressEvent.progress ?? 0) * 100);
this.debouncedUpdateProgress(blockId, progressPercentage);
});
return response;
}
} catch (error) {
console.error("Error in uploading page asset:", error);
throw error;
} finally {
runInAction(() => {
delete this.assetsUploadStatus[blockId];
});
}
};
}

View file

@ -126,7 +126,7 @@ export class IssueAttachmentStore implements IIssueAttachmentStore {
return response;
};
debouncedUpdateProgress = debounce((issueId: string, tempId: string, progress: number) => {
private debouncedUpdateProgress = debounce((issueId: string, tempId: string, progress: number) => {
runInAction(() => {
set(this.attachmentsUploadStatusMap, [issueId, tempId, "progress"], progress);
});

View file

@ -1,5 +1,4 @@
import { enableStaticRendering } from "mobx-react";
import { EIssueServiceType } from "@plane/constants";
// plane web store
import { CommandPaletteStore, ICommandPaletteStore } from "@/plane-web/store/command-palette.store";
import { RootStore } from "@/plane-web/store/root.store";
@ -8,6 +7,7 @@ import { IStateStore, StateStore } from "@/plane-web/store/state.store";
import { CycleStore, ICycleStore } from "./cycle.store";
import { CycleFilterStore, ICycleFilterStore } from "./cycle_filter.store";
import { DashboardStore, IDashboardStore } from "./dashboard.store";
import { EditorAssetStore, IEditorAssetStore } from "./editor/asset.store";
import { IProjectEstimateStore, ProjectEstimateStore } from "./estimates/project-estimate.store";
import { EventTrackerStore, IEventTrackerStore } from "./event-tracker.store";
import { FavoriteStore, IFavoriteStore } from "./favorite.store";
@ -61,6 +61,7 @@ export class CoreRootStore {
favorite: IFavoriteStore;
transient: ITransientStore;
stickyStore: IStickyStore;
editorAssetStore: IEditorAssetStore;
constructor() {
this.router = new RouterStore();
@ -90,6 +91,7 @@ export class CoreRootStore {
this.favorite = new FavoriteStore(this);
this.transient = new TransientStore();
this.stickyStore = new StickyStore();
this.editorAssetStore = new EditorAssetStore();
}
resetOnSignOut() {
@ -122,5 +124,6 @@ export class CoreRootStore {
this.favorite = new FavoriteStore(this);
this.transient = new TransientStore();
this.stickyStore = new StickyStore();
this.editorAssetStore = new EditorAssetStore();
}
}