Compare commits

...

18 Commits

Author SHA1 Message Date
Aaryan Khandelwal
91f5f3f07f refactor: reset modal logic 2024-09-06 19:42:46 +05:30
Aaryan Khandelwal
ec5a2126b1 Merge branch 'refactor/link-modal' of https://github.com/makeplane/plane into refactor/link-modal 2024-09-06 19:20:10 +05:30
Aaryan Khandelwal
6078ac6f2d refactor: link modals 2024-09-06 19:20:01 +05:30
NarayanBavisetti
92f7fd51ef chore: reverted the external api changes 2024-09-06 14:35:13 +05:30
NarayanBavisetti
139919ddcb Merge branch 'refactor/link-modal' of github.com:makeplane/plane into refactor/link-modal 2024-09-06 14:33:27 +05:30
NarayanBavisetti
327c83deea chore: removed unwanted imports 2024-09-06 14:33:08 +05:30
Aaryan Khandelwal
1e4fc069de Merge branch 'refactor/link-modal' of https://github.com/makeplane/plane into refactor/link-modal 2024-09-06 14:31:53 +05:30
Aaryan Khandelwal
e663db5474 fix: url validation regex 2024-09-06 14:31:20 +05:30
NarayanBavisetti
2e218b1bfb chore: removed the validator function 2024-09-06 14:31:19 +05:30
Aaryan Khandelwal
c9a4547fa4 refactor: modules link logic 2024-09-06 14:02:00 +05:30
Aaryan Khandelwal
15f2912d6a Merge branch 'refactor/link-modal' of https://github.com/makeplane/plane into refactor/link-modal 2024-09-06 13:47:46 +05:30
Aaryan Khandelwal
fb87d4d293 Merge branch 'preview' of https://github.com/makeplane/plane into refactor/link-modal 2024-09-06 13:42:01 +05:30
NarayanBavisetti
ed21da7adf chore: code cleanup 2024-09-06 10:31:30 +05:30
NarayanBavisetti
0aef74bd47 chore: changed the url validation logic 2024-09-06 10:30:25 +05:30
Aaryan Khandelwal
9482fe4509 refactor: issues and modules link moda; 2024-09-05 16:39:49 +05:30
Aaryan Khandelwal
18c0b0b0e2 Merge branch 'preview' of https://github.com/makeplane/plane into refactor/link-modal 2024-09-04 18:51:00 +05:30
Aaryan Khandelwal
9eaf4b50dd Merge branch 'preview' of https://github.com/makeplane/plane into refactor/link-modal 2024-09-02 18:17:34 +05:30
NarayanBavisetti
e366788176 chore: added module and issue link validation 2024-09-02 17:38:02 +05:30
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
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()

View File

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

View File

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

