diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py index 7f8712bf6..ab054ae51 100644 --- a/apiserver/plane/api/serializers/issue.py +++ b/apiserver/plane/api/serializers/issue.py @@ -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() diff --git a/apiserver/plane/app/serializers/issue.py b/apiserver/plane/app/serializers/issue.py index e6cdc8e85..4cdf94402 100644 --- a/apiserver/plane/app/serializers/issue.py +++ b/apiserver/plane/app/serializers/issue.py @@ -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): diff --git a/apiserver/plane/app/serializers/module.py b/apiserver/plane/app/serializers/module.py index ba71937ab..fc228b6b9 100644 --- a/apiserver/plane/app/serializers/module.py +++ b/apiserver/plane/app/serializers/module.py @@ -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): diff --git a/web/core/components/core/modals/index.ts b/web/core/components/core/modals/index.ts index a95c22114..940dc1a43 100644 --- a/web/core/components/core/modals/index.ts +++ b/web/core/components/core/modals/index.ts @@ -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"; diff --git a/web/core/components/core/modals/link-modal.tsx b/web/core/components/core/modals/link-modal.tsx deleted file mode 100644 index c622d07c9..000000000 --- a/web/core/components/core/modals/link-modal.tsx +++ /dev/null @@ -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 | Promise | void; - updateIssueLink: (formData: IIssueLink | ModuleLink, linkId: string) => Promise | Promise | void; -}; - -const defaultValues: IIssueLink | ModuleLink = { - title: "", - url: "", -}; - -export const LinkModal: FC = (props) => { - const { isOpen, handleClose, createIssueLink, updateIssueLink, status, data } = props; - // form info - const { - formState: { errors, isSubmitting }, - handleSubmit, - control, - reset, - } = useForm({ - 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 ( - - - -
- - -
-
- - -
-
-
- - {status ? "Update Link" : "Add Link"} - -
-
- - ( - - )} - /> -
-
- - ( - - )} - /> -
-
-
-
-
- - -
-
-
-
-
-
-
-
- ); -}; diff --git a/web/core/components/core/sidebar/index.ts b/web/core/components/core/sidebar/index.ts index f970e0f18..ad5c6f683 100644 --- a/web/core/components/core/sidebar/index.ts +++ b/web/core/components/core/sidebar/index.ts @@ -1,3 +1,2 @@ -export * from "./links-list"; export * from "./single-progress-stats"; export * from "./sidebar-menu-hamburger-toggle"; diff --git a/web/core/components/core/sidebar/links-list.tsx b/web/core/components/core/sidebar/links-list.tsx deleted file mode 100644 index 48b66caa8..000000000 --- a/web/core/components/core/sidebar/links-list.tsx +++ /dev/null @@ -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 = 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 ( -
-
-
- - - - - copyToClipboard(link.title && link.title !== "" ? link.title : link.url)} - > - {link.title && link.title !== "" ? link.title : link.url} - - -
- - {!isNotAllowed && ( -
- - - - - -
- )} -
-
-

- Added {calculateTimeAgo(link.created_at)} -
- {createdByDetails && ( - <> - by{" "} - {createdByDetails?.is_bot ? createdByDetails?.first_name + " Bot" : createdByDetails?.display_name} - - )} -

-
-
- ); - })} - - ); -}); diff --git a/web/core/components/issues/issue-detail/links/create-update-link-modal.tsx b/web/core/components/issues/issue-detail/links/create-update-link-modal.tsx index bd09c5087..ab0d80f1b 100644 --- a/web/core/components/issues/issue-detail/links/create-update-link-modal.tsx +++ b/web/core/components/issues/issue-detail/links/create-update-link-modal.tsx @@ -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 = observer((props) => { // props const { isModalOpen, handleOnClose, linkOperations } = props; - // react hook form const { formState: { errors, isSubmitting }, @@ -41,12 +43,12 @@ export const IssueLinkCreateUpdateModal: FC = observe } = useForm({ 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 = observe }, [preloadedData, reset, isModalOpen]); return ( - - - -
- - -
-
- - -
-
-
- - {preloadedData?.id ? "Update link" : "Add link"} - -
-
- - ( - - )} - /> -
-
- - ( - - )} - /> -
-
-
-
-
- - -
-
-
-
+ +
+
+

{preloadedData?.id ? "Update" : "Add"} link

