Sync: Community Changes #5027

This commit is contained in:
sriram veeraghanta
2025-12-10 01:16:30 +05:30
committed by GitHub
63 changed files with 486 additions and 279 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "admin",
"description": "Admin UI for Plane",
"version": "1.1.0",
"version": "1.2.0",
"license": "AGPL-3.0",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "plane-api",
"version": "1.1.0",
"version": "1.2.0",
"license": "AGPL-3.0",
"private": true,
"description": "API server powering Plane's backend"
+2 -2
View File
@@ -981,7 +981,7 @@ class DuplicateAssetEndpoint(BaseAPIView):
return {"entity_identifier": entity_id}
return {}
@allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
def post(self, request, slug, asset_id):
project_id = request.data.get("project_id", None)
entity_id = request.data.get("entity_id", None)
@@ -1000,7 +1000,7 @@ class DuplicateAssetEndpoint(BaseAPIView):
return Response({"error": "Project not found"}, status=status.HTTP_404_NOT_FOUND)
storage = S3Storage(request=request)
original_asset = FileAsset.objects.filter(workspace=workspace, id=asset_id, is_uploaded=True).first()
original_asset = FileAsset.objects.filter(id=asset_id, is_uploaded=True).first()
if not original_asset:
return Response({"error": "Asset not found"}, status=status.HTTP_404_NOT_FOUND)
+96
View File
@@ -0,0 +1,96 @@
# Python imports
import logging
from typing import Optional, Dict, Any
# Third party imports
from pymongo.collection import Collection
from celery import shared_task
# Django imports
from plane.settings.mongo import MongoConnection
from plane.utils.exception_logger import log_exception
from plane.db.models import APIActivityLog
logger = logging.getLogger("plane.worker")
def get_mongo_collection() -> Optional[Collection]:
"""
Returns the MongoDB collection for external API activity logs.
"""
if not MongoConnection.is_configured():
logger.info("MongoDB not configured")
return None
try:
return MongoConnection.get_collection("api_activity_logs")
except Exception as e:
logger.error(f"Error getting MongoDB collection: {str(e)}")
log_exception(e)
return None
def safe_decode_body(content: bytes) -> Optional[str]:
"""
Safely decodes request/response body content, handling binary data.
Returns "[Binary Content]" if the content is binary, or a string representation of the content.
Returns None if the content is None or empty.
"""
# If the content is None, return None
if content is None:
return None
# If the content is an empty bytes object, return None
if content == b"":
return None
# Check if content is binary by looking for common binary file signatures
if content.startswith(b"\x89PNG") or content.startswith(b"\xff\xd8\xff") or content.startswith(b"%PDF"):
return "[Binary Content]"
try:
return content.decode("utf-8")
except UnicodeDecodeError:
return "[Could not decode content]"
def log_to_mongo(log_document: Dict[str, Any]) -> bool:
"""
Logs the request to MongoDB if available.
"""
mongo_collection = get_mongo_collection()
if mongo_collection is None:
logger.error("MongoDB not configured")
return False
try:
mongo_collection.insert_one(log_document)
return True
except Exception as e:
log_exception(e)
return False
def log_to_postgres(log_data: Dict[str, Any]) -> bool:
"""
Fallback to logging to PostgreSQL if MongoDB is unavailable.
"""
try:
APIActivityLog.objects.create(**log_data)
return True
except Exception as e:
log_exception(e)
return False
@shared_task
def process_logs(log_data: Dict[str, Any], mongo_log: Dict[str, Any]) -> None:
"""
Process logs to save to MongoDB or Postgres based on the configuration
"""
if MongoConnection.is_configured():
log_to_mongo(mongo_log)
else:
log_to_postgres(log_data)
+27
View File
@@ -237,3 +237,30 @@ class ChangeTrackerMixin:
all non-deferred fields).
"""
return self._original_values
def save(self, *args: Any, **kwargs: Any) -> None:
"""
Override save to automatically capture changed fields and reset tracking.
Before saving, the current changed_fields are captured and stored in
_changes_on_save. After saving, the tracked fields are reset so
that subsequent saves correctly detect changes relative to the last
saved state, not the original load-time state.
Models that need to access the changed fields after save (e.g., for
syncing related models) can use self._changes_on_save.
"""
self._changes_on_save = self.changed_fields
super().save(*args, **kwargs)
self._reset_tracked_fields()
def _reset_tracked_fields(self) -> None:
"""
Reset the tracked field values to the current state.
This is called automatically after save() to ensure that subsequent
saves correctly detect changes relative to the last saved state,
rather than the original load-time state.
"""
self._original_values = {}
self._track_fields()
+3 -1
View File
@@ -570,10 +570,12 @@ class IssueComment(ChangeTrackerMixin, ProjectBaseModel):
"comment_json": "description_json",
}
# Use _changes_on_save which is captured by ChangeTrackerMixin.save()
# before the tracked fields are reset
changed_fields = {
desc_field: getattr(self, comment_field)
for comment_field, desc_field in field_mapping.items()
if self.has_changed(comment_field)
if comment_field in self._changes_on_save
}
# Update description only if comment fields changed
+42 -19
View File
@@ -4,13 +4,15 @@ import time
# Django imports
from django.http import HttpRequest
from django.utils import timezone
# Third party imports
from rest_framework.request import Request
# Module imports
from plane.utils.ip_address import get_client_ip
from plane.db.models import APIActivityLog
from plane.utils.exception_logger import log_exception
from plane.bgtasks.logger_task import process_logs
api_logger = logging.getLogger("plane.api.request")
@@ -70,6 +72,10 @@ class RequestLoggerMiddleware:
class APITokenLogMiddleware:
"""
Middleware to log External API requests to MongoDB or PostgreSQL.
"""
def __init__(self, get_response):
self.get_response = get_response
@@ -104,24 +110,41 @@ class APITokenLogMiddleware:
def process_request(self, request, response, request_body):
api_key_header = "X-Api-Key"
api_key = request.headers.get(api_key_header)
# If the API key is present, log the request
if api_key:
try:
APIActivityLog.objects.create(
token_identifier=api_key,
path=request.path,
method=request.method,
query_params=request.META.get("QUERY_STRING", ""),
headers=str(request.headers),
body=(self._safe_decode_body(request_body) if request_body else None),
response_body=(self._safe_decode_body(response.content) if response.content else None),
response_code=response.status_code,
ip_address=get_client_ip(request=request),
user_agent=request.META.get("HTTP_USER_AGENT", None),
)
except Exception as e:
api_logger.exception(e)
# If the token does not exist, you can decide whether to log this as an invalid attempt
# If the API key is not present, return
if not api_key:
return
try:
log_data = {
"token_identifier": api_key,
"path": request.path,
"method": request.method,
"query_params": request.META.get("QUERY_STRING", ""),
"headers": str(request.headers),
"body": self._safe_decode_body(request_body) if request_body else None,
"response_body": self._safe_decode_body(response.content) if response.content else None,
"response_code": response.status_code,
"ip_address": get_client_ip(request=request),
"user_agent": request.META.get("HTTP_USER_AGENT", None),
}
user_id = (
str(request.user.id)
if getattr(request, "user") and getattr(request.user, "is_authenticated", False)
else None
)
# Additional fields for MongoDB
mongo_log = {
**log_data,
"created_at": timezone.now(),
"updated_at": timezone.now(),
"created_by": user_id,
"updated_by": user_id,
}
process_logs.delay(log_data=log_data, mongo_log=mongo_log)
except Exception as e:
log_exception(e)
return None
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "live",
"version": "1.1.0",
"version": "1.2.0",
"license": "AGPL-3.0",
"description": "A realtime collaborative server powers Plane's rich text editor",
"main": "./dist/start.mjs",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "space",
"version": "1.1.0",
"version": "1.2.0",
"private": true,
"license": "AGPL-3.0",
"type": "module",
+3 -3
View File
@@ -18,7 +18,7 @@ import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper";
// plane web helpers
import { getIsWorkspaceCreationDisabled } from "@/plane-web/helpers/instance.helper";
function CreateWorkspacePage() {
const CreateWorkspacePage = observer(function CreateWorkspacePage() {
const { t } = useTranslation();
// router
const router = useAppRouter();
@@ -104,6 +104,6 @@ function CreateWorkspacePage() {
</div>
</AuthenticationWrapper>
);
}
});
export default observer(CreateWorkspacePage);
export default CreateWorkspacePage;
@@ -1 +1,2 @@
export * from "./dependency";
export * from "./layers";
@@ -0,0 +1,8 @@
import type { FC } from "react";
type Props = {
itemsContainerWidth: number;
blockCount: number;
};
export const GanttAdditionalLayers: FC<Props> = () => null;
@@ -0,0 +1 @@
export { GanttAdditionalLayers } from "./additional-layers";
@@ -59,13 +59,12 @@ export const DescriptionVersionsModal = observer(function DescriptionVersionsMod
const handleCopyMarkdown = useCallback(() => {
if (!editorRef.current) return;
copyTextToClipboard(editorRef.current.getMarkDown()).then(() =>
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("toast.success"),
message: "Markdown copied to clipboard.",
})
);
editorRef.current.copyMarkdownToClipboard();
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("toast.success"),
message: "Markdown copied to clipboard.",
});
}, [t]);
if (!workspaceId) return null;
@@ -17,7 +17,11 @@ import { GanttChartSidebar, MonthChartView, QuarterChartView, WeekChartView } fr
// hooks
import { useTimeLineChartStore } from "@/hooks/use-timeline-chart";
// plane web components
import { TimelineDependencyPaths, TimelineDraggablePath } from "@/plane-web/components/gantt-chart";
import {
TimelineDependencyPaths,
TimelineDraggablePath,
GanttAdditionalLayers,
} from "@/plane-web/components/gantt-chart";
import { GanttChartRowList } from "@/plane-web/components/gantt-chart/blocks/block-row-list";
import { GanttChartBlocksList } from "@/plane-web/components/gantt-chart/blocks/blocks-list";
import { IssueBulkOperationsRoot } from "@/plane-web/components/issues/bulk-operations";
@@ -212,6 +216,7 @@ export const GanttChartMainContent = observer(function GanttChartMainContent(pro
/>
<TimelineDependencyPaths isEpic={isEpic} />
<TimelineDraggablePath />
<GanttAdditionalLayers itemsContainerWidth={itemsContainerWidth} blockCount={blockIds.length} />
<GanttChartBlocksList
blockIds={blockIds}
blockToRender={blockToRender}
@@ -1,4 +1,4 @@
import React, { useState } from "react";
import { useState } from "react";
import { observer } from "mobx-react";
import { Controller, useForm } from "react-hook-form";
import { CircleCheck } from "lucide-react";
@@ -71,47 +71,46 @@ export const WorkspaceCreateStep = observer(function WorkspaceCreateStep({
const handleCreateWorkspace = async (formData: IWorkspace) => {
if (isSubmitting) return;
await workspaceService
.workspaceSlugCheck(formData.slug)
.then(async (res) => {
if (res.status === true && !RESTRICTED_URLS.includes(formData.slug)) {
setSlugError(false);
await createWorkspace(formData)
.then(async (workspaceResponse) => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("workspace_creation.toast.success.title"),
message: t("workspace_creation.toast.success.message"),
});
captureSuccess({
eventName: WORKSPACE_TRACKER_EVENTS.create,
payload: { slug: formData.slug },
});
await fetchWorkspaces();
await completeStep(workspaceResponse.id);
onComplete(formData.organization_size === "Just myself");
})
.catch(() => {
captureError({
eventName: WORKSPACE_TRACKER_EVENTS.create,
payload: { slug: formData.slug },
error: new Error("Error creating workspace"),
});
setToast({
type: TOAST_TYPE.ERROR,
title: t("workspace_creation.toast.error.title"),
message: t("workspace_creation.toast.error.message"),
});
});
} else setSlugError(true);
})
.catch(() =>
setToast({
type: TOAST_TYPE.ERROR,
title: t("workspace_creation.toast.error.title"),
message: t("workspace_creation.toast.error.message"),
})
);
try {
const res = (await workspaceService.workspaceSlugCheck(formData.slug)) as { status: boolean };
if (res.status === true && !RESTRICTED_URLS.includes(formData.slug)) {
setSlugError(false);
try {
const workspaceResponse = await createWorkspace(formData);
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("workspace_creation.toast.success.title"),
message: t("workspace_creation.toast.success.message"),
});
captureSuccess({
eventName: WORKSPACE_TRACKER_EVENTS.create,
payload: { slug: formData.slug },
});
await fetchWorkspaces();
await completeStep(workspaceResponse.id);
onComplete(formData.organization_size === "Just myself");
} catch {
captureError({
eventName: WORKSPACE_TRACKER_EVENTS.create,
payload: { slug: formData.slug },
error: new Error("Error creating workspace"),
});
setToast({
type: TOAST_TYPE.ERROR,
title: t("workspace_creation.toast.error.title"),
message: t("workspace_creation.toast.error.message"),
});
}
} else {
setSlugError(true);
}
} catch {
setToast({
type: TOAST_TYPE.ERROR,
title: t("workspace_creation.toast.error.title"),
message: t("workspace_creation.toast.error.message"),
});
}
};
const completeStep = async (workspaceId: string) => {
@@ -136,7 +135,12 @@ export const WorkspaceCreateStep = observer(function WorkspaceCreateStep({
);
}
return (
<form className="flex flex-col gap-10" onSubmit={handleSubmit(handleCreateWorkspace)}>
<form
className="flex flex-col gap-10"
onSubmit={(e) => {
void handleSubmit(handleCreateWorkspace)(e);
}}
>
<CommonOnboardingHeader title="Create your workspace" description="All your work — unified." />
<div className="flex flex-col gap-8">
<div className="flex flex-col gap-2">
@@ -181,6 +185,7 @@ export const WorkspaceCreateStep = observer(function WorkspaceCreateStep({
"border-red-500": errors.name,
}
)}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
/>
</div>
@@ -71,13 +71,12 @@ export const PageOptionsDropdown = observer(function PageOptionsDropdown(props:
key: "copy-markdown",
action: () => {
if (!editorRef) return;
copyTextToClipboard(editorRef.getMarkDown()).then(() =>
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Markdown copied to clipboard.",
})
);
editorRef.copyMarkdownToClipboard();
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Markdown copied to clipboard.",
});
},
title: "Copy markdown",
icon: Clipboard,
@@ -67,47 +67,46 @@ export const CreateWorkspaceForm = observer(function CreateWorkspaceForm(props:
} = useForm<IWorkspace>({ defaultValues, mode: "onChange" });
const handleCreateWorkspace = async (formData: IWorkspace) => {
await workspaceService
.workspaceSlugCheck(formData.slug)
.then(async (res) => {
if (res.status === true && !RESTRICTED_URLS.includes(formData.slug)) {
setSlugError(false);
try {
const res = (await workspaceService.workspaceSlugCheck(formData.slug)) as { status: boolean };
if (res.status === true && !RESTRICTED_URLS.includes(formData.slug)) {
setSlugError(false);
await createWorkspace(formData)
.then(async (res) => {
captureSuccess({
eventName: WORKSPACE_TRACKER_EVENTS.create,
payload: { slug: formData.slug },
});
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("workspace_creation.toast.success.title"),
message: t("workspace_creation.toast.success.message"),
});
try {
const workspaceResponse = await createWorkspace(formData);
captureSuccess({
eventName: WORKSPACE_TRACKER_EVENTS.create,
payload: { slug: formData.slug },
});
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("workspace_creation.toast.success.title"),
message: t("workspace_creation.toast.success.message"),
});
if (onSubmit) await onSubmit(res);
})
.catch(() => {
captureError({
eventName: WORKSPACE_TRACKER_EVENTS.create,
payload: { slug: formData.slug },
error: new Error("Error creating workspace"),
});
setToast({
type: TOAST_TYPE.ERROR,
title: t("workspace_creation.toast.error.title"),
message: t("workspace_creation.toast.error.message"),
});
});
} else setSlugError(true);
})
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: t("workspace_creation.toast.error.title"),
message: t("workspace_creation.toast.error.message"),
});
if (onSubmit) await onSubmit(workspaceResponse);
} catch {
captureError({
eventName: WORKSPACE_TRACKER_EVENTS.create,
payload: { slug: formData.slug },
error: new Error("Error creating workspace"),
});
setToast({
type: TOAST_TYPE.ERROR,
title: t("workspace_creation.toast.error.title"),
message: t("workspace_creation.toast.error.message"),
});
}
} else {
setSlugError(true);
}
} catch {
setToast({
type: TOAST_TYPE.ERROR,
title: t("workspace_creation.toast.error.title"),
message: t("workspace_creation.toast.error.message"),
});
}
};
useEffect(
@@ -119,7 +118,12 @@ export const CreateWorkspaceForm = observer(function CreateWorkspaceForm(props:
);
return (
<form className="space-y-6 sm:space-y-9" onSubmit={handleSubmit(handleCreateWorkspace)}>
<form
className="space-y-6 sm:space-y-9"
onSubmit={(e) => {
void handleSubmit(handleCreateWorkspace)(e);
}}
>
<div className="space-y-6 sm:space-y-7">
<div className="space-y-1 text-sm">
<label htmlFor="workspaceName">
@@ -19,6 +19,7 @@ import { copyUrlToClipboard, getFileURL } from "@plane/utils";
// components
import { WorkspaceImageUploadModal } from "@/components/core/modals/workspace-image-upload-modal";
// helpers
import { TimezoneSelect } from "@/components/global/timezone-select";
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
import { useWorkspace } from "@/hooks/store/use-workspace";
import { useUserPermissions } from "@/hooks/store/user";
@@ -30,6 +31,7 @@ const defaultValues: Partial<IWorkspace> = {
url: "",
organization_size: "2-10",
logo_url: null,
timezone: "UTC",
};
export const WorkspaceDetails = observer(function WorkspaceDetails() {
@@ -62,64 +64,69 @@ export const WorkspaceDetails = observer(function WorkspaceDetails() {
const payload: Partial<IWorkspace> = {
name: formData.name,
organization_size: formData.organization_size,
timezone: formData.timezone,
};
await updateWorkspace(currentWorkspace.slug, payload)
.then(() => {
captureSuccess({
eventName: WORKSPACE_TRACKER_EVENTS.update,
payload: { slug: currentWorkspace.slug },
});
setToast({
title: "Success!",
type: TOAST_TYPE.SUCCESS,
message: "Workspace updated successfully",
});
})
.catch((err) => {
captureError({
eventName: WORKSPACE_TRACKER_EVENTS.update,
payload: { slug: currentWorkspace.slug },
error: err,
});
console.error(err);
try {
await updateWorkspace(currentWorkspace.slug, payload);
captureSuccess({
eventName: WORKSPACE_TRACKER_EVENTS.update,
payload: { slug: currentWorkspace.slug },
});
setTimeout(() => {
setIsLoading(false);
}, 300);
setToast({
title: "Success!",
type: TOAST_TYPE.SUCCESS,
message: "Workspace updated successfully",
});
} catch (err: unknown) {
captureError({
eventName: WORKSPACE_TRACKER_EVENTS.update,
payload: { slug: currentWorkspace.slug },
error: err instanceof Error ? err : new Error(String(err)),
});
console.error(err);
} finally {
setTimeout(() => {
setIsLoading(false);
}, 300);
}
};
const handleRemoveLogo = async () => {
if (!currentWorkspace) return;
await updateWorkspace(currentWorkspace.slug, {
logo_url: "",
})
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Workspace picture removed successfully.",
});
})
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "There was some error in deleting your profile picture. Please try again.",
});
try {
await updateWorkspace(currentWorkspace.slug, {
logo_url: "",
});
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Workspace picture removed successfully.",
});
} catch {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "There was some error in deleting your profile picture. Please try again.",
});
}
};
const handleCopyUrl = () => {
if (!currentWorkspace) return;
copyUrlToClipboard(`${currentWorkspace.slug}`).then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Workspace URL copied to the clipboard.",
void copyUrlToClipboard(`${currentWorkspace.slug}`)
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Workspace URL copied to the clipboard.",
});
return undefined;
})
.catch(() => {
// Silently handle clipboard errors
});
});
};
useEffect(() => {
@@ -264,12 +271,30 @@ export const WorkspaceDetails = observer(function WorkspaceDetails() {
onChange={onChange}
ref={ref}
hasError={Boolean(errors.url)}
className="w-full"
className="w-full cursor-not-allowed rounded-md !bg-custom-background-90"
disabled
/>
)}
/>
</div>
<div className="flex flex-col gap-1 ">
<h4 className="text-sm">{t("workspace_settings.settings.general.workspace_timezone")}</h4>
<Controller
name="timezone"
control={control}
render={({ field: { value, onChange } }) => (
<>
<TimezoneSelect
value={value}
onChange={onChange}
buttonClassName="border-none"
disabled={!isAdmin}
/>
</>
)}
/>
</div>
</div>
{isAdmin && (
@@ -277,7 +302,9 @@ export const WorkspaceDetails = observer(function WorkspaceDetails() {
<Button
data-ph-element={WORKSPACE_TRACKER_ELEMENTS.UPDATE_WORKSPACE_BUTTON}
variant="primary"
onClick={handleSubmit(onSubmit)}
onClick={(e) => {
void handleSubmit(onSubmit)(e);
}}
loading={isLoading}
>
{isLoading ? t("updating") : t("workspace_settings.settings.general.update_workspace")}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "1.1.0",
"version": "1.2.0",
"private": true,
"license": "AGPL-3.0",
"type": "module",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "plane",
"description": "Open-source project management that unlocks customer value",
"repository": "https://github.com/makeplane/plane.git",
"version": "1.1.0",
"version": "1.2.0",
"license": "AGPL-3.0",
"private": true,
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/codemods",
"version": "1.1.0",
"version": "1.2.0",
"private": true,
"scripts": {
"test": "vitest run",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/constants",
"version": "1.1.0",
"version": "1.2.0",
"private": true,
"license": "AGPL-3.0",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/editor",
"version": "1.1.0",
"version": "1.2.0",
"description": "Core Editor that powers Plane",
"license": "AGPL-3.0",
"private": true,
@@ -56,16 +56,24 @@ export function CustomImageNodeView(props: CustomImageNodeViewProps) {
return;
}
setResolvedSrc(undefined);
setResolvedDownloadSrc(undefined);
setFailedToLoadImage(false);
const getImageSource = async () => {
const url = await extension.options.getImageSource?.(imgNodeSrc);
setResolvedSrc(url);
const downloadUrl = await extension.options.getImageDownloadSource?.(imgNodeSrc);
setResolvedDownloadSrc(downloadUrl);
try {
const url = await extension.options.getImageSource?.(imgNodeSrc);
setResolvedSrc(url);
const downloadUrl = await extension.options.getImageDownloadSource?.(imgNodeSrc);
setResolvedDownloadSrc(downloadUrl);
} catch (error) {
console.error("Error fetching image source:", error);
setFailedToLoadImage(true);
}
};
getImageSource();
}, [imgNodeSrc, extension.options]);
// Handle image duplication when status is duplicating
useEffect(() => {
const handleDuplication = async () => {
if (status !== ECustomImageStatus.DUPLICATING || !extension.options.duplicateImage || !imgNodeSrc) {
@@ -87,11 +95,8 @@ export function CustomImageNodeView(props: CustomImageNodeViewProps) {
throw new Error("Duplication returned invalid asset ID");
}
// Update node with new source and success status
updateAttributes({
src: newAssetId,
status: ECustomImageStatus.UPLOADED,
});
setFailedToLoadImage(false);
updateAttributes({ src: newAssetId, status: ECustomImageStatus.UPLOADED });
} catch (error: unknown) {
console.error("Failed to duplicate image:", error);
// Update status to failed
@@ -115,11 +120,13 @@ export function CustomImageNodeView(props: CustomImageNodeViewProps) {
useEffect(() => {
if (status === ECustomImageStatus.UPLOADED) {
hasRetriedOnMount.current = false;
setFailedToLoadImage(false);
}
}, [status]);
const hasDuplicationFailed = hasImageDuplicationFailed(status);
const shouldShowBlock = (isUploaded || imageFromFileSystem) && !failedToLoadImage;
const hasValidImageSource = imageFromFileSystem || (isUploaded && resolvedSrc);
const shouldShowBlock = hasValidImageSource && !failedToLoadImage && !hasDuplicationFailed;
return (
<NodeViewWrapper>
@@ -8,8 +8,6 @@ import { DropHandlerPlugin } from "@/plugins/drop";
import { FilePlugins } from "@/plugins/file/root";
import { NodeHighlightPlugin } from "@/plugins/highlight";
import { MarkdownClipboardPlugin } from "@/plugins/markdown-clipboard";
// types
import { PasteAssetPlugin } from "@/plugins/paste-asset";
import type { IEditorProps, TEditorAsset, TFileHandler } from "@/types";
import { codemark } from "./code-mark";
@@ -83,7 +81,6 @@ export const UtilityExtension = (props: Props) => {
flaggedExtensions,
editor: this.editor,
}),
PasteAssetPlugin(),
NodeHighlightPlugin(),
];
},
@@ -96,6 +96,27 @@ export const getEditorRefHelpers = (args: TArgs): EditorRefApi => {
});
return markdown;
},
copyMarkdownToClipboard: () => {
if (!editor) return;
const html = editor.getHTML();
const metaData = getEditorMetaData(html);
const markdown = convertHTMLToMarkdown({
description_html: html,
metaData,
});
const copyHandler = (event: ClipboardEvent) => {
event.preventDefault();
event.clipboardData?.setData("text/plain", markdown);
event.clipboardData?.setData("text/html", html);
event.clipboardData?.setData("text/plane-editor-html", html);
document.removeEventListener("copy", copyHandler);
};
document.addEventListener("copy", copyHandler);
document.execCommand("copy");
},
isAnyDropbarOpen: () => {
if (!editor) return false;
const utilityStorage = editor.storage.utility;
@@ -0,0 +1,28 @@
import { assetDuplicationHandlers } from "@/plane-editor/helpers/asset-duplication";
// Utility function to process HTML content with all registered handlers
export const processAssetDuplication = (htmlContent: string): { processedHtml: string } => {
const tempDiv = document.createElement("div");
tempDiv.innerHTML = htmlContent;
let processedHtml = htmlContent;
// Process each registered component type
for (const [componentName, handler] of Object.entries(assetDuplicationHandlers)) {
const elements = tempDiv.querySelectorAll(componentName);
if (elements.length > 0) {
elements.forEach((element) => {
const result = handler({ element, originalHtml: processedHtml });
if (result.shouldProcess) {
processedHtml = result.modifiedHtml;
}
});
// Update tempDiv with processed HTML for next iteration
tempDiv.innerHTML = processedHtml;
}
}
return { processedHtml };
};
@@ -32,6 +32,7 @@ export const MarkdownClipboardPlugin = (args: TArgs): Plugin => {
});
event.clipboardData?.setData("text/plain", markdown);
event.clipboardData?.setData("text/html", clipboardHTML);
event.clipboardData?.setData("text/plane-editor-html", clipboardHTML);
return true;
} catch (error) {
console.error("Failed to copy markdown content to clipboard:", error);
@@ -1,77 +0,0 @@
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { assetDuplicationHandlers } from "@/plane-editor/helpers/asset-duplication";
export const PasteAssetPlugin = (): Plugin =>
new Plugin({
key: new PluginKey("paste-asset-duplication"),
props: {
handlePaste: (view, event) => {
if (!event.clipboardData) return false;
const htmlContent = event.clipboardData.getData("text/html");
if (!htmlContent || htmlContent.includes('data-uploaded="true"')) return false;
// Process the HTML content using the registry
const { processedHtml, hasChanges } = processAssetDuplication(htmlContent);
if (!hasChanges) return false;
event.preventDefault();
event.stopPropagation();
// Mark the content as already processed to avoid infinite loops
const tempDiv = document.createElement("div");
tempDiv.innerHTML = processedHtml;
const metaTag = tempDiv.querySelector("meta[charset='utf-8']");
if (metaTag) {
metaTag.setAttribute("data-uploaded", "true");
}
const finalHtml = tempDiv.innerHTML;
const newDataTransfer = new DataTransfer();
newDataTransfer.setData("text/html", finalHtml);
if (event.clipboardData) {
newDataTransfer.setData("text/plain", event.clipboardData.getData("text/plain"));
}
const pasteEvent = new ClipboardEvent("paste", {
clipboardData: newDataTransfer,
bubbles: true,
cancelable: true,
});
view.dom.dispatchEvent(pasteEvent);
return true;
},
},
});
// Utility function to process HTML content with all registered handlers
const processAssetDuplication = (htmlContent: string): { processedHtml: string; hasChanges: boolean } => {
const tempDiv = document.createElement("div");
tempDiv.innerHTML = htmlContent;
let processedHtml = htmlContent;
let hasChanges = false;
// Process each registered component type
for (const [componentName, handler] of Object.entries(assetDuplicationHandlers)) {
const elements = tempDiv.querySelectorAll(componentName);
if (elements.length > 0) {
elements.forEach((element) => {
const result = handler({ element, originalHtml: processedHtml });
if (result.shouldProcess) {
processedHtml = result.modifiedHtml;
hasChanges = true;
}
});
// Update tempDiv with processed HTML for next iteration
tempDiv.innerHTML = processedHtml;
}
}
return { processedHtml, hasChanges };
};
+12
View File
@@ -1,6 +1,8 @@
import type { EditorProps } from "@tiptap/pm/view";
// plane utils
import { cn } from "@plane/utils";
// helpers
import { processAssetDuplication } from "@/helpers/paste-asset";
type TArgs = {
editorClassName: string;
@@ -46,6 +48,16 @@ export const CoreEditorProps = (props: TArgs): EditorProps => {
}
},
},
handlePaste: (view, event) => {
if (!event.clipboardData) return false;
const htmlContent = event.clipboardData.getData("text/plane-editor-html");
if (!htmlContent) return false;
const { processedHtml } = processAssetDuplication(htmlContent);
view.pasteHTML(processedHtml);
return true;
},
transformPastedHTML(html) {
return stripCommentMarksFromHTML(html);
},
+1
View File
@@ -134,6 +134,7 @@ export type CoreEditorRefApi = {
getDocumentInfo: () => TDocumentInfo;
getHeadings: () => IMarking[];
getMarkDown: () => string;
copyMarkdownToClipboard: () => void;
getSelectedText: () => string | null;
insertText: (contentHTML: string, insertOnNextLine?: boolean) => void;
isAnyDropbarOpen: () => boolean;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/hooks",
"version": "1.1.0",
"version": "1.2.0",
"license": "AGPL-3.0",
"description": "React hooks that are shared across multiple apps internally",
"private": true,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/i18n",
"version": "1.1.0",
"version": "1.2.0",
"license": "AGPL-3.0",
"description": "I18n shared across multiple apps internally",
"private": true,
@@ -1580,6 +1580,7 @@ export default {
name: "Název pracovního prostoru",
company_size: "Velikost společnosti",
url: "URL pracovního prostoru",
workspace_timezone: "Časové pásmo pracovního prostoru",
update_workspace: "Aktualizovat prostor",
delete_workspace: "Smazat tento prostor",
delete_workspace_description: "Smazáním prostoru odstraníte všechna data a zdroje. Akce je nevratná.",
@@ -1600,6 +1600,7 @@ export default {
name: "Name des Arbeitsbereichs",
company_size: "Unternehmensgröße",
url: "URL des Arbeitsbereichs",
workspace_timezone: "Zeitzone des Arbeitsbereichs",
update_workspace: "Arbeitsbereich aktualisieren",
delete_workspace: "Diesen Arbeitsbereich löschen",
delete_workspace_description:
@@ -1442,6 +1442,7 @@ export default {
name: "Workspace name",
company_size: "Company size",
url: "Workspace URL",
workspace_timezone: "Workspace Timezone",
update_workspace: "Update workspace",
delete_workspace: "Delete this workspace",
delete_workspace_description:
@@ -1603,6 +1603,7 @@ export default {
name: "Nombre del espacio de trabajo",
company_size: "Tamaño de la empresa",
url: "URL del espacio de trabajo",
workspace_timezone: "Zona horaria del espacio de trabajo",
update_workspace: "Actualizar espacio de trabajo",
delete_workspace: "Eliminar este espacio de trabajo",
delete_workspace_description:
@@ -1601,6 +1601,7 @@ export default {
name: "Nom de lespace de travail",
company_size: "Taille de lentreprise",
url: "URL de lespace de travail",
workspace_timezone: "Fuseau horaire de lespace de travail",
update_workspace: "Mettre à jour lespace de travail",
delete_workspace: "Supprimer cet espace de travail",
delete_workspace_description:
@@ -1588,6 +1588,7 @@ export default {
name: "Nama ruang kerja",
company_size: "Ukuran perusahaan",
url: "URL ruang kerja",
workspace_timezone: "Zona waktu ruang kerja",
update_workspace: "Perbarui ruang kerja",
delete_workspace: "Hapus ruang kerja ini",
delete_workspace_description:
@@ -1593,6 +1593,7 @@ export default {
name: "Nome dello spazio di lavoro",
company_size: "Dimensione aziendale",
url: "URL dello spazio di lavoro",
workspace_timezone: "Fuso orario dello spazio di lavoro",
update_workspace: "Aggiorna spazio di lavoro",
delete_workspace: "Elimina questo spazio di lavoro",
delete_workspace_description:
@@ -1579,6 +1579,7 @@ export default {
name: "ワークスペース名",
company_size: "会社の規模",
url: "ワークスペースURL",
workspace_timezone: "ワークスペースのタイムゾーン",
update_workspace: "ワークスペースを更新",
delete_workspace: "このワークスペースを削除",
delete_workspace_description:
@@ -1572,6 +1572,7 @@ export default {
name: "작업 공간 이름",
company_size: "회사 규모",
url: "작업 공간 URL",
workspace_timezone: "작업 공간 시간대",
update_workspace: "작업 공간 업데이트",
delete_workspace: "이 작업 공간 삭제",
delete_workspace_description:
@@ -1583,6 +1583,7 @@ export default {
name: "Nazwa przestrzeni roboczej",
company_size: "Rozmiar firmy",
url: "URL przestrzeni roboczej",
workspace_timezone: "Strefa czasowa przestrzeni roboczej",
update_workspace: "Zaktualizuj przestrzeń",
delete_workspace: "Usuń tę przestrzeń",
delete_workspace_description:
@@ -1601,6 +1601,7 @@ export default {
name: "Nome do espaço de trabalho",
company_size: "Tamanho da empresa",
url: "URL do espaço de trabalho",
workspace_timezone: "Fuso horário do espaço de trabalho",
update_workspace: "Atualizar espaço de trabalho",
delete_workspace: "Excluir este espaço de trabalho",
delete_workspace_description:
@@ -1593,6 +1593,7 @@ export default {
name: "Numele spațiului de lucru",
company_size: "Dimensiunea companiei",
url: "URL-ul spațiului de lucru",
workspace_timezone: "Fusul orar al spațiului de lucru",
update_workspace: "Actualizează spațiul de lucru",
delete_workspace: "Șterge acest spațiu de lucru",
delete_workspace_description:
@@ -1586,6 +1586,7 @@ export default {
name: "Название пространства",
company_size: "Размер компании",
url: "URL пространства",
workspace_timezone: "Часовой пояс рабочего пространства",
update_workspace: "Обновить пространство",
delete_workspace: "Удалить пространство",
delete_workspace_description: "Все данные будут безвозвратно удалены.",
@@ -1583,6 +1583,7 @@ export default {
name: "Názov pracovného priestoru",
company_size: "Veľkosť spoločnosti",
url: "URL pracovného priestoru",
workspace_timezone: "Časové pásmo pracovného priestoru",
update_workspace: "Aktualizovať priestor",
delete_workspace: "Zmazať tento priestor",
delete_workspace_description: "Zmazaním priestoru odstránite všetky dáta a zdroje. Akcia je nevratná.",
@@ -1588,6 +1588,7 @@ export default {
name: "Çalışma Alanı Adı",
company_size: "Şirket Büyüklüğü",
url: "Çalışma Alanı URL'si",
workspace_timezone: "Çalışma Alanı Saat Dilimi",
update_workspace: "Çalışma Alanını Güncelle",
delete_workspace: "Bu çalışma alanını sil",
delete_workspace_description:
@@ -1587,6 +1587,7 @@ export default {
name: "Назва робочого простору",
company_size: "Розмір компанії",
url: "URL робочого простору",
workspace_timezone: "Часовий пояс робочого простору",
update_workspace: "Оновити простір",
delete_workspace: "Видалити цей простір",
delete_workspace_description: "Видалення простору призведе до втрати всіх даних і ресурсів. Дія незворотна.",
@@ -1589,6 +1589,7 @@ export default {
name: "Tên không gian làm việc",
company_size: "Quy mô công ty",
url: "URL không gian làm việc",
workspace_timezone: "Múi giờ không gian làm việc",
update_workspace: "Cập nhật không gian làm việc",
delete_workspace: "Xóa không gian làm việc này",
delete_workspace_description:
@@ -1562,6 +1562,7 @@ export default {
name: "工作区名称",
company_size: "公司规模",
url: "工作区网址",
workspace_timezone: "工作区时区",
update_workspace: "更新工作区",
delete_workspace: "删除此工作区",
delete_workspace_description: "删除工作区时,该工作区内的所有数据和资源将被永久删除,且无法恢复。",
@@ -1563,6 +1563,7 @@ export default {
name: "工作區名稱",
company_size: "公司規模",
url: "工作區網址",
workspace_timezone: "工作區時區",
update_workspace: "更新工作區",
delete_workspace: "刪除此工作區",
delete_workspace_description: "刪除工作區時,該工作區內的所有資料和資源都將被永久移除且無法復原。",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/logger",
"version": "1.1.0",
"version": "1.2.0",
"license": "AGPL-3.0",
"description": "Logger shared across multiple apps internally",
"private": true,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/propel",
"version": "1.1.0",
"version": "1.2.0",
"private": true,
"license": "AGPL-3.0",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/services",
"version": "1.1.0",
"version": "1.2.0",
"license": "AGPL-3.0",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/shared-state",
"version": "1.1.0",
"version": "1.2.0",
"license": "AGPL-3.0",
"description": "Shared state shared across multiple apps internally",
"private": true,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/tailwind-config",
"version": "1.1.0",
"version": "1.2.0",
"license": "AGPL-3.0",
"description": "common tailwind configuration across monorepo",
"main": "tailwind.config.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/types",
"version": "1.1.0",
"version": "1.2.0",
"license": "AGPL-3.0",
"private": true,
"type": "module",
+2 -1
View File
@@ -32,6 +32,7 @@ export interface IWorkspace {
current_plan?: EProductSubscriptionEnum;
is_on_trial?: boolean;
role: number;
timezone: string;
}
export interface IWorkspaceLite {
@@ -234,7 +235,7 @@ export interface IWorkspaceProgressResponse {
unstarted_issues: number;
}
export interface IWorkspaceAnalyticsResponse {
completion_chart: any;
completion_chart: Record<string, unknown>;
}
export type TWorkspacePaginationInfo = TPaginationInfo & {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/typescript-config",
"version": "1.1.0",
"version": "1.2.0",
"license": "AGPL-3.0",
"private": true,
"files": [
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@plane/ui",
"description": "UI components shared across multiple apps internally",
"private": true,
"version": "1.1.0",
"version": "1.2.0",
"sideEffects": false,
"license": "AGPL-3.0",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/utils",
"version": "1.1.0",
"version": "1.2.0",
"description": "Helper functions shared across multiple apps internally",
"license": "AGPL-3.0",
"private": true,