feat: view, create, update and delete comment (#2106)

* feat: update, delete link

refactor: using old fetch-key

* feat: issue activity with ability to view & add comment

feat: click on view more to view more options in the issue detail

* fix: upload image not working on mobile
This commit is contained in:
Dakshesh Jain 2023-09-06 17:08:19 +05:30 committed by GitHub
parent 58562dc4b7
commit 1655d0cb1c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 781 additions and 48 deletions

View File

@ -0,0 +1,129 @@
import React from "react";
// next
import { useRouter } from "next/router";
// react-hook-form
import { useForm, Controller } from "react-hook-form";
// hooks
import useProjectDetails from "hooks/use-project-details";
// components
import { TipTapEditor } from "components/tiptap";
// icons
import { Send } from "lucide-react";
// ui
import { Icon, SecondaryButton, Tooltip, PrimaryButton } from "components/ui";
// types
import type { IIssueComment } from "types";
const defaultValues: Partial<IIssueComment> = {
access: "INTERNAL",
comment_html: "",
};
type Props = {
disabled?: boolean;
onSubmit: (data: IIssueComment) => Promise<void>;
};
const commentAccess = [
{
icon: "lock",
key: "INTERNAL",
label: "Private",
},
{
icon: "public",
key: "EXTERNAL",
label: "Public",
},
];
export const AddComment: React.FC<Props> = ({ disabled = false, onSubmit }) => {
const editorRef = React.useRef<any>(null);
const router = useRouter();
const { workspaceSlug } = router.query;
const { projectDetails } = useProjectDetails();
const showAccessSpecifier = projectDetails?.is_deployed;
const {
control,
formState: { isSubmitting },
handleSubmit,
reset,
} = useForm<IIssueComment>({ defaultValues });
const handleAddComment = async (formData: IIssueComment) => {
if (!formData.comment_html || isSubmitting) return;
await onSubmit(formData).then(() => {
reset(defaultValues);
editorRef.current?.clearEditor();
});
};
return (
<form className="w-full flex gap-x-2" onSubmit={handleSubmit(handleAddComment)}>
<div className="relative flex-grow">
{showAccessSpecifier && (
<div className="absolute bottom-2 left-3 z-[1]">
<Controller
control={control}
name="access"
render={({ field: { onChange, value } }) => (
<div className="flex border border-custom-border-300 divide-x divide-custom-border-300 rounded overflow-hidden">
{commentAccess.map((access) => (
<Tooltip key={access.key} tooltipContent={access.label}>
<button
type="button"
onClick={() => onChange(access.key)}
className={`grid place-items-center p-1 hover:bg-custom-background-80 ${
value === access.key ? "bg-custom-background-80" : ""
}`}
>
<Icon
iconName={access.icon}
className={`w-4 h-4 -mt-1 ${
value === access.key ? "!text-custom-text-100" : "!text-custom-text-400"
}`}
/>
</button>
</Tooltip>
))}
</div>
)}
/>
</div>
)}
<Controller
name="comment_html"
control={control}
render={({ field: { value, onChange } }) => (
<TipTapEditor
workspaceSlug={workspaceSlug as string}
ref={editorRef}
value={!value || value === "" ? "<p></p>" : value}
customClassName="p-3 min-h-[100px] shadow-sm"
debouncedUpdatesEnabled={false}
onChange={(comment_json: Object, comment_html: string) => onChange(comment_html)}
/>
)}
/>
</div>
<div className="inline">
<PrimaryButton type="submit" disabled={isSubmitting || disabled} className="mt-2">
<Send className="w-4 h-4" />
</PrimaryButton>
</div>
</form>
);
};

View File

@ -1,5 +1,5 @@
// react // react
import React from "react"; import React, { useEffect } from "react";
// next // next
import { useRouter } from "next/router"; import { useRouter } from "next/router";
@ -14,7 +14,7 @@ import { useForm } from "react-hook-form";
import issuesService from "services/issues.service"; import issuesService from "services/issues.service";
// fetch keys // fetch keys
import { M_ISSUE_DETAILS } from "constants/fetch-keys"; import { ISSUE_DETAILS } from "constants/fetch-keys";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
@ -26,13 +26,14 @@ import { PrimaryButton, Input } from "components/ui";
import type { linkDetails, IIssueLink } from "types"; import type { linkDetails, IIssueLink } from "types";
type Props = { type Props = {
links?: linkDetails[]; isOpen: boolean;
data?: linkDetails; data?: linkDetails;
links?: linkDetails[];
onSuccess: () => void; onSuccess: () => void;
}; };
export const CreateUpdateLinkForm: React.FC<Props> = (props) => { export const CreateUpdateLinkForm: React.FC<Props> = (props) => {
const { data, links, onSuccess } = props; const { isOpen, data, links, onSuccess } = props;
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query; const { workspaceSlug, projectId, issueId } = router.query;
@ -42,6 +43,7 @@ export const CreateUpdateLinkForm: React.FC<Props> = (props) => {
const { const {
register, register,
handleSubmit, handleSubmit,
reset,
formState: { errors, isSubmitting }, formState: { errors, isSubmitting },
} = useForm({ } = useForm({
defaultValues: { defaultValues: {
@ -50,6 +52,22 @@ export const CreateUpdateLinkForm: React.FC<Props> = (props) => {
}, },
}); });
useEffect(() => {
if (!data) return;
reset({
title: data.title,
url: data.url,
});
}, [data, reset]);
useEffect(() => {
if (!isOpen)
reset({
title: "",
url: "",
});
}, [isOpen, reset]);
const onSubmit = async (formData: IIssueLink) => { const onSubmit = async (formData: IIssueLink) => {
if (!workspaceSlug || !projectId || !issueId) return; if (!workspaceSlug || !projectId || !issueId) return;
@ -65,9 +83,7 @@ export const CreateUpdateLinkForm: React.FC<Props> = (props) => {
) )
.then(() => { .then(() => {
onSuccess(); onSuccess();
mutate( mutate(ISSUE_DETAILS(issueId.toString()));
M_ISSUE_DETAILS(workspaceSlug.toString(), projectId.toString(), issueId.toString())
);
}) })
.catch((err) => { .catch((err) => {
if (err?.status === 400) if (err?.status === 400)
@ -95,7 +111,7 @@ export const CreateUpdateLinkForm: React.FC<Props> = (props) => {
); );
mutate( mutate(
M_ISSUE_DETAILS(workspaceSlug.toString(), projectId.toString(), issueId.toString()), ISSUE_DETAILS(issueId.toString()),
(prevData) => ({ ...prevData, issue_link: updatedLinks }), (prevData) => ({ ...prevData, issue_link: updatedLinks }),
false false
); );
@ -110,9 +126,7 @@ export const CreateUpdateLinkForm: React.FC<Props> = (props) => {
) )
.then(() => { .then(() => {
onSuccess(); onSuccess();
mutate( mutate(ISSUE_DETAILS(issueId.toString()));
M_ISSUE_DETAILS(workspaceSlug.toString(), projectId.toString(), issueId.toString())
);
}); });
} }
}; };

View File

@ -8,3 +8,7 @@ export * from "./issue-attachments";
export * from "./issue-properties-detail"; export * from "./issue-properties-detail";
export * from "./issue-link-list"; export * from "./issue-link-list";
export * from "./create-update-link-form"; export * from "./create-update-link-form";
export * from "./issue-activity";
export * from "./select-assignee";
export * from "./select-estimate";
export * from "./add-comment";

View File

@ -0,0 +1,233 @@
// react
import React from "react";
// next
import Link from "next/link";
import { useRouter } from "next/router";
// swr
import useSWR, { mutate } from "swr";
// fetch key
import { PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
// services
import issuesService from "services/issues.service";
// hooks
import useUser from "hooks/use-user";
import useToast from "hooks/use-toast";
// components
import { Label, AddComment } from "components/web-view";
import { CommentCard } from "components/issues/comment";
import { ActivityIcon, ActivityMessage } from "components/core";
// helpers
import { timeAgo } from "helpers/date-time.helper";
// ui
import { Icon } from "components/ui";
// types
import type { IIssue, IIssueComment } from "types";
type Props = {
allowed: boolean;
issueDetails: IIssue;
};
export const IssueActivity: React.FC<Props> = (props) => {
const { issueDetails, allowed } = props;
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
const { user } = useUser();
const { setToastAlert } = useToast();
const { data: issueActivities, mutate: mutateIssueActivity } = useSWR(
workspaceSlug && projectId && issueId ? PROJECT_ISSUES_ACTIVITY(issueId.toString()) : null,
workspaceSlug && projectId && issueId
? () =>
issuesService.getIssueActivities(
workspaceSlug.toString(),
projectId.toString(),
issueId.toString()
)
: null
);
const handleCommentUpdate = async (comment: any) => {
if (!workspaceSlug || !projectId || !issueId) return;
await issuesService
.patchIssueComment(
workspaceSlug as string,
projectId as string,
issueId as string,
comment.id,
comment,
user
)
.then(() => mutateIssueActivity());
};
const handleCommentDelete = async (commentId: string) => {
if (!workspaceSlug || !projectId || !issueId) return;
mutateIssueActivity((prevData: any) => prevData?.filter((p: any) => p.id !== commentId), false);
await issuesService
.deleteIssueComment(
workspaceSlug as string,
projectId as string,
issueId as string,
commentId,
user
)
.then(() => mutateIssueActivity());
};
const handleAddComment = async (formData: IIssueComment) => {
if (!workspaceSlug || !issueDetails) return;
await issuesService
.createIssueComment(
workspaceSlug.toString(),
issueDetails.project,
issueDetails.id,
formData,
user
)
.then(() => {
mutate(PROJECT_ISSUES_ACTIVITY(issueDetails.id));
})
.catch(() =>
setToastAlert({
type: "error",
title: "Error!",
message: "Comment could not be posted. Please try again.",
})
);
};
return (
<div>
<Label>Activity</Label>
<div className="mt-1 space-y-[6px] p-2 border rounded-[4px]">
<ul role="list" className="-mb-4">
{issueActivities?.map((activityItem, index) => {
// determines what type of action is performed
const message = activityItem.field ? (
<ActivityMessage activity={activityItem} />
) : (
"created the issue."
);
if ("field" in activityItem && activityItem.field !== "updated_by") {
return (
<li key={activityItem.id}>
<div className="relative pb-1">
{issueActivities.length > 1 && index !== issueActivities.length - 1 ? (
<span
className="absolute top-5 left-5 -ml-px h-full w-0.5 bg-custom-background-80"
aria-hidden="true"
/>
) : null}
<div className="relative flex items-start space-x-2">
<div>
<div className="relative px-1.5">
<div className="mt-1.5">
<div className="ring-6 flex h-7 w-7 items-center justify-center rounded-full bg-custom-background-80 text-custom-text-200 ring-white">
{activityItem.field ? (
activityItem.new_value === "restore" ? (
<Icon
iconName="history"
className="text-sm text-custom-text-200"
/>
) : (
<ActivityIcon activity={activityItem} />
)
) : activityItem.actor_detail.avatar &&
activityItem.actor_detail.avatar !== "" ? (
<img
src={activityItem.actor_detail.avatar}
alt={activityItem.actor_detail.display_name}
height={24}
width={24}
className="rounded-full"
/>
) : (
<div
className={`grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-700 text-xs text-white`}
>
{activityItem.actor_detail.is_bot
? activityItem.actor_detail.first_name.charAt(0)
: activityItem.actor_detail.display_name.charAt(0)}
</div>
)}
</div>
</div>
</div>
</div>
<div className="min-w-0 flex-1 py-3">
<div className="text-xs text-custom-text-200 break-words">
{activityItem.field === "archived_at" &&
activityItem.new_value !== "restore" ? (
<span className="text-gray font-medium">Plane</span>
) : activityItem.actor_detail.is_bot ? (
<span className="text-gray font-medium">
{activityItem.actor_detail.first_name} Bot
</span>
) : (
<Link
href={`/${workspaceSlug}/profile/${activityItem.actor_detail.id}`}
>
<a className="text-gray font-medium">
{activityItem.actor_detail.is_bot
? activityItem.actor_detail.first_name
: activityItem.actor_detail.display_name}
</a>
</Link>
)}{" "}
{message}{" "}
<span className="whitespace-nowrap">
{timeAgo(activityItem.created_at)}
</span>
</div>
</div>
</div>
</div>
</li>
);
} else if ("comment_json" in activityItem)
return (
<div key={activityItem.id} className="my-4">
<CommentCard
workspaceSlug={workspaceSlug as string}
comment={activityItem as any}
onSubmit={handleCommentUpdate}
handleCommentDeletion={handleCommentDelete}
/>
</div>
);
})}
<li>
<div className="my-4">
<AddComment
onSubmit={handleAddComment}
disabled={
!allowed ||
!issueDetails ||
issueDetails.state === "closed" ||
issueDetails.state === "archived"
}
/>
</div>
</li>
</ul>
</div>
</div>
);
};

View File

@ -21,10 +21,11 @@ import { ISSUE_ATTACHMENTS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
// icons // icons
import { ChevronRightIcon } from "@heroicons/react/24/outline"; import { ChevronRightIcon, XMarkIcon } from "@heroicons/react/24/outline";
// components // components
import { Label, WebViewModal } from "components/web-view"; import { Label, WebViewModal } from "components/web-view";
import { DeleteAttachmentModal } from "components/issues";
// types // types
import type { IIssueAttachment } from "types"; import type { IIssueAttachment } from "types";
@ -42,6 +43,9 @@ export const IssueAttachments: React.FC<Props> = (props) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [deleteAttachment, setDeleteAttachment] = useState<IIssueAttachment | null>(null);
const [attachmentDeleteModal, setAttachmentDeleteModal] = useState<boolean>(false);
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const onDrop = useCallback( const onDrop = useCallback(
@ -92,7 +96,7 @@ export const IssueAttachments: React.FC<Props> = (props) => {
[issueId, projectId, setToastAlert, workspaceSlug] [issueId, projectId, setToastAlert, workspaceSlug]
); );
const { getRootProps } = useDropzone({ const { getRootProps, getInputProps } = useDropzone({
onDrop, onDrop,
maxSize: 5 * 1024 * 1024, maxSize: 5 * 1024 * 1024,
disabled: !allowed || isLoading, disabled: !allowed || isLoading,
@ -112,6 +116,12 @@ export const IssueAttachments: React.FC<Props> = (props) => {
return ( return (
<div> <div>
<DeleteAttachmentModal
isOpen={allowed && attachmentDeleteModal}
setIsOpen={setAttachmentDeleteModal}
data={deleteAttachment}
/>
<WebViewModal isOpen={isOpen} onClose={() => setIsOpen(false)} modalTitle="Insert file"> <WebViewModal isOpen={isOpen} onClose={() => setIsOpen(false)} modalTitle="Insert file">
<div className="space-y-6"> <div className="space-y-6">
<div <div
@ -120,6 +130,7 @@ export const IssueAttachments: React.FC<Props> = (props) => {
!allowed || isLoading ? "cursor-not-allowed" : "cursor-pointer" !allowed || isLoading ? "cursor-not-allowed" : "cursor-pointer"
}`} }`}
> >
<input {...getInputProps()} />
{isLoading ? ( {isLoading ? (
<p className="text-center">Uploading...</p> <p className="text-center">Uploading...</p>
) : ( ) : (
@ -144,6 +155,17 @@ export const IssueAttachments: React.FC<Props> = (props) => {
{attachment.attributes.name} {attachment.attributes.name}
</a> </a>
</Link> </Link>
{allowed && (
<button
type="button"
onClick={() => {
setDeleteAttachment(attachment);
setAttachmentDeleteModal(true);
}}
>
<XMarkIcon className="w-5 h-5 text-custom-text-100" />
</button>
)}
</div> </div>
))} ))}
<button <button

View File

@ -3,9 +3,16 @@ import React, { useState } from "react";
// next // next
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router";
// swr
import { mutate } from "swr";
// services
import issuesService from "services/issues.service";
// icons // icons
import { LinkIcon, PlusIcon } from "@heroicons/react/24/outline"; import { LinkIcon, PlusIcon, PencilIcon, TrashIcon } from "@heroicons/react/24/outline";
// components // components
import { Label, WebViewModal, CreateUpdateLinkForm } from "components/web-view"; import { Label, WebViewModal, CreateUpdateLinkForm } from "components/web-view";
@ -13,26 +20,71 @@ import { Label, WebViewModal, CreateUpdateLinkForm } from "components/web-view";
// ui // ui
import { SecondaryButton } from "components/ui"; import { SecondaryButton } from "components/ui";
// fetch keys
import { ISSUE_DETAILS } from "constants/fetch-keys";
// types // types
import type { linkDetails } from "types"; import type { IIssue } from "types";
type Props = { type Props = {
allowed: boolean; allowed: boolean;
links?: linkDetails[]; issueDetails: IIssue;
}; };
export const IssueLinks: React.FC<Props> = (props) => { export const IssueLinks: React.FC<Props> = (props) => {
const { links, allowed } = props; const { issueDetails, allowed } = props;
const links = issueDetails?.issue_link;
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [selectedLink, setSelectedLink] = useState<string | null>(null);
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const handleDeleteLink = async (linkId: string) => {
if (!workspaceSlug || !projectId || !issueDetails) return;
const updatedLinks = issueDetails.issue_link.filter((l) => l.id !== linkId);
mutate<IIssue>(
ISSUE_DETAILS(issueDetails.id),
(prevData) => ({ ...(prevData as IIssue), issue_link: updatedLinks }),
false
);
await issuesService
.deleteIssueLink(workspaceSlug as string, projectId as string, issueDetails.id, linkId)
.then((res) => {
mutate(ISSUE_DETAILS(issueDetails.id));
})
.catch((err) => {
console.log(err);
});
};
return ( return (
<div> <div>
<WebViewModal isOpen={isOpen} onClose={() => setIsOpen(false)} modalTitle="Add Link"> <WebViewModal
<CreateUpdateLinkForm links={links} onSuccess={() => setIsOpen(false)} /> isOpen={isOpen}
onClose={() => {
setIsOpen(false);
setSelectedLink(null);
}}
modalTitle={selectedLink ? "Update Link" : "Add Link"}
>
<CreateUpdateLinkForm
isOpen={isOpen}
links={links}
onSuccess={() => {
setIsOpen(false);
setSelectedLink(null);
}}
data={links?.find((link) => link.id === selectedLink)}
/>
</WebViewModal> </WebViewModal>
<Label>Attachments</Label> <Label>Links</Label>
<div className="mt-1 space-y-[6px]"> <div className="mt-1 space-y-[6px]">
{links?.map((link) => ( {links?.map((link) => (
<div <div
@ -47,6 +99,27 @@ export const IssueLinks: React.FC<Props> = (props) => {
<span>{link.title}</span> <span>{link.title}</span>
</a> </a>
</Link> </Link>
{allowed && (
<div className="flex gap-2 items-center">
<button
type="button"
onClick={() => {
setIsOpen(true);
setSelectedLink(link.id);
}}
>
<PencilIcon className="w-5 h-5 text-custom-text-100" />
</button>
<button
type="button"
onClick={() => {
handleDeleteLink(link.id);
}}
>
<TrashIcon className="w-5 h-5 text-red-500 hover:bg-red-500/20" />
</button>
</div>
)}
</div> </div>
))} ))}
<SecondaryButton <SecondaryButton

View File

@ -1,30 +1,46 @@
// react // react
import React from "react"; import React, { useState } from "react";
// react hook forms // react hook forms
import { Controller } from "react-hook-form"; import { Control, Controller } from "react-hook-form";
// icons
import { ChevronDownIcon, PlayIcon } from "lucide-react";
// hooks
import useEstimateOption from "hooks/use-estimate-option";
// ui // ui
import { Icon } from "components/ui"; import { Icon, SecondaryButton } from "components/ui";
// components // components
import { Label, StateSelect, PrioritySelect } from "components/web-view"; import {
Label,
StateSelect,
PrioritySelect,
AssigneeSelect,
EstimateSelect,
} from "components/web-view";
// types // types
import type { IIssue } from "types"; import type { IIssue } from "types";
type Props = { type Props = {
control: any; control: Control<IIssue, any>;
submitChanges: (data: Partial<IIssue>) => Promise<void>; submitChanges: (data: Partial<IIssue>) => Promise<void>;
}; };
export const IssuePropertiesDetail: React.FC<Props> = (props) => { export const IssuePropertiesDetail: React.FC<Props> = (props) => {
const { control, submitChanges } = props; const { control, submitChanges } = props;
const [isViewAllOpen, setIsViewAllOpen] = useState(false);
const { isEstimateActive } = useEstimateOption();
return ( return (
<div> <div>
<Label>Details</Label> <Label>Details</Label>
<div className="space-y-2 mb-[6px]"> <div className="mb-[6px]">
<div className="border border-custom-border-200 rounded-[4px] p-2 flex justify-between items-center"> <div className="border border-custom-border-200 rounded-[4px] p-2 flex justify-between items-center">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Icon iconName="grid_view" /> <Icon iconName="grid_view" />
@ -44,10 +60,10 @@ export const IssuePropertiesDetail: React.FC<Props> = (props) => {
</div> </div>
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="mb-[6px]">
<div className="border border-custom-border-200 rounded-[4px] p-2 flex justify-between items-center"> <div className="border border-custom-border-200 rounded-[4px] p-2 flex justify-between items-center">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Icon iconName="grid_view" /> <Icon iconName="signal_cellular_alt" />
<span className="text-sm text-custom-text-200">Priority</span> <span className="text-sm text-custom-text-200">Priority</span>
</div> </div>
<div> <div>
@ -64,6 +80,67 @@ export const IssuePropertiesDetail: React.FC<Props> = (props) => {
</div> </div>
</div> </div>
</div> </div>
<div className="mb-[6px]">
<div className="border border-custom-border-200 rounded-[4px] p-2 flex justify-between items-center">
<div className="flex items-center gap-1">
<Icon iconName="person" />
<span className="text-sm text-custom-text-200">Assignee</span>
</div>
<div>
<Controller
control={control}
name="assignees_list"
render={({ field: { value } }) => (
<AssigneeSelect
value={value}
onChange={(val: string) => submitChanges({ assignees_list: [val] })}
/>
)}
/>
</div>
</div>
</div>
{isViewAllOpen && (
<>
{isEstimateActive && (
<div className="mb-[6px]">
<div className="border border-custom-border-200 rounded-[4px] p-2 flex justify-between items-center">
<div className="flex items-center gap-1">
<PlayIcon className="h-4 w-4 flex-shrink-0 -rotate-90" />
<span className="text-sm text-custom-text-200">Estimate</span>
</div>
<div>
<Controller
control={control}
name="estimate_point"
render={({ field: { value } }) => (
<EstimateSelect
value={value}
onChange={(val) => submitChanges({ estimate_point: val })}
/>
)}
/>
</div>
</div>
</div>
)}
</>
)}
<div className="mb-[6px]">
<SecondaryButton
type="button"
onClick={() => setIsViewAllOpen((prev) => !prev)}
className="w-full flex justify-center items-center gap-1 !py-2"
>
<span className="text-base text-custom-primary-100">
{isViewAllOpen ? "View less" : "View all"}
</span>
<ChevronDownIcon
size={16}
className={`ml-1 text-custom-primary-100 ${isViewAllOpen ? "-rotate-180" : ""}`}
/>
</SecondaryButton>
</div>
</div> </div>
); );
}; };

View File

@ -0,0 +1,95 @@
// react
import React, { useState } from "react";
// next
import { useRouter } from "next/router";
// swr
import useSWR from "swr";
// icons
import { ChevronDownIcon } from "@heroicons/react/24/outline";
// services
import projectService from "services/project.service";
// fetch key
import { PROJECT_MEMBERS } from "constants/fetch-keys";
// components
import { Avatar } from "components/ui/avatar";
import { WebViewModal } from "./web-view-modal";
type Props = {
value: string[];
onChange: (value: any) => void;
disabled?: boolean;
};
export const AssigneeSelect: React.FC<Props> = (props) => {
const { value, onChange, disabled = false } = props;
const [isOpen, setIsOpen] = useState(false);
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { data: members } = useSWR(
workspaceSlug && projectId ? PROJECT_MEMBERS(projectId as string) : null,
workspaceSlug && projectId
? () => projectService.projectMembers(workspaceSlug as string, projectId as string)
: null
);
const selectedAssignees = members?.filter((member) => value?.includes(member.member.id));
return (
<>
<WebViewModal
isOpen={isOpen}
modalTitle="Select state"
onClose={() => {
setIsOpen(false);
}}
>
<WebViewModal.Options
options={
members?.map((member) => ({
label: member.member.display_name,
value: member.member.id,
checked: value?.includes(member.member.id),
icon: <Avatar user={member.member} />,
onClick: () => {
setIsOpen(false);
if (disabled) return;
onChange(member.member.id);
},
})) || []
}
/>
</WebViewModal>
<button
type="button"
disabled={disabled}
onClick={() => setIsOpen(true)}
className={
"relative w-full px-2.5 py-0.5 text-base flex justify-between items-center gap-0.5 text-custom-text-100"
}
>
{value && value.length > 0 && Array.isArray(value) ? (
<div className="-my-0.5 flex items-center gap-2">
<Avatar user={selectedAssignees?.[0].member} />
<span className="text-custom-text-100 text-xs">
{selectedAssignees?.length} Assignees
</span>
</div>
) : (
"No assignees"
)}
{/* {selectedAssignee?.member.display_name || "Select assignee"} */}
<ChevronDownIcon className="w-5 h-5" />
</button>
</>
);
};

View File

@ -0,0 +1,83 @@
// react
import React, { useState } from "react";
// icons
import { ChevronDownIcon, PlayIcon } from "lucide-react";
// hooks
import useEstimateOption from "hooks/use-estimate-option";
// components
import { WebViewModal } from "./web-view-modal";
type Props = {
value: any;
onChange: (value: any) => void;
disabled?: boolean;
};
export const EstimateSelect: React.FC<Props> = (props) => {
const { value, onChange, disabled = false } = props;
const [isOpen, setIsOpen] = useState(false);
const { estimatePoints } = useEstimateOption();
return (
<>
<WebViewModal
isOpen={isOpen}
modalTitle="Select estimate"
onClose={() => {
setIsOpen(false);
}}
>
<WebViewModal.Options
options={[
{
label: "None",
value: null,
checked: value === null,
onClick: () => {
setIsOpen(false);
if (disabled) return;
onChange(null);
},
icon: <PlayIcon className="h-4 w-4 -rotate-90" />,
},
...estimatePoints?.map((point) => ({
label: point.value,
value: point.key,
checked: point.key === value,
icon: <PlayIcon className="h-4 w-4 -rotate-90" />,
onClick: () => {
setIsOpen(false);
if (disabled) return;
onChange(point.key);
},
})),
]}
/>
</WebViewModal>
<button
type="button"
disabled={disabled}
onClick={() => setIsOpen(true)}
className={
"relative w-full px-2.5 py-0.5 text-base flex justify-between items-center gap-0.5 text-custom-text-100"
}
>
{value ? (
<div className="flex items-center gap-x-1.5">
<PlayIcon className="h-4 w-4 -rotate-90" />
<span>{estimatePoints?.find((e) => e.key === value)?.value}</span>
</div>
) : (
"No estimate"
)}
<ChevronDownIcon className="w-5 h-5" />
</button>
</>
);
};

View File

@ -2,7 +2,7 @@
import React, { useState } from "react"; import React, { useState } from "react";
// icons // icons
import { ChevronDownIcon } from "@heroicons/react/24/outline"; import { ChevronDownIcon } from "lucide-react";
// constants // constants
import { PRIORITIES } from "constants/project"; import { PRIORITIES } from "constants/project";
@ -35,11 +35,16 @@ export const PrioritySelect: React.FC<Props> = (props) => {
}} }}
> >
<WebViewModal.Options <WebViewModal.Options
selectedOption={value}
options={ options={
PRIORITIES?.map((priority) => ({ PRIORITIES?.map((priority) => ({
label: priority ? capitalizeFirstLetter(priority) : "None", label: priority ? capitalizeFirstLetter(priority) : "None",
value: priority, value: priority,
checked: priority === value,
onClick: () => {
setIsOpen(false);
if (disabled) return;
onChange(priority);
},
icon: ( icon: (
<span <span
className={`text-left text-xs capitalize rounded ${ className={`text-left text-xs capitalize rounded ${
@ -57,11 +62,6 @@ export const PrioritySelect: React.FC<Props> = (props) => {
{getPriorityIcon(priority, "text-sm")} {getPriorityIcon(priority, "text-sm")}
</span> </span>
), ),
onClick: () => {
setIsOpen(false);
if (disabled) return;
onChange(priority);
},
})) || [] })) || []
} }
/> />

View File

@ -57,11 +57,11 @@ export const StateSelect: React.FC<Props> = (props) => {
}} }}
> >
<WebViewModal.Options <WebViewModal.Options
selectedOption={selectedState?.id || null}
options={ options={
states?.map((state) => ({ states?.map((state) => ({
label: state.name, label: state.name,
value: state.id, value: state.id,
checked: state.id === selectedState?.id,
icon: getStateGroupIcon(state.group, "16", "16", state.color), icon: getStateGroupIcon(state.group, "16", "16", state.color),
onClick: () => { onClick: () => {
setIsOpen(false); setIsOpen(false);

View File

@ -74,24 +74,24 @@ export const WebViewModal = (props: Props) => {
}; };
type OptionsProps = { type OptionsProps = {
selectedOption: string | null;
options: Array<{ options: Array<{
label: string; label: string;
value: string | null; value: string | null;
checked: boolean;
icon?: any; icon?: any;
onClick: () => void; onClick: () => void;
}>; }>;
}; };
const Options: React.FC<OptionsProps> = ({ options, selectedOption }) => ( const Options: React.FC<OptionsProps> = ({ options }) => (
<div className="space-y-6"> <div className="space-y-6">
{options.map((option) => ( {options.map((option) => (
<div key={option.value} className="flex items-center justify-between gap-2 py-2"> <div key={option.value} className="flex items-center justify-between gap-2 py-2">
<div className="flex items-center gap-x-2"> <div className="flex items-center gap-x-2">
<input <input
type="checkbox" type="checkbox"
checked={option.value === selectedOption} checked={option.checked}
onClick={option.onClick} onChange={option.onClick}
className="rounded-full border border-custom-border-200 bg-custom-background-100 w-4 h-4" className="rounded-full border border-custom-border-200 bg-custom-background-100 w-4 h-4"
/> />

View File

@ -229,8 +229,6 @@ export const INBOX_ISSUE_DETAILS = (inboxId: string, issueId: string) =>
// Issues // Issues
export const ISSUE_DETAILS = (issueId: string) => `ISSUE_DETAILS_${issueId.toUpperCase()}`; export const ISSUE_DETAILS = (issueId: string) => `ISSUE_DETAILS_${issueId.toUpperCase()}`;
export const M_ISSUE_DETAILS = (workspaceSlug: string, projectId: string, issueId: string) =>
`M_ISSUE_DETAILS_${workspaceSlug.toUpperCase()}_${projectId.toUpperCase()}_${issueId}`;
export const SUB_ISSUES = (issueId: string) => `SUB_ISSUES_${issueId.toUpperCase()}`; export const SUB_ISSUES = (issueId: string) => `SUB_ISSUES_${issueId.toUpperCase()}`;
export const ISSUE_ATTACHMENTS = (issueId: string) => `ISSUE_ATTACHMENTS_${issueId.toUpperCase()}`; export const ISSUE_ATTACHMENTS = (issueId: string) => `ISSUE_ATTACHMENTS_${issueId.toUpperCase()}`;
export const ARCHIVED_ISSUE_DETAILS = (issueId: string) => export const ARCHIVED_ISSUE_DETAILS = (issueId: string) =>

View File

@ -14,7 +14,7 @@ import { useForm } from "react-hook-form";
import issuesService from "services/issues.service"; import issuesService from "services/issues.service";
// fetch key // fetch key
import { M_ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys"; import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
// hooks // hooks
import useUser from "hooks/use-user"; import useUser from "hooks/use-user";
@ -33,6 +33,7 @@ import {
IssueAttachments, IssueAttachments,
IssuePropertiesDetail, IssuePropertiesDetail,
IssueLinks, IssueLinks,
IssueActivity,
} from "components/web-view"; } from "components/web-view";
// types // types
@ -66,9 +67,7 @@ const MobileWebViewIssueDetail = () => {
mutate: mutateIssueDetails, mutate: mutateIssueDetails,
error, error,
} = useSWR( } = useSWR(
workspaceSlug && projectId && issueId workspaceSlug && projectId && issueId ? ISSUE_DETAILS(issueId.toString()) : null,
? M_ISSUE_DETAILS(workspaceSlug.toString(), projectId.toString(), issueId.toString())
: null,
workspaceSlug && projectId && issueId workspaceSlug && projectId && issueId
? () => ? () =>
issuesService.retrieve(workspaceSlug.toString(), projectId.toString(), issueId.toString()) issuesService.retrieve(workspaceSlug.toString(), projectId.toString(), issueId.toString())
@ -83,6 +82,10 @@ const MobileWebViewIssueDetail = () => {
description: issueDetails.description, description: issueDetails.description,
description_html: issueDetails.description_html, description_html: issueDetails.description_html,
state: issueDetails.state, state: issueDetails.state,
assignees_list:
issueDetails.assignees_list ?? issueDetails.assignee_details?.map((user) => user.id),
labels_list: issueDetails.labels_list ?? issueDetails.labels,
labels: issueDetails.labels_list ?? issueDetails.labels,
}); });
}, [issueDetails, reset]); }, [issueDetails, reset]);
@ -91,7 +94,7 @@ const MobileWebViewIssueDetail = () => {
if (!workspaceSlug || !projectId || !issueId) return; if (!workspaceSlug || !projectId || !issueId) return;
mutate<IIssue>( mutate<IIssue>(
M_ISSUE_DETAILS(workspaceSlug.toString(), projectId.toString(), issueId.toString()), ISSUE_DETAILS(issueId.toString()),
(prevData) => { (prevData) => {
if (!prevData) return prevData; if (!prevData) return prevData;
@ -161,7 +164,9 @@ const MobileWebViewIssueDetail = () => {
<IssueAttachments allowed={isAllowed} /> <IssueAttachments allowed={isAllowed} />
<IssueLinks allowed={isAllowed} links={issueDetails?.issue_link} /> <IssueLinks allowed={isAllowed} issueDetails={issueDetails!} />
<IssueActivity allowed={isAllowed} issueDetails={issueDetails!} />
</div> </div>
</DefaultLayout> </DefaultLayout>
); );