View File

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

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 "./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";
import { FC, useEffect, Fragment } from "react";
import { FC, useEffect } from "react";
import { observer } from "mobx-react";
import { Controller, useForm } from "react-hook-form";
import { Dialog, Transition } from "@headlessui/react";
// plane types
import type { TIssueLinkEditableFields } from "@plane/types";
// ui
import { Button, Input } from "@plane/ui";
// plane ui
import { Button, Input, ModalCore } from "@plane/ui";
// helpers
import { checkURLValidity } from "@/helpers/string.helper";
// hooks
import { useIssueDetail } from "@/hooks/store";
// types
import { TLinkOperations } from "./root";
@@ -31,7 +34,6 @@ const defaultValues: TIssueLinkCreateFormFieldOptions = {
export const IssueLinkCreateUpdateModal: FC<TIssueLinkCreateEditModal> = observer((props) => {
// props
const { isModalOpen, handleOnClose, linkOperations } = props;
// react hook form
const {
formState: { errors, isSubmitting },
@@ -41,12 +43,12 @@ export const IssueLinkCreateUpdateModal: FC<TIssueLinkCreateEditModal> = observe
} = useForm<TIssueLinkCreateFormFieldOptions>({
defaultValues,
});
// store hooks
const { issueLinkData: preloadedData, setIssueLinkData } = useIssueDetail();
const onClose = () => {
setIssueLinkData(null);
reset(defaultValues);
reset();
if (handleOnClose) handleOnClose();
};
@@ -61,110 +63,70 @@ export const IssueLinkCreateUpdateModal: FC<TIssueLinkCreateEditModal> = observe
}, [preloadedData, reset, isModalOpen]);
return (
<Transition.Root show={isModalOpen} as={Fragment}>
<Dialog as="div" className="relative z-20" onClose={onClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-custom-background-100 px-5 py-8 text-left shadow-custom-shadow-md transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
<form onSubmit={handleSubmit(handleFormSubmit)}>
<div>
<div className="space-y-5">
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-custom-text-100">
{preloadedData?.id ? "Update link" : "Add link"}
</Dialog.Title>
<div className="mt-2 space-y-3">
<div>
<label htmlFor="url" className="mb-2 text-custom-text-200">
URL
</label>
<Controller
control={control}
name="url"
rules={{
required: "URL is required",
}}
render={({ field: { value, onChange, ref } }) => (
<Input
id="url"
name="url"
type="url"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.url)}
placeholder="https://..."
pattern="^(https?://).*"
className="w-full"
/>
)}
/>
</div>
<div>
<label htmlFor="title" className="mb-2 text-custom-text-200">
{`Title (optional)`}
</label>
<Controller
control={control}
name="title"
render={({ field: { value, onChange, ref } }) => (
<Input
id="title"
name="title"
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.title)}
placeholder="Enter title"
className="w-full"
/>
)}
/>
</div>
</div>
</div>
</div>
<div className="mt-5 flex justify-end gap-2">
<Button variant="neutral-primary" size="sm" onClick={onClose}>
Cancel
</Button>
<Button variant="primary" size="sm" type="submit" loading={isSubmitting}>
{preloadedData?.id
? isSubmitting
? "Updating link..."
: "Update link"
: isSubmitting
? "Adding link..."
: "Add link"}
</Button>
</div>
</form>
</Dialog.Panel>
</Transition.Child>
<ModalCore isOpen={isModalOpen} handleClose={onClose}>
<form onSubmit={handleSubmit(handleFormSubmit)}>
<div className="space-y-5 p-5">
<h3 className="text-xl font-medium text-custom-text-200">{preloadedData?.id ? "Update" : "Add"} link</h3>
<div className="mt-2 space-y-3">
<div>
<label htmlFor="url" className="mb-2 text-custom-text-200">
URL
</label>
<Controller
control={control}
name="url"
rules={{
required: "URL is required",
validate: (value) => checkURLValidity(value) || "URL is invalid",
}}
render={({ field: { value, onChange, ref } }) => (
<Input
id="url"
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.url)}
placeholder="Type or paste a URL"
className="w-full"
/>
)}
/>
{errors.url && <span className="text-xs text-red-500">URL is invalid</span>}
</div>
<div>
<label htmlFor="title" className="mb-2 text-custom-text-200">
Display title
<span className="text-[10px] block">Optional</span>
</label>
<Controller
control={control}
name="title"
render={({ field: { value, onChange, ref } }) => (
<Input
id="title"
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.title)}
placeholder="What you'd like to see this link as"
className="w-full"
/>
)}
/>
</div>
</div>
</div>
</Dialog>
</Transition.Root>
<div className="px-5 py-4 flex items-center justify-end gap-2 border-t-[0.5px] border-custom-border-200">
<Button variant="neutral-primary" size="sm" onClick={onClose}>
Cancel
</Button>
<Button variant="primary" size="sm" type="submit" loading={isSubmitting}>
{preloadedData?.id ? (isSubmitting ? "Updating" : "Update") : isSubmitting ? "Adding" : "Add"} link
</Button>
</div>
</form>
</ModalCore>
);
});

View File

@@ -24,15 +24,13 @@ export const LinkList: FC<TLinkList> = observer((props) => {
const issueLinks = getLinksByIssueId(issueId);
if (!issueLinks) return <></>;
if (!issueLinks) return null;
return (
<div className="grid grid-cols-12 3xl:grid-cols-10 gap-2 px-9 py-4">
{issueLinks &&
issueLinks.length > 0 &&
issueLinks.map((linkId) => (
<IssueLinkItem key={linkId} linkId={linkId} linkOperations={linkOperations} isNotAllowed={disabled} />
))}
{issueLinks.map((linkId) => (
<IssueLinkItem key={linkId} linkId={linkId} linkOperations={linkOperations} isNotAllowed={disabled} />
))}
</div>
);
});
});

View File

