From 7c1f357bed480fd0ebe396044a8875d452593c19 Mon Sep 17 00:00:00 2001 From: pablohashescobar <118773738+pablohashescobar@users.noreply.github.com> Date: Fri, 17 Feb 2023 17:04:12 +0530 Subject: [PATCH] feat: issue links (#288) * feat: links for issues * fix: add issue link in serilaizer * feat: links can be added to issues --------- Co-authored-by: Aaryan Khandelwal --- apiserver/plane/api/serializers/issue.py | 57 ++++++ apiserver/plane/api/views/issue.py | 24 +-- apiserver/plane/db/models/__init__.py | 1 + apiserver/plane/db/models/issue.py | 17 ++ apps/app/components/core/index.ts | 1 + .../link-modal.tsx} | 36 +--- apps/app/components/issues/index.ts | 1 + apps/app/components/issues/links-list.tsx | 179 ++++++++++++++++++ .../app/components/issues/sub-issues-list.tsx | 20 +- apps/app/components/modules/index.ts | 1 - apps/app/components/modules/sidebar.tsx | 41 ++-- .../projects/[projectId]/issues/[issueId].tsx | 4 +- apps/app/types/issues.d.ts | 14 ++ 13 files changed, 331 insertions(+), 65 deletions(-) rename apps/app/components/{modules/module-link-modal.tsx => core/link-modal.tsx} (79%) create mode 100644 apps/app/components/issues/links-list.tsx diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py index 3add8f965..c501a3d94 100644 --- a/apiserver/plane/api/serializers/issue.py +++ b/apiserver/plane/api/serializers/issue.py @@ -24,9 +24,15 @@ from plane.db.models import ( Cycle, Module, ModuleIssue, + IssueLink, ) +class IssueLinkCreateSerializer(serializers.Serializer): + url = serializers.CharField(required=True) + title = serializers.CharField(required=False) + + class IssueFlatSerializer(BaseSerializer): ## Contain only flat fields @@ -86,6 +92,11 @@ class IssueCreateSerializer(BaseSerializer): write_only=True, required=False, ) + links_list = serializers.ListField( + child=IssueLinkCreateSerializer(), + write_only=True, + required=False, + ) class Meta: model = Issue @@ -104,6 +115,7 @@ class IssueCreateSerializer(BaseSerializer): assignees = validated_data.pop("assignees_list", None) labels = validated_data.pop("labels_list", None) blocks = validated_data.pop("blocks_list", None) + links = validated_data.pop("links_list", None) project = self.context["project"] issue = Issue.objects.create(**validated_data, project=project) @@ -172,6 +184,24 @@ class IssueCreateSerializer(BaseSerializer): batch_size=10, ) + if links is not None: + IssueLink.objects.bulk_create( + [ + IssueLink( + issue=issue, + project=project, + workspace=project.workspace, + created_by=issue.created_by, + updated_by=issue.updated_by, + title=link.get("title", None), + url=link.get("url", None), + ) + for link in links + ], + batch_size=10, + ignore_conflicts=True, + ) + return issue def update(self, instance, validated_data): @@ -179,6 +209,7 @@ class IssueCreateSerializer(BaseSerializer): assignees = validated_data.pop("assignees_list", None) labels = validated_data.pop("labels_list", None) blocks = validated_data.pop("blocks_list", None) + links = validated_data.pop("links_list", None) if blockers is not None: IssueBlocker.objects.filter(block=instance).delete() @@ -248,6 +279,25 @@ class IssueCreateSerializer(BaseSerializer): batch_size=10, ) + if links is not None: + IssueLink.objects.filter(issue=instance).delete() + IssueLink.objects.bulk_create( + [ + IssueLink( + issue=instance, + project=instance.project, + workspace=instance.project.workspace, + created_by=instance.created_by, + updated_by=instance.updated_by, + title=link.get("title", None), + url=link.get("url", None), + ) + for link in links + ], + batch_size=10, + ignore_conflicts=True, + ) + return super().update(instance, validated_data) @@ -410,6 +460,12 @@ class IssueModuleDetailSerializer(BaseSerializer): ] +class IssueLinkSerializer(BaseSerializer): + class Meta: + model = IssueLink + fields = "__all__" + + class IssueSerializer(BaseSerializer): project_detail = ProjectSerializer(read_only=True, source="project") state_detail = StateSerializer(read_only=True, source="state") @@ -422,6 +478,7 @@ class IssueSerializer(BaseSerializer): blocker_issues = BlockerIssueSerializer(read_only=True, many=True) issue_cycle = IssueCycleDetailSerializer(read_only=True) issue_module = IssueModuleDetailSerializer(read_only=True) + issue_link = IssueLinkSerializer(read_only=True, many=True) sub_issues_count = serializers.IntegerField(read_only=True) class Meta: diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index 37082e0ec..4f7e7473b 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -39,6 +39,7 @@ from plane.db.models import ( IssueBlocker, CycleIssue, ModuleIssue, + IssueLink, ) from plane.bgtasks.issue_activites_task import issue_activity @@ -75,7 +76,6 @@ class IssueViewSet(BaseViewSet): self.get_queryset().filter(pk=self.kwargs.get("pk", None)).first() ) if current_instance is not None: - issue_activity.delay( { "type": "issue.activity", @@ -92,7 +92,6 @@ class IssueViewSet(BaseViewSet): return super().perform_update(serializer) def get_queryset(self): - return ( super() .get_queryset() @@ -136,6 +135,12 @@ class IssueViewSet(BaseViewSet): ).prefetch_related("module__members"), ), ) + .prefetch_related( + Prefetch( + "issue_link", + queryset=IssueLink.objects.select_related("issue"), + ) + ) ) def grouper(self, issue, group_by): @@ -265,6 +270,12 @@ class UserWorkSpaceIssues(BaseAPIView): queryset=ModuleIssue.objects.select_related("module", "issue"), ), ) + .prefetch_related( + Prefetch( + "issue_link", + queryset=IssueLink.objects.select_related("issue"), + ) + ) ) serializer = IssueSerializer(issues, many=True) return Response(serializer.data, status=status.HTTP_200_OK) @@ -277,7 +288,6 @@ class UserWorkSpaceIssues(BaseAPIView): class WorkSpaceIssuesEndpoint(BaseAPIView): - permission_classes = [ WorkSpaceAdminPermission, ] @@ -298,7 +308,6 @@ class WorkSpaceIssuesEndpoint(BaseAPIView): class IssueActivityEndpoint(BaseAPIView): - permission_classes = [ ProjectEntityPermission, ] @@ -333,7 +342,6 @@ class IssueActivityEndpoint(BaseAPIView): class IssueCommentViewSet(BaseViewSet): - serializer_class = IssueCommentSerializer model = IssueComment permission_classes = [ @@ -436,7 +444,6 @@ class IssuePropertyViewSet(BaseViewSet): def create(self, request, slug, project_id): try: - issue_property, created = IssueProperty.objects.get_or_create( user=request.user, project_id=project_id, @@ -463,7 +470,6 @@ class IssuePropertyViewSet(BaseViewSet): class LabelViewSet(BaseViewSet): - serializer_class = LabelSerializer model = Label permission_classes = [ @@ -490,14 +496,12 @@ class LabelViewSet(BaseViewSet): class BulkDeleteIssuesEndpoint(BaseAPIView): - permission_classes = [ ProjectEntityPermission, ] def delete(self, request, slug, project_id): try: - issue_ids = request.data.get("issue_ids", []) if not len(issue_ids): @@ -527,14 +531,12 @@ class BulkDeleteIssuesEndpoint(BaseAPIView): class SubIssuesEndpoint(BaseAPIView): - permission_classes = [ ProjectEntityPermission, ] def get(self, request, slug, project_id, issue_id): try: - sub_issues = ( Issue.objects.filter( parent_id=issue_id, workspace__slug=slug, project_id=project_id diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py index ef7ad5b8d..d12578fa1 100644 --- a/apiserver/plane/db/models/__init__.py +++ b/apiserver/plane/db/models/__init__.py @@ -23,6 +23,7 @@ from .issue import ( IssueAssignee, Label, IssueBlocker, + IssueLink, ) from .asset import FileAsset diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py index 82e8343bb..d212f7565 100644 --- a/apiserver/plane/db/models/issue.py +++ b/apiserver/plane/db/models/issue.py @@ -161,6 +161,23 @@ class IssueAssignee(ProjectBaseModel): return f"{self.issue.name} {self.assignee.email}" +class IssueLink(ProjectBaseModel): + title = models.CharField(max_length=255, null=True) + url = models.URLField() + issue = models.ForeignKey( + "db.Issue", on_delete=models.CASCADE, related_name="issue_link" + ) + + class Meta: + verbose_name = "Issue Link" + verbose_name_plural = "Issue Links" + db_table = "issue_links" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.issue.name} {self.url}" + + class IssueActivity(ProjectBaseModel): issue = models.ForeignKey( Issue, on_delete=models.CASCADE, related_name="issue_activity" diff --git a/apps/app/components/core/index.ts b/apps/app/components/core/index.ts index 482258b4a..8146660a1 100644 --- a/apps/app/components/core/index.ts +++ b/apps/app/components/core/index.ts @@ -6,4 +6,5 @@ export * from "./existing-issues-list-modal"; export * from "./image-upload-modal"; export * from "./issues-view-filter"; export * from "./issues-view"; +export * from "./link-modal"; export * from "./not-authorized-view"; diff --git a/apps/app/components/modules/module-link-modal.tsx b/apps/app/components/core/link-modal.tsx similarity index 79% rename from apps/app/components/modules/module-link-modal.tsx rename to apps/app/components/core/link-modal.tsx index 2917717ad..2b1004e29 100644 --- a/apps/app/components/modules/module-link-modal.tsx +++ b/apps/app/components/core/link-modal.tsx @@ -8,19 +8,15 @@ import { mutate } from "swr"; import { useForm } from "react-hook-form"; // headless ui import { Dialog, Transition } from "@headlessui/react"; -// services -import modulesService from "services/modules.service"; // ui import { Button, Input } from "components/ui"; // types -import type { IModule, ModuleLink } from "types"; -// fetch-keys -import { MODULE_DETAILS } from "constants/fetch-keys"; +import type { IIssueLink, ModuleLink } from "types"; type Props = { isOpen: boolean; - module: IModule | undefined; handleClose: () => void; + onFormSubmit: (formData: IIssueLink | ModuleLink) => void; }; const defaultValues: ModuleLink = { @@ -28,42 +24,20 @@ const defaultValues: ModuleLink = { url: "", }; -export const ModuleLinkModal: React.FC = ({ isOpen, module, handleClose }) => { - const router = useRouter(); - const { workspaceSlug, projectId, moduleId } = router.query; - +export const LinkModal: React.FC = ({ isOpen, handleClose, onFormSubmit }) => { const { register, formState: { errors, isSubmitting }, handleSubmit, reset, - setError, } = useForm({ defaultValues, }); const onSubmit = async (formData: ModuleLink) => { - if (!workspaceSlug || !projectId || !moduleId) return; + await onFormSubmit(formData); - const previousLinks = module?.link_module.map((l) => ({ title: l.title, url: l.url })); - - const payload: Partial = { - links_list: [...(previousLinks ?? []), formData], - }; - - await modulesService - .patchModule(workspaceSlug as string, projectId as string, moduleId as string, payload) - .then((res) => { - mutate(MODULE_DETAILS(moduleId as string)); - onClose(); - }) - .catch((err) => { - Object.keys(err).map((key) => { - setError(key as keyof ModuleLink, { - message: err[key].join(", "), - }); - }); - }); + onClose(); }; const onClose = () => { diff --git a/apps/app/components/issues/index.ts b/apps/app/components/issues/index.ts index 18253c6d3..1c8884993 100644 --- a/apps/app/components/issues/index.ts +++ b/apps/app/components/issues/index.ts @@ -4,6 +4,7 @@ export * from "./activity"; export * from "./delete-issue-modal"; export * from "./description-form"; export * from "./form"; +export * from "./links-list"; export * from "./modal"; export * from "./my-issues-list-item"; export * from "./parent-issues-list-modal"; diff --git a/apps/app/components/issues/links-list.tsx b/apps/app/components/issues/links-list.tsx new file mode 100644 index 000000000..752c614b4 --- /dev/null +++ b/apps/app/components/issues/links-list.tsx @@ -0,0 +1,179 @@ +import { FC, useState } from "react"; + +import Link from "next/link"; +import { useRouter } from "next/router"; + +import { mutate } from "swr"; + +// headless ui +import { Disclosure, Transition } from "@headlessui/react"; +// services +import issuesService from "services/issues.service"; +// hooks +import useToast from "hooks/use-toast"; +// components +import { LinkModal } from "components/core"; +// ui +import { CustomMenu } from "components/ui"; +// icons +import { ChevronRightIcon, LinkIcon, PlusIcon } from "@heroicons/react/24/outline"; +// helpers +import { copyTextToClipboard } from "helpers/string.helper"; +import { timeAgo } from "helpers/date-time.helper"; +// types +import { IIssue, IIssueLink, UserAuth } from "types"; +// fetch-keys +import { ISSUE_DETAILS } from "constants/fetch-keys"; + +type Props = { + parentIssue: IIssue; + userAuth: UserAuth; +}; + +export const LinksList: FC = ({ parentIssue, userAuth }) => { + const [linkModal, setLinkModal] = useState(false); + + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; + + const { setToastAlert } = useToast(); + + const handleCreateLink = async (formData: IIssueLink) => { + if (!workspaceSlug || !projectId || !parentIssue) return; + + const previousLinks = parentIssue?.issue_link.map((l) => ({ title: l.title, url: l.url })); + + const payload: Partial = { + links_list: [...(previousLinks ?? []), formData], + }; + + await issuesService + .patchIssue(workspaceSlug as string, projectId as string, parentIssue.id, payload) + .then((res) => { + mutate(ISSUE_DETAILS(parentIssue.id as string)); + }) + .catch((err) => { + console.log(err); + }); + }; + + const handleDeleteLink = async (linkId: string) => { + if (!workspaceSlug || !projectId || !parentIssue) return; + + const updatedLinks = parentIssue.issue_link.filter((l) => l.id !== linkId); + + mutate( + ISSUE_DETAILS(parentIssue.id as string), + (prevData) => ({ ...(prevData as IIssue), issue_link: updatedLinks }), + false + ); + + await issuesService + .patchIssue(workspaceSlug as string, projectId as string, parentIssue.id, { + links_list: updatedLinks, + }) + .then((res) => { + mutate(ISSUE_DETAILS(parentIssue.id as string)); + }) + .catch((err) => { + console.log(err); + }); + }; + + const isNotAllowed = userAuth.isGuest || userAuth.isViewer; + + return ( + <> + setLinkModal(false)} + onFormSubmit={handleCreateLink} + /> + {parentIssue.issue_link && parentIssue.issue_link.length > 0 ? ( + + {({ open }) => ( + <> +
+ + + Links {parentIssue.issue_link.length} + + {open && !isNotAllowed ? ( +
+ +
+ ) : null} +
+ + + {parentIssue.issue_link.map((link) => ( +
+ + + + {link.title} + + {timeAgo(link.created_at)} + + + + {!isNotAllowed && ( +
+ + + copyTextToClipboard(link.url).then(() => { + setToastAlert({ + type: "success", + title: "Link copied to clipboard", + }); + }) + } + > + Copy link + + handleDeleteLink(link.id)}> + Remove link + + +
+ )} +
+ ))} +
+
+ + )} +
+ ) : ( + !isNotAllowed && ( + + ) + )} + + ); +}; diff --git a/apps/app/components/issues/sub-issues-list.tsx b/apps/app/components/issues/sub-issues-list.tsx index a274e51eb..7ef258d70 100644 --- a/apps/app/components/issues/sub-issues-list.tsx +++ b/apps/app/components/issues/sub-issues-list.tsx @@ -15,7 +15,7 @@ import { CreateUpdateIssueModal } from "components/issues"; // ui import { CustomMenu } from "components/ui"; // icons -import { ChevronRightIcon, PlusIcon } from "@heroicons/react/24/outline"; +import { ChevronRightIcon, PlusIcon, XMarkIcon } from "@heroicons/react/24/outline"; // helpers import { orderArrayBy } from "helpers/array.helper"; // types @@ -23,12 +23,12 @@ import { IIssue, IssueResponse, UserAuth } from "types"; // fetch-keys import { PROJECT_ISSUES_LIST, SUB_ISSUES } from "constants/fetch-keys"; -type SubIssueListProps = { +type Props = { parentIssue: IIssue; userAuth: UserAuth; }; -export const SubIssuesList: FC = ({ parentIssue, userAuth }) => { +export const SubIssuesList: FC = ({ parentIssue, userAuth }) => { // states const [createIssueModal, setCreateIssueModal] = useState(false); const [subIssuesListModal, setSubIssuesListModal] = useState(false); @@ -226,13 +226,13 @@ export const SubIssuesList: FC = ({ parentIssue, userAuth }) {!isNotAllowed && ( -
- - handleSubIssueRemove(issue.id)}> - Remove as sub-issue - - -
+ )} ))} diff --git a/apps/app/components/modules/index.ts b/apps/app/components/modules/index.ts index 6f3121292..5ba7ea47e 100644 --- a/apps/app/components/modules/index.ts +++ b/apps/app/components/modules/index.ts @@ -3,6 +3,5 @@ export * from "./sidebar-select"; export * from "./delete-module-modal"; export * from "./form"; export * from "./modal"; -export * from "./module-link-modal"; export * from "./sidebar"; export * from "./single-module-card"; diff --git a/apps/app/components/modules/sidebar.tsx b/apps/app/components/modules/sidebar.tsx index 9e379642e..8e9f4aec7 100644 --- a/apps/app/components/modules/sidebar.tsx +++ b/apps/app/components/modules/sidebar.tsx @@ -27,16 +27,12 @@ import modulesService from "services/modules.service"; // hooks import useToast from "hooks/use-toast"; // components -import { - DeleteModuleModal, - ModuleLinkModal, - SidebarLeadSelect, - SidebarMembersSelect, -} from "components/modules"; +import { LinkModal, SidebarProgressStats } from "components/core"; +import { DeleteModuleModal, SidebarLeadSelect, SidebarMembersSelect } from "components/modules"; +import ProgressChart from "components/core/sidebar/progress-chart"; import "react-circular-progressbar/dist/styles.css"; // components -import { SidebarProgressStats } from "components/core"; // ui import { CustomSelect, Loader } from "components/ui"; // helpers @@ -44,10 +40,9 @@ import { renderShortNumericDateFormat, timeAgo } from "helpers/date-time.helper" import { copyTextToClipboard } from "helpers/string.helper"; import { groupBy } from "helpers/array.helper"; // types -import { IIssue, IModule, ModuleIssueResponse } from "types"; +import { IIssue, IModule, ModuleIssueResponse, ModuleLink } from "types"; // fetch-keys import { MODULE_DETAILS } from "constants/fetch-keys"; -import ProgressChart from "components/core/sidebar/progress-chart"; // constant import { MODULE_STATUS } from "constants/module"; @@ -113,6 +108,29 @@ export const ModuleDetailsSidebar: React.FC = ({ issues, module, isOpen, }); }; + const handleCreateLink = async (formData: ModuleLink) => { + if (!workspaceSlug || !projectId || !moduleId) return; + + const previousLinks = module?.link_module.map((l) => ({ title: l.title, url: l.url })); + + const payload: Partial = { + links_list: [...(previousLinks ?? []), formData], + }; + + await modulesService + .patchModule(workspaceSlug as string, projectId as string, moduleId as string, payload) + .then((res) => { + mutate(MODULE_DETAILS(moduleId as string)); + }) + .catch((err) => { + setToastAlert({ + type: "error", + title: "Error!", + message: "Couldn't create the link. Please try again.", + }); + }); + }; + useEffect(() => { if (module) reset({ @@ -123,12 +141,13 @@ export const ModuleDetailsSidebar: React.FC = ({ issues, module, isOpen, const isStartValid = new Date(`${module?.start_date}`) <= new Date(); const isEndValid = new Date(`${module?.target_date}`) >= new Date(`${module?.start_date}`); + return ( <> - setModuleLinkModal(false)} - module={module} + onFormSubmit={handleCreateLink} /> = (props) => { handleFormSubmit={submitChanges} userAuth={props} /> -
+
+
diff --git a/apps/app/types/issues.d.ts b/apps/app/types/issues.d.ts index 08dbd5245..fe59d4ef4 100644 --- a/apps/app/types/issues.d.ts +++ b/apps/app/types/issues.d.ts @@ -60,6 +60,11 @@ export interface IIssueParent { target_date: string | null; } +export interface IIssueLink { + title: string; + url: string; +} + export interface IIssue { assignees: any[] | null; assignee_details: IUser[]; @@ -83,8 +88,17 @@ export interface IIssue { description_html: any; id: string; issue_cycle: IIssueCycle | null; + issue_link: { + created_at: Date; + created_by: string; + created_by_detail: IUserLite; + id: string; + title: string; + url: string; + }[]; issue_module: IIssueModule | null; label_details: any[]; + links_list: IIssueLink[]; module: string | null; name: string; parent: string | null;