+
+
+ + checkURLValidity(value) || "URL is invalid", + }} + render={({ field: { value, onChange, ref } }) => ( + + )} + /> + {errors.url && URL is invalid} +
+
+ + ( + + )} + /> +
-
-
+
+ + +
+ + ); }); diff --git a/web/core/components/issues/issue-detail/links/link-list.tsx b/web/core/components/issues/issue-detail/links/link-list.tsx index ad8424623..455671537 100644 --- a/web/core/components/issues/issue-detail/links/link-list.tsx +++ b/web/core/components/issues/issue-detail/links/link-list.tsx @@ -24,15 +24,13 @@ export const LinkList: FC = observer((props) => { const issueLinks = getLinksByIssueId(issueId); - if (!issueLinks) return <>; + if (!issueLinks) return null; return (
- {issueLinks && - issueLinks.length > 0 && - issueLinks.map((linkId) => ( - - ))} + {issueLinks.map((linkId) => ( + + ))}
); -}); \ No newline at end of file +}); diff --git a/web/core/components/modules/analytics-sidebar/root.tsx b/web/core/components/modules/analytics-sidebar/root.tsx index 360c8495b..7a97b3f22 100644 --- a/web/core/components/modules/analytics-sidebar/root.tsx +++ b/web/core/components/modules/analytics-sidebar/root.tsx @@ -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 = 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 = 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 = observer((props) => { return (
- { setModuleLinkModal(false); - setSelectedLinkToUpdate(null); + setTimeout(() => { + setSelectedLinkToUpdate(null); + }, 500); }} data={selectedLinkToUpdate} - status={selectedLinkToUpdate ? true : false} - createIssueLink={handleCreateLink} - updateIssueLink={handleUpdateLink} + createLink={handleCreateLink} + updateLink={handleUpdateLink} /> {workspaceSlug && projectId && ( = observer((props) => { )} {moduleId && ( - Promise; + data?: ILinkDetails | null; + isOpen: boolean; + handleClose: () => void; + updateLink: (formData: ModuleLink, linkId: string) => Promise; +}; + +const defaultValues: ModuleLink = { + title: "", + url: "", +}; + +export const CreateUpdateModuleLinkModal: FC = (props) => { + const { isOpen, handleClose, createLink, updateLink, data } = props; + // form info + const { + formState: { errors, isSubmitting }, + handleSubmit, + control, + reset, + } = useForm({ + 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 ( + +
+
+

{data ? "Update" : "Add"} link

+
+
+ + checkURLValidity(value) || "URL is invalid", + }} + render={({ field: { value, onChange, ref } }) => ( + + )} + /> +
+
+ + ( + + )} + /> +
+
+
+
+ + +
+
+
+ ); +}; diff --git a/web/core/components/modules/links/index.ts b/web/core/components/modules/links/index.ts new file mode 100644 index 000000000..114112190 --- /dev/null +++ b/web/core/components/modules/links/index.ts @@ -0,0 +1,3 @@ +export * from "./create-update-modal"; +export * from "./list-item"; +export * from "./list"; diff --git a/web/core/components/modules/links/list-item.tsx b/web/core/components/modules/links/list-item.tsx new file mode 100644 index 000000000..bbcce0b8b --- /dev/null +++ b/web/core/components/modules/links/list-item.tsx @@ -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 = 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 ( +
+
+
+ + + + + copyToClipboard(link.title && link.title !== "" ? link.title : link.url)} + > + {link.title && link.title !== "" ? link.title : link.url} + + +
+ +
+ {isEditingAllowed && ( + + )} + + + + {isEditingAllowed && ( + + )} +
+
+
+

+ Added {calculateTimeAgo(link.created_at)} +
+ {createdByDetails && ( + <>by {createdByDetails?.is_bot ? createdByDetails?.first_name + " Bot" : createdByDetails?.display_name} + )} +

+
+
+ ); +}); diff --git a/web/core/components/modules/links/list.tsx b/web/core/components/modules/links/list.tsx new file mode 100644 index 000000000..e6e67b022 --- /dev/null +++ b/web/core/components/modules/links/list.tsx @@ -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 = 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) => ( + memoizedDeleteLink(link.id)} + handleEditLink={() => memoizedEditLink(link)} + isEditingAllowed={(userAuth.isMember || userAuth.isOwner) && !disabled} + link={link} + /> + ))} + + ); +}); diff --git a/web/helpers/string.helper.ts b/web/helpers/string.helper.ts index de0d8ac9d..00d601c74 100644 --- a/web/helpers/string.helper.ts +++ b/web/helpers/string.helper.ts @@ -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 === "

" || isEmptyHtmlString(comment ?? "", ["mention-component"]); + return ( + comment?.trim() === "" || comment === "

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