[WEB-1933] refactor: link create/update for issues and modules (#5543)
* chore: added module and issue link validation * refactor: issues and modules link moda; * chore: changed the url validation logic * chore: code cleanup * refactor: modules link logic * chore: removed the validator function * fix: url validation regex * chore: removed unwanted imports * chore: reverted the external api changes * refactor: link modals * refactor: reset modal logic --------- Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
This commit is contained in:
parent
c95aa6a0f7
commit
68b412badf
16 changed files with 497 additions and 475 deletions
|
|
@ -1,6 +1,3 @@
|
||||||
from django.core.exceptions import ValidationError
|
|
||||||
from django.core.validators import URLValidator
|
|
||||||
|
|
||||||
# Django imports
|
# Django imports
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from lxml import html
|
from lxml import html
|
||||||
|
|
@ -30,6 +27,9 @@ from .module import ModuleLiteSerializer, ModuleSerializer
|
||||||
from .state import StateLiteSerializer
|
from .state import StateLiteSerializer
|
||||||
from .user import UserLiteSerializer
|
from .user import UserLiteSerializer
|
||||||
|
|
||||||
|
# Django imports
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.core.validators import URLValidator
|
||||||
|
|
||||||
class IssueSerializer(BaseSerializer):
|
class IssueSerializer(BaseSerializer):
|
||||||
assignees = serializers.ListField(
|
assignees = serializers.ListField(
|
||||||
|
|
|
||||||
|
|
@ -437,17 +437,21 @@ class IssueLinkSerializer(BaseSerializer):
|
||||||
"issue",
|
"issue",
|
||||||
]
|
]
|
||||||
|
|
||||||
def validate_url(self, value):
|
def to_internal_value(self, data):
|
||||||
# Check URL format
|
# Modify the URL before validation by appending http:// if missing
|
||||||
validate_url = URLValidator()
|
url = data.get("url", "")
|
||||||
try:
|
if url and not url.startswith(("http://", "https://")):
|
||||||
validate_url(value)
|
data["url"] = "http://" + url
|
||||||
except ValidationError:
|
|
||||||
raise serializers.ValidationError("Invalid URL format.")
|
|
||||||
|
|
||||||
# Check URL scheme
|
return super().to_internal_value(data)
|
||||||
if not value.startswith(("http://", "https://")):
|
|
||||||
raise serializers.ValidationError("Invalid URL scheme.")
|
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
|
return value
|
||||||
|
|
||||||
|
|
@ -533,7 +537,7 @@ class IssueReactionSerializer(BaseSerializer):
|
||||||
"project",
|
"project",
|
||||||
"issue",
|
"issue",
|
||||||
"actor",
|
"actor",
|
||||||
"deleted_at"
|
"deleted_at",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -552,7 +556,13 @@ class CommentReactionSerializer(BaseSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = CommentReaction
|
model = CommentReaction
|
||||||
fields = "__all__"
|
fields = "__all__"
|
||||||
read_only_fields = ["workspace", "project", "comment", "actor", "deleted_at"]
|
read_only_fields = [
|
||||||
|
"workspace",
|
||||||
|
"project",
|
||||||
|
"comment",
|
||||||
|
"actor",
|
||||||
|
"deleted_at",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class IssueVoteSerializer(BaseSerializer):
|
class IssueVoteSerializer(BaseSerializer):
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,10 @@ from rest_framework import serializers
|
||||||
from .base import BaseSerializer, DynamicBaseSerializer
|
from .base import BaseSerializer, DynamicBaseSerializer
|
||||||
from .project import ProjectLiteSerializer
|
from .project import ProjectLiteSerializer
|
||||||
|
|
||||||
|
# Django imports
|
||||||
|
from django.core.validators import URLValidator
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
from plane.db.models import (
|
from plane.db.models import (
|
||||||
User,
|
User,
|
||||||
Module,
|
Module,
|
||||||
|
|
@ -155,16 +159,48 @@ class ModuleLinkSerializer(BaseSerializer):
|
||||||
"module",
|
"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):
|
def create(self, validated_data):
|
||||||
|
validated_data["url"] = self.validate_url(validated_data.get("url"))
|
||||||
if ModuleLink.objects.filter(
|
if ModuleLink.objects.filter(
|
||||||
url=validated_data.get("url"),
|
url=validated_data.get("url"),
|
||||||
module_id=validated_data.get("module_id"),
|
module_id=validated_data.get("module_id"),
|
||||||
).exists():
|
).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(
|
raise serializers.ValidationError(
|
||||||
{"error": "URL already exists for this Issue"}
|
{"error": "URL already exists for this Issue"}
|
||||||
)
|
)
|
||||||
return ModuleLink.objects.create(**validated_data)
|
|
||||||
|
return super().update(instance, validated_data)
|
||||||
|
|
||||||
|
|
||||||
class ModuleSerializer(DynamicBaseSerializer):
|
class ModuleSerializer(DynamicBaseSerializer):
|
||||||
|
|
@ -229,7 +265,14 @@ class ModuleDetailSerializer(ModuleSerializer):
|
||||||
cancelled_estimate_points = serializers.FloatField(read_only=True)
|
cancelled_estimate_points = serializers.FloatField(read_only=True)
|
||||||
|
|
||||||
class Meta(ModuleSerializer.Meta):
|
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):
|
class ModuleUserPropertiesSerializer(BaseSerializer):
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
export * from "./bulk-delete-issues-modal";
|
export * from "./bulk-delete-issues-modal";
|
||||||
export * from "./existing-issues-list-modal";
|
export * from "./existing-issues-list-modal";
|
||||||
export * from "./gpt-assistant-popover";
|
export * from "./gpt-assistant-popover";
|
||||||
export * from "./link-modal";
|
|
||||||
export * from "./user-image-upload-modal";
|
export * from "./user-image-upload-modal";
|
||||||
export * from "./workspace-image-upload-modal";
|
export * from "./workspace-image-upload-modal";
|
||||||
export * from "./issue-search-modal-empty-state";
|
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 "./single-progress-stats";
|
||||||
export * from "./sidebar-menu-hamburger-toggle";
|
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";
|
"use client";
|
||||||
|
|
||||||
import { FC, useEffect, Fragment } from "react";
|
import { FC, useEffect } from "react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
import { Dialog, Transition } from "@headlessui/react";
|
// plane types
|
||||||
import type { TIssueLinkEditableFields } from "@plane/types";
|
import type { TIssueLinkEditableFields } from "@plane/types";
|
||||||
// ui
|
// plane ui
|
||||||
import { Button, Input } from "@plane/ui";
|
import { Button, Input, ModalCore } from "@plane/ui";
|
||||||
|
// helpers
|
||||||
|
import { checkURLValidity } from "@/helpers/string.helper";
|
||||||
|
// hooks
|
||||||
import { useIssueDetail } from "@/hooks/store";
|
import { useIssueDetail } from "@/hooks/store";
|
||||||
// types
|
// types
|
||||||
import { TLinkOperations } from "./root";
|
import { TLinkOperations } from "./root";
|
||||||
|
|
@ -31,7 +34,6 @@ const defaultValues: TIssueLinkCreateFormFieldOptions = {
|
||||||
export const IssueLinkCreateUpdateModal: FC<TIssueLinkCreateEditModal> = observer((props) => {
|
export const IssueLinkCreateUpdateModal: FC<TIssueLinkCreateEditModal> = observer((props) => {
|
||||||
// props
|
// props
|
||||||
const { isModalOpen, handleOnClose, linkOperations } = props;
|
const { isModalOpen, handleOnClose, linkOperations } = props;
|
||||||
|
|
||||||
// react hook form
|
// react hook form
|
||||||
const {
|
const {
|
||||||
formState: { errors, isSubmitting },
|
formState: { errors, isSubmitting },
|
||||||
|
|
@ -41,12 +43,12 @@ export const IssueLinkCreateUpdateModal: FC<TIssueLinkCreateEditModal> = observe
|
||||||
} = useForm<TIssueLinkCreateFormFieldOptions>({
|
} = useForm<TIssueLinkCreateFormFieldOptions>({
|
||||||
defaultValues,
|
defaultValues,
|
||||||
});
|
});
|
||||||
|
// store hooks
|
||||||
const { issueLinkData: preloadedData, setIssueLinkData } = useIssueDetail();
|
const { issueLinkData: preloadedData, setIssueLinkData } = useIssueDetail();
|
||||||
|
|
||||||
const onClose = () => {
|
const onClose = () => {
|
||||||
setIssueLinkData(null);
|
setIssueLinkData(null);
|
||||||
reset(defaultValues);
|
reset();
|
||||||
if (handleOnClose) handleOnClose();
|
if (handleOnClose) handleOnClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -61,110 +63,70 @@ export const IssueLinkCreateUpdateModal: FC<TIssueLinkCreateEditModal> = observe
|
||||||
}, [preloadedData, reset, isModalOpen]);
|
}, [preloadedData, reset, isModalOpen]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Transition.Root show={isModalOpen} as={Fragment}>
|
<ModalCore isOpen={isModalOpen} handleClose={onClose}>
|
||||||
<Dialog as="div" className="relative z-20" onClose={onClose}>
|
<form onSubmit={handleSubmit(handleFormSubmit)}>
|
||||||
<Transition.Child
|
<div className="space-y-5 p-5">
|
||||||
as={Fragment}
|
<h3 className="text-xl font-medium text-custom-text-200">{preloadedData?.id ? "Update" : "Add"} link</h3>
|
||||||
enter="ease-out duration-300"
|
<div className="mt-2 space-y-3">
|
||||||
enterFrom="opacity-0"
|
<div>
|
||||||
enterTo="opacity-100"
|
<label htmlFor="url" className="mb-2 text-custom-text-200">
|
||||||
leave="ease-in duration-200"
|
URL
|
||||||
leaveFrom="opacity-100"
|
</label>
|
||||||
leaveTo="opacity-0"
|
<Controller
|
||||||
>
|
control={control}
|
||||||
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
|
name="url"
|
||||||
</Transition.Child>
|
rules={{
|
||||||
|
required: "URL is required",
|
||||||
<div className="fixed inset-0 z-10 overflow-y-auto">
|
validate: (value) => checkURLValidity(value) || "URL is invalid",
|
||||||
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
|
}}
|
||||||
<Transition.Child
|
render={({ field: { value, onChange, ref } }) => (
|
||||||
as={Fragment}
|
<Input
|
||||||
enter="ease-out duration-300"
|
id="url"
|
||||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
type="text"
|
||||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
value={value}
|
||||||
leave="ease-in duration-200"
|
onChange={onChange}
|
||||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
ref={ref}
|
||||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
hasError={Boolean(errors.url)}
|
||||||
>
|
placeholder="Type or paste a URL"
|
||||||
<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">
|
className="w-full"
|
||||||
<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">
|
{errors.url && <span className="text-xs text-red-500">URL is invalid</span>}
|
||||||
{preloadedData?.id ? "Update link" : "Add link"}
|
</div>
|
||||||
</Dialog.Title>
|
<div>
|
||||||
<div className="mt-2 space-y-3">
|
<label htmlFor="title" className="mb-2 text-custom-text-200">
|
||||||
<div>
|
Display title
|
||||||
<label htmlFor="url" className="mb-2 text-custom-text-200">
|
<span className="text-[10px] block">Optional</span>
|
||||||
URL
|
</label>
|
||||||
</label>
|
<Controller
|
||||||
<Controller
|
control={control}
|
||||||
control={control}
|
name="title"
|
||||||
name="url"
|
render={({ field: { value, onChange, ref } }) => (
|
||||||
rules={{
|
<Input
|
||||||
required: "URL is required",
|
id="title"
|
||||||
}}
|
type="text"
|
||||||
render={({ field: { value, onChange, ref } }) => (
|
value={value}
|
||||||
<Input
|
onChange={onChange}
|
||||||
id="url"
|
ref={ref}
|
||||||
name="url"
|
hasError={Boolean(errors.title)}
|
||||||
type="url"
|
placeholder="What you'd like to see this link as"
|
||||||
value={value}
|
className="w-full"
|
||||||
onChange={onChange}
|
/>
|
||||||
ref={ref}
|
)}
|
||||||
hasError={Boolean(errors.url)}
|
/>
|
||||||
placeholder="https://..."
|
</div>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
<div className="px-5 py-4 flex items-center justify-end gap-2 border-t-[0.5px] border-custom-border-200">
|
||||||
</Transition.Root>
|
<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);
|
const issueLinks = getLinksByIssueId(issueId);
|
||||||
|
|
||||||
if (!issueLinks) return <></>;
|
if (!issueLinks) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-12 3xl:grid-cols-10 gap-2 px-9 py-4">
|
<div className="grid grid-cols-12 3xl:grid-cols-10 gap-2 px-9 py-4">
|
||||||
{issueLinks &&
|
{issueLinks.map((linkId) => (
|
||||||
issueLinks.length > 0 &&
|
<IssueLinkItem key={linkId} linkId={linkId} linkOperations={linkOperations} isNotAllowed={disabled} />
|
||||||
issueLinks.map((linkId) => (
|
))}
|
||||||
<IssueLinkItem key={linkId} linkId={linkId} linkOperations={linkOperations} isNotAllowed={disabled} />
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -17,8 +17,9 @@ import {
|
||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Disclosure, Transition } from "@headlessui/react";
|
import { Disclosure, Transition } from "@headlessui/react";
|
||||||
|
// plane types
|
||||||
import { ILinkDetails, IModule, ModuleLink } from "@plane/types";
|
import { ILinkDetails, IModule, ModuleLink } from "@plane/types";
|
||||||
// ui
|
// plane ui
|
||||||
import {
|
import {
|
||||||
CustomMenu,
|
CustomMenu,
|
||||||
Loader,
|
Loader,
|
||||||
|
|
@ -31,9 +32,14 @@ import {
|
||||||
TextArea,
|
TextArea,
|
||||||
} from "@plane/ui";
|
} from "@plane/ui";
|
||||||
// components
|
// components
|
||||||
import { LinkModal, LinksList } from "@/components/core";
|
|
||||||
import { DateRangeDropdown, MemberDropdown } from "@/components/dropdowns";
|
import { DateRangeDropdown, MemberDropdown } from "@/components/dropdowns";
|
||||||
import { ArchiveModuleModal, DeleteModuleModal, ModuleAnalyticsProgress } from "@/components/modules";
|
import {
|
||||||
|
ArchiveModuleModal,
|
||||||
|
DeleteModuleModal,
|
||||||
|
CreateUpdateModuleLinkModal,
|
||||||
|
ModuleAnalyticsProgress,
|
||||||
|
ModuleLinksList,
|
||||||
|
} from "@/components/modules";
|
||||||
import {
|
import {
|
||||||
MODULE_LINK_CREATED,
|
MODULE_LINK_CREATED,
|
||||||
MODULE_LINK_DELETED,
|
MODULE_LINK_DELETED,
|
||||||
|
|
@ -121,25 +127,12 @@ export const ModuleAnalyticsSidebar: React.FC<Props> = observer((props) => {
|
||||||
|
|
||||||
const payload = { metadata: {}, ...formData };
|
const payload = { metadata: {}, ...formData };
|
||||||
|
|
||||||
createModuleLink(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), payload)
|
await createModuleLink(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), payload).then(() =>
|
||||||
.then(() => {
|
captureEvent(MODULE_LINK_CREATED, {
|
||||||
captureEvent(MODULE_LINK_CREATED, {
|
module_id: moduleId,
|
||||||
module_id: moduleId,
|
state: "SUCCESS",
|
||||||
state: "SUCCESS",
|
|
||||||
});
|
|
||||||
setToast({
|
|
||||||
type: TOAST_TYPE.SUCCESS,
|
|
||||||
title: "Success!",
|
|
||||||
message: "Module link created successfully.",
|
|
||||||
});
|
|
||||||
})
|
})
|
||||||
.catch(() => {
|
);
|
||||||
setToast({
|
|
||||||
type: TOAST_TYPE.ERROR,
|
|
||||||
title: "Error!",
|
|
||||||
message: "Some error occurred",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateLink = async (formData: ModuleLink, linkId: string) => {
|
const handleUpdateLink = async (formData: ModuleLink, linkId: string) => {
|
||||||
|
|
@ -147,25 +140,13 @@ export const ModuleAnalyticsSidebar: React.FC<Props> = observer((props) => {
|
||||||
|
|
||||||
const payload = { metadata: {}, ...formData };
|
const payload = { metadata: {}, ...formData };
|
||||||
|
|
||||||
updateModuleLink(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), linkId, payload)
|
await updateModuleLink(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), linkId, payload).then(
|
||||||
.then(() => {
|
() =>
|
||||||
captureEvent(MODULE_LINK_UPDATED, {
|
captureEvent(MODULE_LINK_UPDATED, {
|
||||||
module_id: moduleId,
|
module_id: moduleId,
|
||||||
state: "SUCCESS",
|
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) => {
|
const handleDeleteLink = async (linkId: string) => {
|
||||||
|
|
@ -287,16 +268,17 @@ export const ModuleAnalyticsSidebar: React.FC<Props> = observer((props) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<LinkModal
|
<CreateUpdateModuleLinkModal
|
||||||
isOpen={moduleLinkModal}
|
isOpen={moduleLinkModal}
|
||||||
handleClose={() => {
|
handleClose={() => {
|
||||||
setModuleLinkModal(false);
|
setModuleLinkModal(false);
|
||||||
setSelectedLinkToUpdate(null);
|
setTimeout(() => {
|
||||||
|
setSelectedLinkToUpdate(null);
|
||||||
|
}, 500);
|
||||||
}}
|
}}
|
||||||
data={selectedLinkToUpdate}
|
data={selectedLinkToUpdate}
|
||||||
status={selectedLinkToUpdate ? true : false}
|
createLink={handleCreateLink}
|
||||||
createIssueLink={handleCreateLink}
|
updateLink={handleUpdateLink}
|
||||||
updateIssueLink={handleUpdateLink}
|
|
||||||
/>
|
/>
|
||||||
{workspaceSlug && projectId && (
|
{workspaceSlug && projectId && (
|
||||||
<ArchiveModuleModal
|
<ArchiveModuleModal
|
||||||
|
|
@ -583,7 +565,7 @@ export const ModuleAnalyticsSidebar: React.FC<Props> = observer((props) => {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{moduleId && (
|
{moduleId && (
|
||||||
<LinksList
|
<ModuleLinksList
|
||||||
moduleId={moduleId}
|
moduleId={moduleId}
|
||||||
handleEditLink={handleEditLink}
|
handleEditLink={handleEditLink}
|
||||||
handleDeleteLink={handleDeleteLink}
|
handleDeleteLink={handleDeleteLink}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ export * from "./sidebar-select";
|
||||||
export * from "./delete-module-modal";
|
export * from "./delete-module-modal";
|
||||||
export * from "./form";
|
export * from "./form";
|
||||||
export * from "./gantt-chart";
|
export * from "./gantt-chart";
|
||||||
|
export * from "./links";
|
||||||
export * from "./modal";
|
export * from "./modal";
|
||||||
export * from "./modules-list-view";
|
export * from "./modules-list-view";
|
||||||
export * from "./module-card-item";
|
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 => {
|
export const isCommentEmpty = (comment: string | undefined): boolean => {
|
||||||
// return true if comment is undefined
|
// return true if comment is undefined
|
||||||
if (!comment) return true;
|
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);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue