diff --git a/web/core/components/core/modals/index.ts b/web/core/components/core/modals/index.ts index a95c22114e..940dc1a432 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 c622d07c9c..0000000000 --- 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 f970e0f181..ad5c6f6835 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 48b66caa81..0000000000 --- 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 bd09c5087f..4a440362fc 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,7 +43,7 @@ export const IssueLinkCreateUpdateModal: FC = observe } = useForm({ defaultValues, }); - + // store hooks const { issueLinkData: preloadedData, setIssueLinkData } = useIssueDetail(); const onClose = () => { @@ -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 ad8424623c..455671537f 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 360c8495b8..6d31fa0146 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, @@ -287,16 +293,18 @@ export const ModuleAnalyticsSidebar: React.FC = observer((props) => { return (
- { setModuleLinkModal(false); - setSelectedLinkToUpdate(null); + const timeoutId = setTimeout(() => { + setSelectedLinkToUpdate(null); + clearTimeout(timeoutId); + }, 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 timeoutId = setTimeout(() => { + reset(defaultValues); + clearTimeout(timeoutId); + }, 500); + }; + + const handleFormSubmit = async (formData: ModuleLink) => { + const payload = { + title: formData.title, + url: formData.url, + }; + + try { + if (!data) await createLink(payload); + else await updateLink(payload, data.id); + onClose(); + reset(defaultValues); + + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Link created", + message: "The link has been created successfully.", + }); + } catch (error: any) { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Link not created", + message: error?.data?.error ?? "The link could not be created.", + }); + } + }; + + useEffect(() => { + if (!data) return; + reset({ + ...defaultValues, + ...data, + }); + }, [data, 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 0000000000..1141121906 --- /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 0000000000..bbcce0b8ba --- /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 0000000000..d216d8851e --- /dev/null +++ b/web/core/components/modules/links/list.tsx @@ -0,0 +1,44 @@ +"use client"; +import { observer } from "mobx-react"; +// plane types +import { ILinkDetails, UserAuth } from "@plane/types"; +// ui +import { TOAST_TYPE, setToast } from "@plane/ui"; +// components +import { ModulesLinksListItem } from "@/components/modules"; +// hooks +import { useMember, useModule } from "@/hooks/store"; +import { usePlatformOS } from "@/hooks/use-platform-os"; + +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; + + if (!moduleLinks) return null; + + return ( + <> + {moduleLinks.map((link) => ( + handleDeleteLink(link.id)} + handleEditLink={() => handleEditLink(link)} + isEditingAllowed={(userAuth.isMember || userAuth.isOwner) && !disabled} + link={link} + /> + ))} + + ); +}); diff --git a/web/helpers/string.helper.ts b/web/helpers/string.helper.ts index de0d8ac9d4..af00a769d4 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); };