mirror of
https://github.com/makeplane/plane
synced 2025-08-07 19:59:33 +00:00
Compare commits
18 Commits
chore-main
...
refactor/l
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91f5f3f07f | ||
|
|
ec5a2126b1 | ||
|
|
6078ac6f2d | ||
|
|
92f7fd51ef | ||
|
|
139919ddcb | ||
|
|
327c83deea | ||
|
|
1e4fc069de | ||
|
|
e663db5474 | ||
|
|
2e218b1bfb | ||
|
|
c9a4547fa4 | ||
|
|
15f2912d6a | ||
|
|
fb87d4d293 | ||
|
|
ed21da7adf | ||
|
|
0aef74bd47 | ||
|
|
9482fe4509 | ||
|
|
18c0b0b0e2 | ||
|
|
9eaf4b50dd | ||
|
|
e366788176 |
@@ -1,6 +1,3 @@
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import URLValidator
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from lxml import html
|
||||
@@ -30,6 +27,9 @@ from .module import ModuleLiteSerializer, ModuleSerializer
|
||||
from .state import StateLiteSerializer
|
||||
from .user import UserLiteSerializer
|
||||
|
||||
# Django imports
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import URLValidator
|
||||
|
||||
class IssueSerializer(BaseSerializer):
|
||||
assignees = serializers.ListField(
|
||||
@@ -315,7 +315,7 @@ class IssueLinkSerializer(BaseSerializer):
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
|
||||
def validate_url(self, value):
|
||||
# Check URL format
|
||||
validate_url = URLValidator()
|
||||
|
||||
@@ -437,17 +437,21 @@ class IssueLinkSerializer(BaseSerializer):
|
||||
"issue",
|
||||
]
|
||||
|
||||
def validate_url(self, value):
|
||||
# Check URL format
|
||||
validate_url = URLValidator()
|
||||
try:
|
||||
validate_url(value)
|
||||
except ValidationError:
|
||||
raise serializers.ValidationError("Invalid URL format.")
|
||||
def to_internal_value(self, data):
|
||||
# Modify the URL before validation by appending http:// if missing
|
||||
url = data.get("url", "")
|
||||
if url and not url.startswith(("http://", "https://")):
|
||||
data["url"] = "http://" + url
|
||||
|
||||
# Check URL scheme
|
||||
if not value.startswith(("http://", "https://")):
|
||||
raise serializers.ValidationError("Invalid URL scheme.")
|
||||
return super().to_internal_value(data)
|
||||
|
||||
def validate_url(self, value):
|
||||
# Use Django's built-in URLValidator for validation
|
||||
url_validator = URLValidator()
|
||||
try:
|
||||
url_validator(value)
|
||||
except ValidationError:
|
||||
raise serializers.ValidationError({"error": "Invalid URL format."})
|
||||
|
||||
return value
|
||||
|
||||
@@ -533,7 +537,7 @@ class IssueReactionSerializer(BaseSerializer):
|
||||
"project",
|
||||
"issue",
|
||||
"actor",
|
||||
"deleted_at"
|
||||
"deleted_at",
|
||||
]
|
||||
|
||||
|
||||
@@ -552,7 +556,13 @@ class CommentReactionSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = CommentReaction
|
||||
fields = "__all__"
|
||||
read_only_fields = ["workspace", "project", "comment", "actor", "deleted_at"]
|
||||
read_only_fields = [
|
||||
"workspace",
|
||||
"project",
|
||||
"comment",
|
||||
"actor",
|
||||
"deleted_at",
|
||||
]
|
||||
|
||||
|
||||
class IssueVoteSerializer(BaseSerializer):
|
||||
|
||||
@@ -5,6 +5,10 @@ from rest_framework import serializers
|
||||
from .base import BaseSerializer, DynamicBaseSerializer
|
||||
from .project import ProjectLiteSerializer
|
||||
|
||||
# Django imports
|
||||
from django.core.validators import URLValidator
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from plane.db.models import (
|
||||
User,
|
||||
Module,
|
||||
@@ -155,16 +159,48 @@ class ModuleLinkSerializer(BaseSerializer):
|
||||
"module",
|
||||
]
|
||||
|
||||
# Validation if url already exists
|
||||
def to_internal_value(self, data):
|
||||
# Modify the URL before validation by appending http:// if missing
|
||||
url = data.get("url", "")
|
||||
if url and not url.startswith(("http://", "https://")):
|
||||
data["url"] = "http://" + url
|
||||
|
||||
return super().to_internal_value(data)
|
||||
|
||||
def validate_url(self, value):
|
||||
# Use Django's built-in URLValidator for validation
|
||||
url_validator = URLValidator()
|
||||
try:
|
||||
url_validator(value)
|
||||
except ValidationError:
|
||||
raise serializers.ValidationError({"error": "Invalid URL format."})
|
||||
|
||||
return value
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data["url"] = self.validate_url(validated_data.get("url"))
|
||||
if ModuleLink.objects.filter(
|
||||
url=validated_data.get("url"),
|
||||
module_id=validated_data.get("module_id"),
|
||||
).exists():
|
||||
raise serializers.ValidationError({"error": "URL already exists."})
|
||||
return super().create(validated_data)
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
validated_data["url"] = self.validate_url(validated_data.get("url"))
|
||||
if (
|
||||
ModuleLink.objects.filter(
|
||||
url=validated_data.get("url"),
|
||||
module_id=instance.module_id,
|
||||
)
|
||||
.exclude(pk=instance.id)
|
||||
.exists()
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
{"error": "URL already exists for this Issue"}
|
||||
)
|
||||
return ModuleLink.objects.create(**validated_data)
|
||||
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
class ModuleSerializer(DynamicBaseSerializer):
|
||||
@@ -229,7 +265,14 @@ class ModuleDetailSerializer(ModuleSerializer):
|
||||
cancelled_estimate_points = serializers.FloatField(read_only=True)
|
||||
|
||||
class Meta(ModuleSerializer.Meta):
|
||||
fields = ModuleSerializer.Meta.fields + ["link_module", "sub_issues", "backlog_estimate_points", "unstarted_estimate_points", "started_estimate_points", "cancelled_estimate_points"]
|
||||
fields = ModuleSerializer.Meta.fields + [
|
||||
"link_module",
|
||||
"sub_issues",
|
||||
"backlog_estimate_points",
|
||||
"unstarted_estimate_points",
|
||||
"started_estimate_points",
|
||||
"cancelled_estimate_points",
|
||||
]
|
||||
|
||||
|
||||
class ModuleUserPropertiesSerializer(BaseSerializer):
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export * from "./bulk-delete-issues-modal";
|
||||
export * from "./existing-issues-list-modal";
|
||||
export * from "./gpt-assistant-popover";
|
||||
export * from "./link-modal";
|
||||
export * from "./user-image-upload-modal";
|
||||
export * from "./workspace-image-upload-modal";
|
||||
export * from "./issue-search-modal-empty-state";
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { FC, useEffect, Fragment } from "react";
|
||||
// react-hook-form
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// headless ui
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import type { IIssueLink, ILinkDetails, ModuleLink } from "@plane/types";
|
||||
// ui
|
||||
import { Button, Input } from "@plane/ui";
|
||||
// types
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
handleClose: () => void;
|
||||
data?: ILinkDetails | null;
|
||||
status: boolean;
|
||||
createIssueLink: (formData: IIssueLink | ModuleLink) => Promise<ILinkDetails> | Promise<void> | void;
|
||||
updateIssueLink: (formData: IIssueLink | ModuleLink, linkId: string) => Promise<ILinkDetails> | Promise<void> | void;
|
||||
};
|
||||
|
||||
const defaultValues: IIssueLink | ModuleLink = {
|
||||
title: "",
|
||||
url: "",
|
||||
};
|
||||
|
||||
export const LinkModal: FC<Props> = (props) => {
|
||||
const { isOpen, handleClose, createIssueLink, updateIssueLink, status, data } = props;
|
||||
// form info
|
||||
const {
|
||||
formState: { errors, isSubmitting },
|
||||
handleSubmit,
|
||||
control,
|
||||
reset,
|
||||
} = useForm<IIssueLink | ModuleLink>({
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const onClose = () => {
|
||||
handleClose();
|
||||
const timeout = setTimeout(() => {
|
||||
reset(defaultValues);
|
||||
clearTimeout(timeout);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (formData: IIssueLink | ModuleLink) => {
|
||||
if (!data) await createIssueLink({ title: formData.title, url: formData.url });
|
||||
else await updateIssueLink({ title: formData.title, url: formData.url }, data.id);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleCreateUpdatePage = async (formData: IIssueLink | ModuleLink) => {
|
||||
await handleFormSubmit(formData);
|
||||
|
||||
reset({
|
||||
...defaultValues,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
reset({
|
||||
...defaultValues,
|
||||
...data,
|
||||
});
|
||||
}, [data, reset]);
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-20" onClose={onClose}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-10 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-custom-background-100 px-5 py-8 text-left shadow-custom-shadow-md transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
|
||||
<form onSubmit={handleSubmit(handleCreateUpdatePage)}>
|
||||
<div>
|
||||
<div className="space-y-5">
|
||||
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-custom-text-100">
|
||||
{status ? "Update Link" : "Add Link"}
|
||||
</Dialog.Title>
|
||||
<div className="mt-2 space-y-3">
|
||||
<div>
|
||||
<label htmlFor="url" className="mb-2 text-custom-text-200">
|
||||
URL
|
||||
</label>
|
||||
<Controller
|
||||
control={control}
|
||||
name="url"
|
||||
rules={{
|
||||
required: "URL is required",
|
||||
}}
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="url"
|
||||
name="url"
|
||||
type="url"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.url)}
|
||||
placeholder="https://..."
|
||||
pattern="^(https?://).*"
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="title" className="mb-2 text-custom-text-200">
|
||||
{`Title (optional)`}
|
||||
</label>
|
||||
<Controller
|
||||
control={control}
|
||||
name="title"
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="title"
|
||||
name="title"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.title)}
|
||||
placeholder="Enter title"
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<Button variant="neutral-primary" size="sm" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" type="submit" loading={isSubmitting}>
|
||||
{status
|
||||
? isSubmitting
|
||||
? "Updating link..."
|
||||
: "Update link"
|
||||
: isSubmitting
|
||||
? "Adding link..."
|
||||
: "Add link"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,2 @@
|
||||
export * from "./links-list";
|
||||
export * from "./single-progress-stats";
|
||||
export * from "./sidebar-menu-hamburger-toggle";
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
"use client";
|
||||
import { observer } from "mobx-react";
|
||||
// icons
|
||||
import { Pencil, Trash2, LinkIcon, ExternalLink } from "lucide-react";
|
||||
import { ILinkDetails, UserAuth } from "@plane/types";
|
||||
// ui
|
||||
import { Tooltip, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// helpers
|
||||
import { calculateTimeAgo } from "@/helpers/date-time.helper";
|
||||
// hooks
|
||||
import { useMember, useModule } from "@/hooks/store";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// types
|
||||
|
||||
type Props = {
|
||||
moduleId: string;
|
||||
|
||||
handleDeleteLink: (linkId: string) => void;
|
||||
handleEditLink: (link: ILinkDetails) => void;
|
||||
userAuth: UserAuth;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const LinksList: React.FC<Props> = observer((props) => {
|
||||
const { moduleId, handleDeleteLink, handleEditLink, userAuth, disabled } = props;
|
||||
// hooks
|
||||
const { getUserDetails } = useMember();
|
||||
const { isMobile } = usePlatformOS();
|
||||
const { getModuleById } = useModule();
|
||||
// derived values
|
||||
const currentModule = getModuleById(moduleId);
|
||||
const moduleLinks = currentModule?.link_module || undefined;
|
||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disabled;
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Copied to clipboard",
|
||||
message: "The URL has been successfully copied to your clipboard",
|
||||
});
|
||||
};
|
||||
|
||||
if (!moduleLinks) return <></>;
|
||||
return (
|
||||
<>
|
||||
{moduleLinks.map((link) => {
|
||||
const createdByDetails = getUserDetails(link.created_by);
|
||||
return (
|
||||
<div key={link.id} className="relative flex flex-col rounded-md bg-custom-background-90 p-2.5">
|
||||
<div className="flex w-full items-start justify-between gap-2">
|
||||
<div className="flex items-start gap-2 truncate">
|
||||
<span className="py-1">
|
||||
<LinkIcon className="h-3 w-3 flex-shrink-0" />
|
||||
</span>
|
||||
<Tooltip tooltipContent={link.title && link.title !== "" ? link.title : link.url} isMobile={isMobile}>
|
||||
<span
|
||||
className="cursor-pointer truncate text-xs"
|
||||
onClick={() => copyToClipboard(link.title && link.title !== "" ? link.title : link.url)}
|
||||
>
|
||||
{link.title && link.title !== "" ? link.title : link.url}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{!isNotAllowed && (
|
||||
<div className="z-[1] flex flex-shrink-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-center p-1 hover:bg-custom-background-80"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleEditLink(link);
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-3 w-3 stroke-[1.5] text-custom-text-200" />
|
||||
</button>
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center p-1 hover:bg-custom-background-80"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3 stroke-[1.5] text-custom-text-200" />
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-center p-1 hover:bg-custom-background-80"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleDeleteLink(link.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-5">
|
||||
<p className="mt-0.5 stroke-[1.5] text-xs text-custom-text-300">
|
||||
Added {calculateTimeAgo(link.created_at)}
|
||||
<br />
|
||||
{createdByDetails && (
|
||||
<>
|
||||
by{" "}
|
||||
{createdByDetails?.is_bot ? createdByDetails?.first_name + " Bot" : createdByDetails?.display_name}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -1,12 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { FC, useEffect, Fragment } from "react";
|
||||
import { FC, useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// plane types
|
||||
import type { TIssueLinkEditableFields } from "@plane/types";
|
||||
// ui
|
||||
import { Button, Input } from "@plane/ui";
|
||||
// plane ui
|
||||
import { Button, Input, ModalCore } from "@plane/ui";
|
||||
// helpers
|
||||
import { checkURLValidity } from "@/helpers/string.helper";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store";
|
||||
// types
|
||||
import { TLinkOperations } from "./root";
|
||||
@@ -31,7 +34,6 @@ const defaultValues: TIssueLinkCreateFormFieldOptions = {
|
||||
export const IssueLinkCreateUpdateModal: FC<TIssueLinkCreateEditModal> = observer((props) => {
|
||||
// props
|
||||
const { isModalOpen, handleOnClose, linkOperations } = props;
|
||||
|
||||
// react hook form
|
||||
const {
|
||||
formState: { errors, isSubmitting },
|
||||
@@ -41,12 +43,12 @@ export const IssueLinkCreateUpdateModal: FC<TIssueLinkCreateEditModal> = observe
|
||||
} = useForm<TIssueLinkCreateFormFieldOptions>({
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
// store hooks
|
||||
const { issueLinkData: preloadedData, setIssueLinkData } = useIssueDetail();
|
||||
|
||||
const onClose = () => {
|
||||
setIssueLinkData(null);
|
||||
reset(defaultValues);
|
||||
reset();
|
||||
if (handleOnClose) handleOnClose();
|
||||
};
|
||||
|
||||
@@ -61,110 +63,70 @@ export const IssueLinkCreateUpdateModal: FC<TIssueLinkCreateEditModal> = observe
|
||||
}, [preloadedData, reset, isModalOpen]);
|
||||
|
||||
return (
|
||||
<Transition.Root show={isModalOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-20" onClose={onClose}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-10 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-custom-background-100 px-5 py-8 text-left shadow-custom-shadow-md transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)}>
|
||||
<div>
|
||||
<div className="space-y-5">
|
||||
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-custom-text-100">
|
||||
{preloadedData?.id ? "Update link" : "Add link"}
|
||||
</Dialog.Title>
|
||||
<div className="mt-2 space-y-3">
|
||||
<div>
|
||||
<label htmlFor="url" className="mb-2 text-custom-text-200">
|
||||
URL
|
||||
</label>
|
||||
<Controller
|
||||
control={control}
|
||||
name="url"
|
||||
rules={{
|
||||
required: "URL is required",
|
||||
}}
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="url"
|
||||
name="url"
|
||||
type="url"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.url)}
|
||||
placeholder="https://..."
|
||||
pattern="^(https?://).*"
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="title" className="mb-2 text-custom-text-200">
|
||||
{`Title (optional)`}
|
||||
</label>
|
||||
<Controller
|
||||
control={control}
|
||||
name="title"
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="title"
|
||||
name="title"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.title)}
|
||||
placeholder="Enter title"
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<Button variant="neutral-primary" size="sm" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" type="submit" loading={isSubmitting}>
|
||||
{preloadedData?.id
|
||||
? isSubmitting
|
||||
? "Updating link..."
|
||||
: "Update link"
|
||||
: isSubmitting
|
||||
? "Adding link..."
|
||||
: "Add link"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
<ModalCore isOpen={isModalOpen} handleClose={onClose}>
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)}>
|
||||
<div className="space-y-5 p-5">
|
||||
<h3 className="text-xl font-medium text-custom-text-200">{preloadedData?.id ? "Update" : "Add"} link</h3>
|
||||
<div className="mt-2 space-y-3">
|
||||
<div>
|
||||
<label htmlFor="url" className="mb-2 text-custom-text-200">
|
||||
URL
|
||||
</label>
|
||||
<Controller
|
||||
control={control}
|
||||
name="url"
|
||||
rules={{
|
||||
required: "URL is required",
|
||||
validate: (value) => checkURLValidity(value) || "URL is invalid",
|
||||
}}
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="url"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.url)}
|
||||
placeholder="Type or paste a URL"
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.url && <span className="text-xs text-red-500">URL is invalid</span>}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="title" className="mb-2 text-custom-text-200">
|
||||
Display title
|
||||
<span className="text-[10px] block">Optional</span>
|
||||
</label>
|
||||
<Controller
|
||||
control={control}
|
||||
name="title"
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="title"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.title)}
|
||||
placeholder="What you'd like to see this link as"
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
<div className="px-5 py-4 flex items-center justify-end gap-2 border-t-[0.5px] border-custom-border-200">
|
||||
<Button variant="neutral-primary" size="sm" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" type="submit" loading={isSubmitting}>
|
||||
{preloadedData?.id ? (isSubmitting ? "Updating" : "Update") : isSubmitting ? "Adding" : "Add"} link
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</ModalCore>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -24,15 +24,13 @@ export const LinkList: FC<TLinkList> = observer((props) => {
|
||||
|
||||
const issueLinks = getLinksByIssueId(issueId);
|
||||
|
||||
if (!issueLinks) return <></>;
|
||||
if (!issueLinks) return null;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-12 3xl:grid-cols-10 gap-2 px-9 py-4">
|
||||
{issueLinks &&
|
||||
issueLinks.length > 0 &&
|
||||
issueLinks.map((linkId) => (
|
||||
<IssueLinkItem key={linkId} linkId={linkId} linkOperations={linkOperations} isNotAllowed={disabled} />
|
||||
))}
|
||||
{issueLinks.map((linkId) => (
|
||||
<IssueLinkItem key={linkId} linkId={linkId} linkOperations={linkOperations} isNotAllowed={disabled} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,8 +17,9 @@ import {
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { Disclosure, Transition } from "@headlessui/react";
|
||||
// plane types
|
||||
import { ILinkDetails, IModule, ModuleLink } from "@plane/types";
|
||||
// ui
|
||||
// plane ui
|
||||
import {
|
||||
CustomMenu,
|
||||
Loader,
|
||||
@@ -31,9 +32,14 @@ import {
|
||||
TextArea,
|
||||
} from "@plane/ui";
|
||||
// components
|
||||
import { LinkModal, LinksList } from "@/components/core";
|
||||
import { DateRangeDropdown, MemberDropdown } from "@/components/dropdowns";
|
||||
import { ArchiveModuleModal, DeleteModuleModal, ModuleAnalyticsProgress } from "@/components/modules";
|
||||
import {
|
||||
ArchiveModuleModal,
|
||||
DeleteModuleModal,
|
||||
CreateUpdateModuleLinkModal,
|
||||
ModuleAnalyticsProgress,
|
||||
ModuleLinksList,
|
||||
} from "@/components/modules";
|
||||
import {
|
||||
MODULE_LINK_CREATED,
|
||||
MODULE_LINK_DELETED,
|
||||
@@ -121,25 +127,12 @@ export const ModuleAnalyticsSidebar: React.FC<Props> = observer((props) => {
|
||||
|
||||
const payload = { metadata: {}, ...formData };
|
||||
|
||||
createModuleLink(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), payload)
|
||||
.then(() => {
|
||||
captureEvent(MODULE_LINK_CREATED, {
|
||||
module_id: moduleId,
|
||||
state: "SUCCESS",
|
||||
});
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Module link created successfully.",
|
||||
});
|
||||
await createModuleLink(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), payload).then(() =>
|
||||
captureEvent(MODULE_LINK_CREATED, {
|
||||
module_id: moduleId,
|
||||
state: "SUCCESS",
|
||||
})
|
||||
.catch(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Some error occurred",
|
||||
});
|
||||
});
|
||||
);
|
||||
};
|
||||
|
||||
const handleUpdateLink = async (formData: ModuleLink, linkId: string) => {
|
||||
@@ -147,25 +140,13 @@ export const ModuleAnalyticsSidebar: React.FC<Props> = observer((props) => {
|
||||
|
||||
const payload = { metadata: {}, ...formData };
|
||||
|
||||
updateModuleLink(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), linkId, payload)
|
||||
.then(() => {
|
||||
await updateModuleLink(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), linkId, payload).then(
|
||||
() =>
|
||||
captureEvent(MODULE_LINK_UPDATED, {
|
||||
module_id: moduleId,
|
||||
state: "SUCCESS",
|
||||
});
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Module link updated successfully.",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Some error occurred",
|
||||
});
|
||||
});
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handleDeleteLink = async (linkId: string) => {
|
||||
@@ -287,16 +268,17 @@ export const ModuleAnalyticsSidebar: React.FC<Props> = observer((props) => {
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<LinkModal
|
||||
<CreateUpdateModuleLinkModal
|
||||
isOpen={moduleLinkModal}
|
||||
handleClose={() => {
|
||||
setModuleLinkModal(false);
|
||||
setSelectedLinkToUpdate(null);
|
||||
setTimeout(() => {
|
||||
setSelectedLinkToUpdate(null);
|
||||
}, 500);
|
||||
}}
|
||||
data={selectedLinkToUpdate}
|
||||
status={selectedLinkToUpdate ? true : false}
|
||||
createIssueLink={handleCreateLink}
|
||||
updateIssueLink={handleUpdateLink}
|
||||
createLink={handleCreateLink}
|
||||
updateLink={handleUpdateLink}
|
||||
/>
|
||||
{workspaceSlug && projectId && (
|
||||
<ArchiveModuleModal
|
||||
@@ -583,7 +565,7 @@ export const ModuleAnalyticsSidebar: React.FC<Props> = observer((props) => {
|
||||
)}
|
||||
|
||||
{moduleId && (
|
||||
<LinksList
|
||||
<ModuleLinksList
|
||||
moduleId={moduleId}
|
||||
handleEditLink={handleEditLink}
|
||||
handleDeleteLink={handleDeleteLink}
|
||||
|
||||
@@ -5,6 +5,7 @@ export * from "./sidebar-select";
|
||||
export * from "./delete-module-modal";
|
||||
export * from "./form";
|
||||
export * from "./gantt-chart";
|
||||
export * from "./links";
|
||||
export * from "./modal";
|
||||
export * from "./modules-list-view";
|
||||
export * from "./module-card-item";
|
||||
|
||||
146
web/core/components/modules/links/create-update-modal.tsx
Normal file
146
web/core/components/modules/links/create-update-modal.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import { FC, useEffect } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// plane types
|
||||
import type { ILinkDetails, ModuleLink } from "@plane/types";
|
||||
// plane ui
|
||||
import { Button, Input, ModalCore, setToast, TOAST_TYPE } from "@plane/ui";
|
||||
// helpers
|
||||
import { checkURLValidity } from "@/helpers/string.helper";
|
||||
|
||||
type Props = {
|
||||
createLink: (formData: ModuleLink) => Promise<void>;
|
||||
data?: ILinkDetails | null;
|
||||
isOpen: boolean;
|
||||
handleClose: () => void;
|
||||
updateLink: (formData: ModuleLink, linkId: string) => Promise<void>;
|
||||
};
|
||||
|
||||
const defaultValues: ModuleLink = {
|
||||
title: "",
|
||||
url: "",
|
||||
};
|
||||
|
||||
export const CreateUpdateModuleLinkModal: FC<Props> = (props) => {
|
||||
const { isOpen, handleClose, createLink, updateLink, data } = props;
|
||||
// form info
|
||||
const {
|
||||
formState: { errors, isSubmitting },
|
||||
handleSubmit,
|
||||
control,
|
||||
reset,
|
||||
} = useForm<ModuleLink>({
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const onClose = () => {
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (formData: ModuleLink) => {
|
||||
const payload = {
|
||||
title: formData.title,
|
||||
url: formData.url,
|
||||
};
|
||||
|
||||
try {
|
||||
if (!data) {
|
||||
await createLink(payload);
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Module link created successfully.",
|
||||
});
|
||||
} else {
|
||||
await updateLink(payload, data.id);
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Module link updated successfully.",
|
||||
});
|
||||
}
|
||||
onClose();
|
||||
} catch (error: any) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: error?.data?.error ?? "Some error occurred. Please try again.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
reset({
|
||||
...defaultValues,
|
||||
...data,
|
||||
});
|
||||
}, [data, isOpen, reset]);
|
||||
|
||||
return (
|
||||
<ModalCore isOpen={isOpen} handleClose={onClose}>
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)}>
|
||||
<div className="space-y-5 p-5">
|
||||
<h3 className="text-xl font-medium text-custom-text-200">{data ? "Update" : "Add"} link</h3>
|
||||
<div className="mt-2 space-y-3">
|
||||
<div>
|
||||
<label htmlFor="url" className="mb-2 text-custom-text-200">
|
||||
URL
|
||||
</label>
|
||||
<Controller
|
||||
control={control}
|
||||
name="url"
|
||||
rules={{
|
||||
required: "URL is required",
|
||||
validate: (value) => checkURLValidity(value) || "URL is invalid",
|
||||
}}
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="url"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.url)}
|
||||
placeholder="Type or paste a URL"
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="title" className="mb-2 text-custom-text-200">
|
||||
Display title
|
||||
<span className="text-[10px] block">Optional</span>
|
||||
</label>
|
||||
<Controller
|
||||
control={control}
|
||||
name="title"
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="title"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.title)}
|
||||
placeholder="What you'd like to see this link as"
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-5 py-4 flex items-center justify-end gap-2 border-t-[0.5px] border-custom-border-200">
|
||||
<Button variant="neutral-primary" size="sm" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" type="submit" loading={isSubmitting}>
|
||||
{data ? (isSubmitting ? "Updating link" : "Update link") : isSubmitting ? "Adding link" : "Add link"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</ModalCore>
|
||||
);
|
||||
};
|
||||
3
web/core/components/modules/links/index.ts
Normal file
3
web/core/components/modules/links/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./create-update-modal";
|
||||
export * from "./list-item";
|
||||
export * from "./list";
|
||||
105
web/core/components/modules/links/list-item.tsx
Normal file
105
web/core/components/modules/links/list-item.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { ExternalLink, LinkIcon, Pencil, Trash2 } from "lucide-react";
|
||||
// plane types
|
||||
import { ILinkDetails } from "@plane/types";
|
||||
// plane ui
|
||||
import { setToast, TOAST_TYPE, Tooltip } from "@plane/ui";
|
||||
// helpers
|
||||
import { calculateTimeAgo } from "@/helpers/date-time.helper";
|
||||
import { copyTextToClipboard } from "@/helpers/string.helper";
|
||||
// hooks
|
||||
import { useMember } from "@/hooks/store";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
|
||||
type Props = {
|
||||
handleDeleteLink: () => void;
|
||||
handleEditLink: () => void;
|
||||
isEditingAllowed: boolean;
|
||||
link: ILinkDetails;
|
||||
};
|
||||
|
||||
export const ModulesLinksListItem: React.FC<Props> = observer((props) => {
|
||||
const { handleDeleteLink, handleEditLink, isEditingAllowed, link } = props;
|
||||
// store hooks
|
||||
const { getUserDetails } = useMember();
|
||||
// derived values
|
||||
const createdByDetails = getUserDetails(link.created_by);
|
||||
// platform os
|
||||
const { isMobile } = usePlatformOS();
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
copyTextToClipboard(text).then(() =>
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Copied to clipboard",
|
||||
message: "The URL has been successfully copied to your clipboard",
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col rounded-md bg-custom-background-90 p-2.5">
|
||||
<div className="flex w-full items-start justify-between gap-2">
|
||||
<div className="flex items-start gap-2 truncate">
|
||||
<span className="py-1">
|
||||
<LinkIcon className="h-3 w-3 flex-shrink-0" />
|
||||
</span>
|
||||
<Tooltip tooltipContent={link.title && link.title !== "" ? link.title : link.url} isMobile={isMobile}>
|
||||
<span
|
||||
className="cursor-pointer truncate text-xs"
|
||||
onClick={() => copyToClipboard(link.title && link.title !== "" ? link.title : link.url)}
|
||||
>
|
||||
{link.title && link.title !== "" ? link.title : link.url}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="z-[1] flex flex-shrink-0 items-center">
|
||||
{isEditingAllowed && (
|
||||
<button
|
||||
type="button"
|
||||
className="grid place-items-center p-1 hover:bg-custom-background-80"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleEditLink();
|
||||
}}
|
||||
>
|
||||
<Pencil className="size-3 stroke-[1.5] text-custom-text-200" />
|
||||
</button>
|
||||
)}
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="grid place-items-center p-1 hover:bg-custom-background-80"
|
||||
>
|
||||
<ExternalLink className="size-3 stroke-[1.5] text-custom-text-200" />
|
||||
</a>
|
||||
{isEditingAllowed && (
|
||||
<button
|
||||
type="button"
|
||||
className="grid place-items-center p-1 hover:bg-custom-background-80"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleDeleteLink();
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3 stroke-[1.5] text-custom-text-200" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-5">
|
||||
<p className="mt-0.5 stroke-[1.5] text-xs text-custom-text-300">
|
||||
Added {calculateTimeAgo(link.created_at)}
|
||||
<br />
|
||||
{createdByDetails && (
|
||||
<>by {createdByDetails?.is_bot ? createdByDetails?.first_name + " Bot" : createdByDetails?.display_name}</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
45
web/core/components/modules/links/list.tsx
Normal file
45
web/core/components/modules/links/list.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
import { useCallback } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane types
|
||||
import { ILinkDetails, UserAuth } from "@plane/types";
|
||||
// components
|
||||
import { ModulesLinksListItem } from "@/components/modules";
|
||||
// hooks
|
||||
import { useModule } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
disabled?: boolean;
|
||||
handleDeleteLink: (linkId: string) => void;
|
||||
handleEditLink: (link: ILinkDetails) => void;
|
||||
moduleId: string;
|
||||
userAuth: UserAuth;
|
||||
};
|
||||
|
||||
export const ModuleLinksList: React.FC<Props> = observer((props) => {
|
||||
const { moduleId, handleDeleteLink, handleEditLink, userAuth, disabled } = props;
|
||||
// store hooks
|
||||
const { getModuleById } = useModule();
|
||||
// derived values
|
||||
const currentModule = getModuleById(moduleId);
|
||||
const moduleLinks = currentModule?.link_module;
|
||||
// memoized link handlers
|
||||
const memoizedDeleteLink = useCallback((id: string) => handleDeleteLink(id), [handleDeleteLink]);
|
||||
const memoizedEditLink = useCallback((link: ILinkDetails) => handleEditLink(link), [handleEditLink]);
|
||||
|
||||
if (!moduleLinks) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{moduleLinks.map((link) => (
|
||||
<ModulesLinksListItem
|
||||
key={link.id}
|
||||
handleDeleteLink={() => memoizedDeleteLink(link.id)}
|
||||
handleEditLink={() => memoizedEditLink(link)}
|
||||
isEditingAllowed={(userAuth.isMember || userAuth.isOwner) && !disabled}
|
||||
link={link}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -248,5 +248,27 @@ export const isEmptyHtmlString = (htmlString: string, allowedHTMLTags: string[]
|
||||
export const isCommentEmpty = (comment: string | undefined): boolean => {
|
||||
// return true if comment is undefined
|
||||
if (!comment) return true;
|
||||
return comment?.trim() === "" || comment === "<p></p>" || isEmptyHtmlString(comment ?? "", ["mention-component"]);
|
||||
return (
|
||||
comment?.trim() === "" || comment === "<p></p>" || isEmptyHtmlString(comment ?? "", ["img", "mention-component"])
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* @description
|
||||
* This function test whether a URL is valid or not.
|
||||
*
|
||||
* It accepts URLs with or without the protocol.
|
||||
* @param {string} url
|
||||
* @returns {boolean}
|
||||
* @example
|
||||
* checkURLValidity("https://example.com") => true
|
||||
* checkURLValidity("example.com") => true
|
||||
* checkURLValidity("example") => false
|
||||
*/
|
||||
export const checkURLValidity = (url: string): boolean => {
|
||||
if (!url) return false;
|
||||
// regex to match valid URLs (with or without http/https)
|
||||
const urlPattern = /^(https?:\/\/)?([\da-z.-]+)\.([a-z]{2,6})(\/[\w.-]*)*\/?(\?[=&\w.-]*)?$/i;
|
||||
// test if the URL matches the pattern
|
||||
return urlPattern.test(url);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user