[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:
Aaryan Khandelwal 2024-09-06 22:52:29 +05:30 committed by GitHub
parent c95aa6a0f7
commit 68b412badf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 497 additions and 475 deletions

View file

@ -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(

View file

@ -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):

View file

@ -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):

View file

@ -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";

View file

@ -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>
);
};

View file

@ -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";

View file

@ -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>
);
})}
</>
);
});

View file

@ -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,38 +63,10 @@ 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}>
<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)}> <form onSubmit={handleSubmit(handleFormSubmit)}>
<div> <div className="space-y-5 p-5">
<div className="space-y-5"> <h3 className="text-xl font-medium text-custom-text-200">{preloadedData?.id ? "Update" : "Add"} link</h3>
<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 className="mt-2 space-y-3">
<div> <div>
<label htmlFor="url" className="mb-2 text-custom-text-200"> <label htmlFor="url" className="mb-2 text-custom-text-200">
@ -103,26 +77,27 @@ export const IssueLinkCreateUpdateModal: FC<TIssueLinkCreateEditModal> = observe
name="url" name="url"
rules={{ rules={{
required: "URL is required", required: "URL is required",
validate: (value) => checkURLValidity(value) || "URL is invalid",
}} }}
render={({ field: { value, onChange, ref } }) => ( render={({ field: { value, onChange, ref } }) => (
<Input <Input
id="url" id="url"
name="url" type="text"
type="url"
value={value} value={value}
onChange={onChange} onChange={onChange}
ref={ref} ref={ref}
hasError={Boolean(errors.url)} hasError={Boolean(errors.url)}
placeholder="https://..." placeholder="Type or paste a URL"
pattern="^(https?://).*"
className="w-full" className="w-full"
/> />
)} )}
/> />
{errors.url && <span className="text-xs text-red-500">URL is invalid</span>}
</div> </div>
<div> <div>
<label htmlFor="title" className="mb-2 text-custom-text-200"> <label htmlFor="title" className="mb-2 text-custom-text-200">
{`Title (optional)`} Display title
<span className="text-[10px] block">Optional</span>
</label> </label>
<Controller <Controller
control={control} control={control}
@ -130,13 +105,12 @@ export const IssueLinkCreateUpdateModal: FC<TIssueLinkCreateEditModal> = observe
render={({ field: { value, onChange, ref } }) => ( render={({ field: { value, onChange, ref } }) => (
<Input <Input
id="title" id="title"
name="title"
type="text" type="text"
value={value} value={value}
onChange={onChange} onChange={onChange}
ref={ref} ref={ref}
hasError={Boolean(errors.title)} hasError={Boolean(errors.title)}
placeholder="Enter title" placeholder="What you'd like to see this link as"
className="w-full" className="w-full"
/> />
)} )}
@ -144,27 +118,15 @@ export const IssueLinkCreateUpdateModal: FC<TIssueLinkCreateEditModal> = observe
</div> </div>
</div> </div>
</div> </div>
</div> <div className="px-5 py-4 flex items-center justify-end gap-2 border-t-[0.5px] border-custom-border-200">
<div className="mt-5 flex justify-end gap-2">
<Button variant="neutral-primary" size="sm" onClick={onClose}> <Button variant="neutral-primary" size="sm" onClick={onClose}>
Cancel Cancel
</Button> </Button>
<Button variant="primary" size="sm" type="submit" loading={isSubmitting}> <Button variant="primary" size="sm" type="submit" loading={isSubmitting}>
{preloadedData?.id {preloadedData?.id ? (isSubmitting ? "Updating" : "Update") : isSubmitting ? "Adding" : "Add"} link
? isSubmitting
? "Updating link..."
: "Update link"
: isSubmitting
? "Adding link..."
: "Add link"}
</Button> </Button>
</div> </div>
</form> </form>
</Dialog.Panel> </ModalCore>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
); );
}); });

View file

@ -24,13 +24,11 @@ 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 &&
issueLinks.map((linkId) => (
<IssueLinkItem key={linkId} linkId={linkId} linkOperations={linkOperations} isNotAllowed={disabled} /> <IssueLinkItem key={linkId} linkId={linkId} linkOperations={linkOperations} isNotAllowed={disabled} />
))} ))}
</div> </div>

View file

@ -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);
setTimeout(() => {
setSelectedLinkToUpdate(null); 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}

View file

@ -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";

View 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>
);
};

View file

@ -0,0 +1,3 @@
export * from "./create-update-modal";
export * from "./list-item";
export * from "./list";

View 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>
);
});

View 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}
/>
))}
</>
);
});

View file

@ -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);
}; };