@@ -17,8 +17,9 @@ import {
Users,
} from "lucide-react";
import { Disclosure, Transition } from "@headlessui/react";
// plane types
import { ILinkDetails, IModule, ModuleLink } from "@plane/types";
// ui
// plane ui
import {
CustomMenu,
Loader,
@@ -31,9 +32,14 @@ import {
TextArea,
} from "@plane/ui";
// components
import { LinkModal, LinksList } from "@/components/core";
import { DateRangeDropdown, MemberDropdown } from "@/components/dropdowns";
import { ArchiveModuleModal, DeleteModuleModal, ModuleAnalyticsProgress } from "@/components/modules";
import {
ArchiveModuleModal,
DeleteModuleModal,
CreateUpdateModuleLinkModal,
ModuleAnalyticsProgress,
ModuleLinksList,
} from "@/components/modules";
import {
MODULE_LINK_CREATED,
MODULE_LINK_DELETED,
@@ -121,25 +127,12 @@ export const ModuleAnalyticsSidebar: React.FC<Props> = observer((props) => {
const payload = { metadata: {}, ...formData };
createModuleLink(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), payload)
.then(() => {
captureEvent(MODULE_LINK_CREATED, {
module_id: moduleId,
state: "SUCCESS",
});
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Module link created successfully.",
});
await createModuleLink(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), payload).then(() =>
captureEvent(MODULE_LINK_CREATED, {
module_id: moduleId,
state: "SUCCESS",
})
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Some error occurred",
});
});
);
};
const handleUpdateLink = async (formData: ModuleLink, linkId: string) => {
@@ -147,25 +140,13 @@ export const ModuleAnalyticsSidebar: React.FC<Props> = observer((props) => {
const payload = { metadata: {}, ...formData };
updateModuleLink(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), linkId, payload)
.then(() => {
await updateModuleLink(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), linkId, payload).then(
() =>
captureEvent(MODULE_LINK_UPDATED, {
module_id: moduleId,
state: "SUCCESS",
});
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Module link updated successfully.",
});
})
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Some error occurred",
});
});
})
);
};
const handleDeleteLink = async (linkId: string) => {
@@ -287,16 +268,17 @@ export const ModuleAnalyticsSidebar: React.FC<Props> = observer((props) => {
return (
<div className="relative">
<LinkModal
<CreateUpdateModuleLinkModal
isOpen={moduleLinkModal}
handleClose={() => {
setModuleLinkModal(false);
setSelectedLinkToUpdate(null);
setTimeout(() => {
setSelectedLinkToUpdate(null);
}, 500);
}}
data={selectedLinkToUpdate}
status={selectedLinkToUpdate ? true : false}
createIssueLink={handleCreateLink}
updateIssueLink={handleUpdateLink}
createLink={handleCreateLink}
updateLink={handleUpdateLink}
/>
{workspaceSlug && projectId && (
<ArchiveModuleModal
@@ -583,7 +565,7 @@ export const ModuleAnalyticsSidebar: React.FC<Props> = observer((props) => {
)}
{moduleId && (
<LinksList
<ModuleLinksList
moduleId={moduleId}
handleEditLink={handleEditLink}
handleDeleteLink={handleDeleteLink}

View File

@@ -5,6 +5,7 @@ export * from "./sidebar-select";
export * from "./delete-module-modal";
export * from "./form";
export * from "./gantt-chart";
export * from "./links";
export * from "./modal";
export * from "./modules-list-view";
export * from "./module-card-item";

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 => {
// return true if comment is undefined
if (!comment) return true;
return comment?.trim() === "" || comment === "<p></p>" || isEmptyHtmlString(comment ?? "", ["mention-component"]);
return (
comment?.trim() === "" || comment === "<p></p>" || isEmptyHtmlString(comment ?? "", ["img", "mention-component"])
);
};
/**
* @description
* This function test whether a URL is valid or not.
*
* It accepts URLs with or without the protocol.
* @param {string} url
* @returns {boolean}
* @example
* checkURLValidity("https://example.com") => true
* checkURLValidity("example.com") => true
* checkURLValidity("example") => false
*/
export const checkURLValidity = (url: string): boolean => {
if (!url) return false;
// regex to match valid URLs (with or without http/https)
const urlPattern = /^(https?:\/\/)?([\da-z.-]+)\.([a-z]{2,6})(\/[\w.-]*)*\/?(\?[=&\w.-]*)?$/i;
// test if the URL matches the pattern
return urlPattern.test(url);
};