diff --git a/web/components/issues/comment/comment-reaction.tsx b/web/components/issues/comment/comment-reaction.tsx index b6ce3bbbc..d84e2b9ff 100644 --- a/web/components/issues/comment/comment-reaction.tsx +++ b/web/components/issues/comment/comment-reaction.tsx @@ -13,10 +13,11 @@ import { renderEmoji } from "helpers/emoji.helper"; type Props = { projectId?: string | string[]; commentId: string; + readonly?: boolean; }; export const CommentReaction: React.FC = (props) => { - const { projectId, commentId } = props; + const { projectId, commentId, readonly = false } = props; const router = useRouter(); const { workspaceSlug } = router.query; @@ -47,16 +48,18 @@ export const CommentReaction: React.FC = (props) => { return (
- reaction.actor === user?.id) - .map((r) => r.reaction) || [] - } - onSelect={handleReactionClick} - /> + {!readonly && ( + reaction.actor === user?.id) + .map((r) => r.reaction) || [] + } + onSelect={handleReactionClick} + /> + )} {Object.keys(groupedReactions || {}).map( (reaction) => @@ -64,6 +67,7 @@ export const CommentReaction: React.FC = (props) => { groupedReactions[reaction].length > 0 && ( . ), @@ -124,9 +138,25 @@ const activityDetails: { {activity.old_value === "" ? "marked this issue is being blocked by issue " : "removed this issue being blocked by issue "} - + . ), @@ -139,9 +169,25 @@ const activityDetails: { {activity.old_value === "" ? "marked this issue as duplicate of " : "removed this issue as a duplicate of "} - + . ), @@ -154,9 +200,25 @@ const activityDetails: { {activity.old_value === "" ? "marked that this issue relates to " : "removed the relation from "} - + . ), @@ -175,14 +237,17 @@ const activityDetails: { console.log( "cycle", JSON.stringify({ - cycle_id: activity.new_identifier, + cycle_id: activity.new_identifier ?? activity.old_identifier, project_id: activity.project, + cycle_name: activity.verb === "created" ? activity.new_value : activity.old_value, }) ) } className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline" > - {activity.new_value} + {activity.verb === "created" || activity.verb === "updated" + ? activity.new_value + : activity.old_value} @@ -295,16 +360,23 @@ const activityDetails: { {activity.verb === "created" && "added this "} {activity.verb === "updated" && "updated this "} {activity.verb === "deleted" && "removed this "} + module{" "} . @@ -333,9 +405,25 @@ const activityDetails: { message: (activity, showIssue) => ( <> {activity.new_value ? "set the parent to " : "removed the parent "} - + {showIssue && ( <> {" "} diff --git a/web/components/web-view/commend-card.tsx b/web/components/web-view/commend-card.tsx new file mode 100644 index 000000000..d214ea90a --- /dev/null +++ b/web/components/web-view/commend-card.tsx @@ -0,0 +1,215 @@ +import React, { useEffect, useState } from "react"; + +// react-hook-form +import { useForm } from "react-hook-form"; +// icons +import { ChatBubbleLeftEllipsisIcon, CheckIcon, XMarkIcon } from "@heroicons/react/24/outline"; +// hooks +import useUser from "hooks/use-user"; +// ui +import { CustomMenu, Icon } from "components/ui"; +import { CommentReaction } from "components/issues"; +import { TipTapEditor } from "components/tiptap"; +// helpers +import { timeAgo } from "helpers/date-time.helper"; +// types +import type { IIssueComment } from "types"; + +type Props = { + comment: IIssueComment; + handleCommentDeletion: (comment: string) => void; + onSubmit: (commentId: string, data: Partial) => void; + showAccessSpecifier?: boolean; + workspaceSlug: string; + disabled?: boolean; +}; + +export const CommentCard: React.FC = (props) => { + const { + comment, + handleCommentDeletion, + onSubmit, + showAccessSpecifier = false, + workspaceSlug, + disabled, + } = props; + + const { user } = useUser(); + + const editorRef = React.useRef(null); + const showEditorRef = React.useRef(null); + + const [isEditing, setIsEditing] = useState(false); + + const { + formState: { isSubmitting }, + handleSubmit, + setFocus, + watch, + setValue, + } = useForm({ + defaultValues: comment, + }); + + const onEnter = (formData: Partial) => { + if (isSubmitting) return; + setIsEditing(false); + + onSubmit(comment.id, formData); + + editorRef.current?.setEditorValue(formData.comment_html); + showEditorRef.current?.setEditorValue(formData.comment_html); + }; + + useEffect(() => { + isEditing && setFocus("comment"); + }, [isEditing, setFocus]); + + return ( +
+
+ {comment.actor_detail.avatar && comment.actor_detail.avatar !== "" ? ( + { + ) : ( +
+ {comment.actor_detail.is_bot + ? comment.actor_detail.first_name.charAt(0) + : comment.actor_detail.display_name.charAt(0)} +
+ )} + + + +
+
+
+
+ {comment.actor_detail.is_bot + ? comment.actor_detail.first_name + " Bot" + : comment.actor_detail.display_name} +
+

+ commented {timeAgo(comment.created_at)} +

+
+
+
+
+ { + setValue("comment_json", comment_json); + setValue("comment_html", comment_html); + }} + /> +
+
+ + +
+
+
+ {showAccessSpecifier && ( +
+ +
+ )} + + +
+
+
+ {user?.id === comment.actor && !disabled && ( + + setIsEditing(true)} + className="flex items-center gap-1" + > + + Edit comment + + {showAccessSpecifier && ( + <> + {comment.access === "INTERNAL" ? ( + onSubmit(comment.id, { access: "EXTERNAL" })} + className="flex items-center gap-1" + > + + Switch to public comment + + ) : ( + onSubmit(comment.id, { access: "INTERNAL" })} + className="flex items-center gap-1" + > + + Switch to private comment + + )} + + )} + { + handleCommentDeletion(comment.id); + }} + className="flex items-center gap-1" + > + + Delete comment + + + )} +
+ ); +}; diff --git a/web/components/web-view/confirm-delete.tsx b/web/components/web-view/confirm-delete.tsx new file mode 100644 index 000000000..5602f7778 --- /dev/null +++ b/web/components/web-view/confirm-delete.tsx @@ -0,0 +1,30 @@ +import { WebViewModal } from "components/web-view"; + +type DeleteConfirmationProps = { + isOpen: boolean; + title: string; + content: string | React.ReactNode; + onCancel: () => void; + onConfirm: () => void; +}; + +export const DeleteConfirmation: React.FC = (props) => { + const { isOpen, onCancel, onConfirm, title, content } = props; + + return ( + +
+

{content}

+
+
+ +
+
+ ); +}; diff --git a/web/components/web-view/date-selector.tsx b/web/components/web-view/date-selector.tsx new file mode 100644 index 000000000..1493d5ed6 --- /dev/null +++ b/web/components/web-view/date-selector.tsx @@ -0,0 +1,188 @@ +// react +import React, { useState, useEffect } from "react"; + +// icons +import { ChevronDown } from "lucide-react"; + +// react date-picker +import DatePicker, { ReactDatePickerProps } from "react-datepicker"; + +// components +import { WebViewModal } from "./web-view-modal"; +import { SecondaryButton, PrimaryButton } from "components/ui"; + +// helpers +import { renderDateFormat } from "helpers/date-time.helper"; + +interface Props extends ReactDatePickerProps { + value: string | undefined; + onChange: (value: any) => void; + disabled?: boolean; + renderAs?: "input" | "button"; + error?: any; + noBorder?: boolean; +} + +export const DateSelector: React.FC = (props) => { + const { + value, + onChange, + disabled = false, + renderAs = "button", + noBorder = true, + error, + className, + } = props; + + const [isOpen, setIsOpen] = useState(false); + const [selectedDate, setSelectedDate] = useState(null); + + useEffect(() => { + if (value) setSelectedDate(new Date(value)); + }, [value]); + + useEffect(() => { + if (!isOpen) return; + + if (value) setSelectedDate(new Date(value)); + else setSelectedDate(new Date()); + }, [isOpen, value]); + + return ( + <> + { + setIsOpen(false); + }} + > +
+ { + if (!val) setSelectedDate(null); + else setSelectedDate(val); + }} + renderCustomHeader={({ + date, + decreaseMonth, + increaseMonth, + prevMonthButtonDisabled, + nextMonthButtonDisabled, + }) => ( +
+

+ {date.toLocaleString("default", { month: "long" })} {date.getFullYear()} +

+
+ + +
+
+ )} + /> +
+ + + { + setIsOpen(false); + onChange(null); + setSelectedDate(null); + }} + className="w-full" + > + Clear + + { + if (!selectedDate) onChange(null); + else onChange(renderDateFormat(selectedDate)); + setIsOpen(false); + }} + type="button" + className="w-full" + > + Apply + + +
+ + + + ); +}; diff --git a/web/components/web-view/index.ts b/web/components/web-view/index.ts index 915c891a9..4a18f8581 100644 --- a/web/components/web-view/index.ts +++ b/web/components/web-view/index.ts @@ -13,6 +13,15 @@ export * from "./select-assignee"; export * from "./select-estimate"; export * from "./add-comment"; export * from "./select-parent"; -export * from "./select-blocker"; -export * from "./select-blocked"; +export * from "./select-blocker-to"; +export * from "./select-blocked-by"; export * from "./activity-message"; +export * from "./issues-select-bottom-sheet"; +export * from "./select-relates-to"; +export * from "./select-duplicate"; +export * from "./spinner"; +export * from "./select-module"; +export * from "./select-cycle"; +export * from "./confirm-delete"; +export * from "./commend-card"; +export * from "./date-selector"; diff --git a/web/components/web-view/issue-activity.tsx b/web/components/web-view/issue-activity.tsx index 4bd13eb5c..4b24eaa62 100644 --- a/web/components/web-view/issue-activity.tsx +++ b/web/components/web-view/issue-activity.tsx @@ -17,8 +17,7 @@ import issuesService from "services/issues.service"; import useUser from "hooks/use-user"; // components -import { CommentCard } from "components/issues/comment"; -import { Label, AddComment, ActivityMessage, ActivityIcon } from "components/web-view"; +import { Label, AddComment, ActivityMessage, ActivityIcon, CommentCard } from "components/web-view"; // helpers import { timeAgo } from "helpers/date-time.helper"; @@ -54,23 +53,32 @@ export const IssueActivity: React.FC = (props) => { : null ); - const handleCommentUpdate = async (comment: any) => { - if (!workspaceSlug || !projectId || !issueId) return; + const handleCommentUpdate = async (comment: any, formData: any) => { + if (!workspaceSlug || !projectId || !issueId || !allowed) return; await issuesService .patchIssueComment( workspaceSlug as string, projectId as string, issueId as string, - comment.id, comment, + formData, user ) - .then(() => mutateIssueActivity()); + .then(() => mutateIssueActivity()) + .catch(() => + console.log( + "toast", + JSON.stringify({ + type: "error", + message: "Comment could not be updated. Please try again.", + }) + ) + ); }; const handleCommentDelete = async (commentId: string) => { - if (!workspaceSlug || !projectId || !issueId) return; + if (!workspaceSlug || !projectId || !issueId || !allowed) return; mutateIssueActivity((prevData: any) => prevData?.filter((p: any) => p.id !== commentId), false); @@ -82,11 +90,20 @@ export const IssueActivity: React.FC = (props) => { commentId, user ) - .then(() => mutateIssueActivity()); + .then(() => mutateIssueActivity()) + .catch(() => + console.log( + "toast", + JSON.stringify({ + type: "error", + message: "Comment could not be deleted. Please try again.", + }) + ) + ); }; const handleAddComment = async (formData: IIssueComment) => { - if (!workspaceSlug || !issueDetails) return; + if (!workspaceSlug || !issueDetails || !allowed) return; await issuesService .createIssueComment( @@ -104,7 +121,6 @@ export const IssueActivity: React.FC = (props) => { "toast", JSON.stringify({ type: "error", - title: "Error!", message: "Comment could not be posted. Please try again.", }) ) @@ -114,7 +130,7 @@ export const IssueActivity: React.FC = (props) => { return (
-
+
    {issueActivities?.map((activityItem, index) => { // determines what type of action is performed @@ -208,23 +224,31 @@ export const IssueActivity: React.FC = (props) => { comment={activityItem as any} onSubmit={handleCommentUpdate} handleCommentDeletion={handleCommentDelete} + disabled={ + !allowed || + !issueDetails || + issueDetails.state === "closed" || + issueDetails.state === "archived" + } />
); })} -
  • -
    - -
    -
  • + {allowed && ( +
  • +
    + +
    +
  • + )}
    diff --git a/web/components/web-view/issue-attachments.tsx b/web/components/web-view/issue-attachments.tsx index ba4540318..596d791b7 100644 --- a/web/components/web-view/issue-attachments.tsx +++ b/web/components/web-view/issue-attachments.tsx @@ -21,8 +21,10 @@ import { ISSUE_ATTACHMENTS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys import { FileText, ChevronRight, X, Image as ImageIcon } from "lucide-react"; // components -import { Label, WebViewModal } from "components/web-view"; -import { DeleteAttachmentModal } from "components/issues"; +import { Label, WebViewModal, DeleteConfirmation } from "components/web-view"; + +// helpers +import { getFileName } from "helpers/attachment.helper"; // types import type { IIssueAttachment } from "types"; @@ -47,7 +49,7 @@ export const IssueAttachments: React.FC = (props) => { const onDrop = useCallback( (acceptedFiles: File[]) => { - if (!acceptedFiles[0] || !workspaceSlug) return; + if (!acceptedFiles[0] || !workspaceSlug || !allowed) return; const formData = new FormData(); formData.append("asset", acceptedFiles[0]); @@ -97,9 +99,37 @@ export const IssueAttachments: React.FC = (props) => { ); }); }, - [issueId, projectId, workspaceSlug] + [issueId, projectId, workspaceSlug, allowed] ); + const handleDeletion = async (assetId: string) => { + if (!workspaceSlug || !projectId) return; + + mutate( + ISSUE_ATTACHMENTS(issueId as string), + (prevData) => (prevData ?? [])?.filter((p) => p.id !== assetId), + false + ); + + await issuesService + .deleteIssueAttachment( + workspaceSlug as string, + projectId as string, + issueId as string, + assetId as string + ) + .then(() => mutate(PROJECT_ISSUES_ACTIVITY(issueId as string))) + .catch(() => { + console.log( + "toast", + JSON.stringify({ + type: "error", + message: "Something went wrong please try again.", + }) + ); + }); + }; + const { getRootProps, getInputProps } = useDropzone({ onDrop, maxSize: 5 * 1024 * 1024, @@ -120,10 +150,24 @@ export const IssueAttachments: React.FC = (props) => { return (
    - + Are you sure you want to delete attachment-{" "} + + {getFileName(deleteAttachment?.attributes?.name ?? "")} + + ? This attachment will be permanently removed. This action cannot be undone. +

    + } isOpen={allowed && attachmentDeleteModal} - setIsOpen={setAttachmentDeleteModal} - data={deleteAttachment} + onCancel={() => setAttachmentDeleteModal(false)} + onConfirm={() => { + if (!deleteAttachment) return; + handleDeletion(deleteAttachment.id); + setAttachmentDeleteModal(false); + }} /> setIsOpen(false)} modalTitle="Insert file"> @@ -179,6 +223,7 @@ export const IssueAttachments: React.FC = (props) => { ))} {allowed && (
    + issuesService + .deleteIssueRelation( + workspaceSlug as string, + projectId as string, + relation.issue, + relation.id, + user + ) + .then(() => { + mutate(ISSUE_DETAILS(issueId as string)); + mutate(PROJECT_ISSUES_ACTIVITY(issueId as string)); + }); + }} + > + + + )}
    ))}
    -
    + + {/* blocked by */} +
    @@ -309,89 +313,190 @@ export const IssuePropertiesDetail: React.FC = (props) => { Blocked by
    - { - if (!user || !workspaceSlug || !projectId || !issueId) return; - - issuesService - .createIssueRelation( - workspaceSlug as string, - projectId as string, - issueId as string, - user, - { - related_list: [ - ...val.map((issue: any) => ({ - issue: issue.blocked_issue_detail.id, - relation_type: "blocked_by" as const, - related_issue: issueId as string, - related_issue_detail: issue.blocked_issue_detail, - })), - ], - } - ) - .then((response) => { - handleMutation({ - related_issues: [ - ...blockedIssue, - ...(response ?? []).map((i: any) => ({ - id: i.id, - relation_type: i.relation_type, - issue_detail: i.related_issue_detail, - issue: i.related_issue, - })), - ], - }); - }); - }} +
    - {blockedIssue && - blockedIssue.map((issue) => ( + {blockedByIssues && + blockedByIssues.map((relation) => (
    - + console.log( + "issue", + JSON.stringify({ + issue_id: relation.issue_detail?.id, + project_id: relation.issue_detail?.project_detail.id, + }) + ) + } className="flex items-center gap-1" > - {`${issue?.issue_detail?.project_detail?.identifier}-${issue?.issue_detail?.sequence_id}`} - - + issuesService + .deleteIssueRelation( + workspaceSlug as string, + projectId as string, + issueId as string, + relation.id, + user + ) + .then(() => { + mutate(ISSUE_DETAILS(issueId as string)); + mutate(PROJECT_ISSUES_ACTIVITY(issueId as string)); + }); + }} + > + + + )}
    ))}
    -
    + {/* duplicate */} +
    +
    +
    +
    + + Duplicate +
    +
    + +
    +
    + {duplicateIssuesRelation && + duplicateIssuesRelation.map((relation) => ( +
    + + console.log( + "issue", + JSON.stringify({ + issue_id: relation.issue_detail?.id, + project_id: relation.issue_detail?.project_detail.id, + }) + ) + } + className="flex items-center gap-1" + > + + {`${relation?.issue_detail?.project_detail?.identifier}-${relation?.issue_detail?.sequence_id}`} + + {!isArchive && !(memberRole.isGuest || memberRole.isViewer) && ( + + )} +
    + ))} +
    +
    + + {/* relates to */} +
    +
    +
    +
    + + Relates To +
    +
    + +
    +
    + {relatedToIssueRelation && + relatedToIssueRelation.map((relation) => ( +
    + + console.log( + "issue", + JSON.stringify({ + issue_id: relation.issue_detail?.id, + project_id: relation.issue_detail?.project_detail.id, + }) + ) + } + className="flex items-center gap-1" + > + + {`${relation?.issue_detail?.project_detail?.identifier}-${relation?.issue_detail?.sequence_id}`} + + {!isArchive && !(memberRole.isGuest || memberRole.isViewer) && ( + + )} +
    + ))} +
    +
    + +
    @@ -403,10 +508,11 @@ export const IssuePropertiesDetail: React.FC = (props) => { control={control} name="target_date" render={({ field: { value } }) => ( - submitChanges({ target_date: val, @@ -421,9 +527,46 @@ export const IssuePropertiesDetail: React.FC = (props) => {
    + +
    +
    +
    +
    + + Module +
    +
    + +
    +
    +
    +
    + +
    +
    +
    +
    + + Cycle +
    +
    + +
    +
    +
    +
    )} -
    +
    setIsViewAllOpen((prev) => !prev)} diff --git a/web/components/web-view/issue-web-view-form.tsx b/web/components/web-view/issue-web-view-form.tsx index ff9383fd0..7a5ed3394 100644 --- a/web/components/web-view/issue-web-view-form.tsx +++ b/web/components/web-view/issue-web-view-form.tsx @@ -138,6 +138,7 @@ export const IssueWebViewForm: React.FC = (props) => { } noBorder={!isAllowed} onChange={(description: Object, description_html: string) => { + if (!isAllowed) return; setShowAlert(true); setIsSubmitting("submitting"); onChange(description_html); diff --git a/web/components/web-view/issues-select-bottom-sheet.tsx b/web/components/web-view/issues-select-bottom-sheet.tsx new file mode 100644 index 000000000..266f29a76 --- /dev/null +++ b/web/components/web-view/issues-select-bottom-sheet.tsx @@ -0,0 +1,188 @@ +// react +import React, { useState, useEffect } from "react"; + +// next +import { useRouter } from "next/router"; + +// hooks +import useUser from "hooks/use-user"; +import useDebounce from "hooks/use-debounce"; + +// services +import projectService from "services/project.service"; + +// components +import { WebViewModal } from "components/web-view"; +import { LayerDiagonalIcon } from "components/icons"; +import { Loader, PrimaryButton, SecondaryButton, ToggleSwitch } from "components/ui"; + +// types +import { ISearchIssueResponse, TProjectIssuesSearchParams } from "types"; + +type IssuesSelectBottomSheetProps = { + isOpen: boolean; + onClose: () => void; + onSubmit: (data: ISearchIssueResponse[]) => Promise; + searchParams: Partial; + singleSelect?: boolean; +}; + +export const IssuesSelectBottomSheet: React.FC = (props) => { + const { isOpen, onClose, onSubmit, searchParams, singleSelect = false } = props; + + const router = useRouter(); + const { workspaceSlug, projectId, issueId } = router.query; + + const [searchTerm, setSearchTerm] = useState(""); + const [issues, setIssues] = useState([]); + const [isSearching, setIsSearching] = useState(false); + const [selectedIssues, setSelectedIssues] = useState([]); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isWorkspaceLevel, setIsWorkspaceLevel] = useState(false); + + const debouncedSearchTerm: string = useDebounce(searchTerm, 500); + + const { user } = useUser(); + + const handleClose = () => { + onClose(); + setSearchTerm(""); + setSelectedIssues([]); + setIsWorkspaceLevel(false); + }; + + const handleSelect = async (data: ISearchIssueResponse[]) => { + if (!user || !workspaceSlug || !projectId || !issueId) return; + + setIsSubmitting(true); + + await onSubmit(data).finally(() => { + setIsSubmitting(false); + }); + + handleClose(); + + console.log( + "toast", + JSON.stringify({ + type: "success", + message: `Issue${data.length > 1 ? "s" : ""} added successfully.`, + }) + ); + }; + + useEffect(() => { + if (!isOpen || !workspaceSlug || !projectId || !issueId) return; + + setIsSearching(true); + + projectService + .projectIssuesSearch(workspaceSlug as string, projectId as string, { + search: debouncedSearchTerm, + ...searchParams, + issue_id: issueId.toString(), + workspace_search: isWorkspaceLevel, + }) + .then((res) => setIssues(res)) + .finally(() => setIsSearching(false)); + }, [ + debouncedSearchTerm, + isOpen, + isWorkspaceLevel, + issueId, + projectId, + workspaceSlug, + searchParams, + ]); + + return ( + + {!isSearching && issues.length === 0 && searchTerm !== "" && debouncedSearchTerm !== "" && ( +
    + +

    + No issues found. Create a new issue with{" "} +
    C
    . +

    +
    + )} + +
    + setIsWorkspaceLevel((prevData) => !prevData)} + /> + +
    + + {isSearching && ( + + + + + + + )} + + {!isSearching && ( + ({ + value: issue.id, + label: ( +
    + + + {issue.project__identifier}-{issue.sequence_id} + + {issue.name} +
    + ), + checked: selectedIssues.some((i) => i.id === issue.id), + onClick() { + if (singleSelect) { + handleSelect([issue]); + handleClose(); + return; + } + + if (selectedIssues.some((i) => i.id === issue.id)) { + setSelectedIssues(selectedIssues.filter((i) => i.id !== issue.id)); + } else { + setSelectedIssues([...selectedIssues, issue]); + } + }, + }))} + /> + )} + + {selectedIssues.length > 0 && ( + + Cancel + { + handleSelect(selectedIssues); + }} + loading={isSubmitting} + > + {isSubmitting ? "Adding..." : "Add selected issues"} + + + )} +
    + ); +}; diff --git a/web/components/web-view/select-assignee.tsx b/web/components/web-view/select-assignee.tsx index 27eae5db1..43ef0aa36 100644 --- a/web/components/web-view/select-assignee.tsx +++ b/web/components/web-view/select-assignee.tsx @@ -47,7 +47,7 @@ export const AssigneeSelect: React.FC = (props) => { <> { setIsOpen(false); }} @@ -74,20 +74,20 @@ export const AssigneeSelect: React.FC = (props) => { 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" + "relative w-full px-2.5 py-0.5 text-base flex justify-between items-center gap-0.5" } > {value && value.length > 0 && Array.isArray(value) ? (
    - + {selectedAssignees?.length} Assignees
    ) : ( - "No assignees" + No assignees )} - + ); diff --git a/web/components/web-view/select-blocked-by.tsx b/web/components/web-view/select-blocked-by.tsx new file mode 100644 index 000000000..97eaf6d95 --- /dev/null +++ b/web/components/web-view/select-blocked-by.tsx @@ -0,0 +1,127 @@ +// react +import React, { useState } from "react"; + +// next +import { useRouter } from "next/router"; + +// swr +import { mutate } from "swr"; + +// react hook form +import { useFormContext } from "react-hook-form"; + +// services +import issuesService from "services/issues.service"; + +// hooks +import useUser from "hooks/use-user"; + +// fetch keys +import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys"; + +// icons +import { ChevronDown } from "lucide-react"; + +// components +import { IssuesSelectBottomSheet } from "components/web-view"; + +// types +import type { IIssue, BlockeIssueDetail, ISearchIssueResponse } from "types"; + +type Props = { + disabled?: boolean; +}; + +export const BlockedBySelect: React.FC = (props) => { + const { disabled = false } = props; + + const router = useRouter(); + const { workspaceSlug, projectId, issueId } = router.query; + + const { watch } = useFormContext(); + + const { user } = useUser(); + + const [isBlockedModalOpen, setIsBlockedModalOpen] = useState(false); + + const onSubmit = async (data: ISearchIssueResponse[]) => { + if (!workspaceSlug || !projectId || !issueId || !user || disabled) return; + + if (data.length === 0) + return console.log( + "toast", + JSON.stringify({ + type: "error", + message: "Please select at least one issue.", + }) + ); + + const selectedIssues: { blocked_issue_detail: BlockeIssueDetail }[] = data.map((i) => ({ + blocked_issue_detail: { + id: i.id, + name: i.name, + sequence_id: i.sequence_id, + project_detail: { + id: i.project_id, + identifier: i.project__identifier, + name: i.project__name, + }, + }, + })); + + const relatedIssues = watch("related_issues"); + + await issuesService + .createIssueRelation( + workspaceSlug.toString(), + projectId.toString(), + issueId.toString(), + user, + { + related_list: [ + ...selectedIssues.map((issue) => ({ + issue: issueId as string, + relation_type: "blocked_by" as const, + issue_detail: issue.blocked_issue_detail, + related_issue: issue.blocked_issue_detail.id, + })), + ], + } + ) + .then((response) => { + mutate(ISSUE_DETAILS(issueId as string), (prevData) => { + if (!prevData) return prevData; + return { + ...prevData, + related_issues: [...relatedIssues, ...response], + }; + }); + mutate(PROJECT_ISSUES_ACTIVITY(issueId as string)); + }); + + setIsBlockedModalOpen(false); + }; + + return ( + <> + setIsBlockedModalOpen(false)} + searchParams={{ issue_relation: true }} + /> + + + + ); +}; diff --git a/web/components/web-view/select-blocked.tsx b/web/components/web-view/select-blocked.tsx deleted file mode 100644 index 9569c5240..000000000 --- a/web/components/web-view/select-blocked.tsx +++ /dev/null @@ -1,87 +0,0 @@ -// react -import React, { useState } from "react"; - -// next -import { useRouter } from "next/router"; - -// hooks -import useToast from "hooks/use-toast"; - -// icons -import { ChevronDown } from "lucide-react"; - -// components -import { ExistingIssuesListModal } from "components/core"; - -// types -import { BlockeIssueDetail, ISearchIssueResponse } from "types"; - -type Props = { - value: any; - onChange: (value: any) => void; - disabled?: boolean; -}; - -export const BlockedSelect: React.FC = (props) => { - const { value, onChange, disabled = false } = props; - - const [isBlockedModalOpen, setIsBlockedModalOpen] = useState(false); - - const router = useRouter(); - const { issueId } = router.query; - - const { setToastAlert } = useToast(); - - const onSubmit = async (data: ISearchIssueResponse[]) => { - if (data.length === 0) { - setToastAlert({ - type: "error", - title: "Error!", - message: "Please select at least one issue.", - }); - - return; - } - - const selectedIssues: { blocker_issue_detail: BlockeIssueDetail }[] = data.map((i) => ({ - blocker_issue_detail: { - id: i.id, - name: i.name, - sequence_id: i.sequence_id, - project_detail: { - id: i.project_id, - identifier: i.project__identifier, - name: i.project__name, - }, - }, - })); - - onChange([...(value || []), ...selectedIssues]); - - setIsBlockedModalOpen(false); - }; - - return ( - <> - setIsBlockedModalOpen(false)} - searchParams={{ issue_relation: true, issue_id: issueId!.toString() }} - handleOnSubmit={onSubmit} - workspaceLevelToggle - /> - - - - ); -}; diff --git a/web/components/web-view/select-blocker-to.tsx b/web/components/web-view/select-blocker-to.tsx new file mode 100644 index 000000000..8255d19f9 --- /dev/null +++ b/web/components/web-view/select-blocker-to.tsx @@ -0,0 +1,133 @@ +// react +import React, { useState } from "react"; + +// next +import { useRouter } from "next/router"; + +// swr +import { mutate } from "swr"; + +// react hook form +import { useFormContext } from "react-hook-form"; + +// services +import issuesService from "services/issues.service"; + +// hooks +import useUser from "hooks/use-user"; + +// fetch keys +import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys"; + +// icons +import { ChevronDown } from "lucide-react"; + +// components +import { IssuesSelectBottomSheet } from "components/web-view"; + +// types +import { BlockeIssueDetail, ISearchIssueResponse, IIssue } from "types"; + +type Props = { + disabled?: boolean; +}; + +export const BlockerSelect: React.FC = (props) => { + const { disabled = false } = props; + + const [isBlockerModalOpen, setIsBlockerModalOpen] = useState(false); + + const router = useRouter(); + const { workspaceSlug, projectId, issueId } = router.query; + + const { watch } = useFormContext(); + + const { user } = useUser(); + + const onSubmit = async (data: ISearchIssueResponse[]) => { + if (disabled) return; + + if (data.length === 0) + return console.log( + "toast", + JSON.stringify({ + type: "error", + message: "Please select at least one issue.", + }) + ); + + const selectedIssues: { blocker_issue_detail: BlockeIssueDetail }[] = data.map((i) => ({ + blocker_issue_detail: { + id: i.id, + name: i.name, + sequence_id: i.sequence_id, + project_detail: { + id: i.project_id, + identifier: i.project__identifier, + name: i.project__name, + }, + }, + })); + + if (!workspaceSlug || !projectId || !issueId || !user) return; + + const blockerIssue = + watch("issue_relations")?.filter((i) => i.relation_type === "blocked_by") || []; + + issuesService + .createIssueRelation(workspaceSlug as string, projectId as string, issueId as string, user, { + related_list: [ + ...selectedIssues.map((issue) => ({ + issue: issue.blocker_issue_detail.id, + relation_type: "blocked_by" as const, + related_issue: issueId as string, + related_issue_detail: issue.blocker_issue_detail, + })), + ], + relation: "blocking", + }) + .then((response) => { + mutate(ISSUE_DETAILS(issueId as string), (prevData) => { + if (!prevData) return prevData; + return { + ...prevData, + issue_relations: [ + ...blockerIssue, + ...(response ?? []).map((i: any) => ({ + id: i.id, + relation_type: i.relation_type, + issue_detail: i.issue_detail, + issue: i.related_issue, + })), + ], + }; + }); + mutate(PROJECT_ISSUES_ACTIVITY(issueId as string)); + }); + + setIsBlockerModalOpen(false); + }; + + return ( + <> + setIsBlockerModalOpen(false)} + onSubmit={onSubmit} + searchParams={{ issue_relation: true }} + /> + + + + ); +}; diff --git a/web/components/web-view/select-blocker.tsx b/web/components/web-view/select-blocker.tsx deleted file mode 100644 index 39b20ac8e..000000000 --- a/web/components/web-view/select-blocker.tsx +++ /dev/null @@ -1,87 +0,0 @@ -// react -import React, { useState } from "react"; - -// next -import { useRouter } from "next/router"; - -// hooks -import useToast from "hooks/use-toast"; - -// icons -import { ChevronDown } from "lucide-react"; - -// components -import { ExistingIssuesListModal } from "components/core"; - -// types -import { BlockeIssueDetail, ISearchIssueResponse } from "types"; - -type Props = { - value: any; - onChange: (value: any) => void; - disabled?: boolean; -}; - -export const BlockerSelect: React.FC = (props) => { - const { value, onChange, disabled = false } = props; - - const [isBlockerModalOpen, setIsBlockerModalOpen] = useState(false); - - const router = useRouter(); - const { issueId } = router.query; - - const { setToastAlert } = useToast(); - - const onSubmit = async (data: ISearchIssueResponse[]) => { - if (data.length === 0) { - setToastAlert({ - type: "error", - title: "Error!", - message: "Please select at least one issue.", - }); - - return; - } - - const selectedIssues: { blocker_issue_detail: BlockeIssueDetail }[] = data.map((i) => ({ - blocker_issue_detail: { - id: i.id, - name: i.name, - sequence_id: i.sequence_id, - project_detail: { - id: i.project_id, - identifier: i.project__identifier, - name: i.project__name, - }, - }, - })); - - onChange([...(value || []), ...selectedIssues]); - - setIsBlockerModalOpen(false); - }; - - return ( - <> - setIsBlockerModalOpen(false)} - searchParams={{ issue_relation: true, issue_id: issueId!.toString() }} - handleOnSubmit={onSubmit} - workspaceLevelToggle - /> - - - - ); -}; diff --git a/web/components/web-view/select-cycle.tsx b/web/components/web-view/select-cycle.tsx new file mode 100644 index 000000000..920eabf20 --- /dev/null +++ b/web/components/web-view/select-cycle.tsx @@ -0,0 +1,152 @@ +// react +import React, { useState } from "react"; + +// next +import { useRouter } from "next/router"; + +// swr +import useSWR, { mutate } from "swr"; + +// services +import issuesService from "services/issues.service"; +import cyclesService from "services/cycles.service"; + +// hooks +import useUser from "hooks/use-user"; + +// fetch keys +import { + ISSUE_DETAILS, + INCOMPLETE_CYCLES_LIST, + CYCLE_ISSUES, + PROJECT_ISSUES_ACTIVITY, +} from "constants/fetch-keys"; + +// icons +import { ChevronDown } from "lucide-react"; + +// components +import { WebViewModal } from "components/web-view"; + +// types +import { ICycle, IIssueCycle } from "types"; + +type Props = { + disabled?: boolean; + value?: IIssueCycle | null; +}; + +export const CycleSelect: React.FC = (props) => { + const { disabled = false, value } = props; + + const [isBottomSheetOpen, setIsBottomSheetOpen] = useState(false); + + const router = useRouter(); + const { workspaceSlug, projectId, issueId } = router.query; + + const { data: incompleteCycles } = useSWR( + workspaceSlug && projectId ? INCOMPLETE_CYCLES_LIST(projectId as string) : null, + workspaceSlug && projectId + ? () => + cyclesService.getCyclesWithParams( + workspaceSlug as string, + projectId as string, + "incomplete" + ) + : null + ); + + const { user } = useUser(); + + const handleCycleChange = (cycleDetails: ICycle) => { + if (!workspaceSlug || !projectId || !issueId || disabled) return; + + issuesService + .addIssueToCycle( + workspaceSlug as string, + projectId as string, + cycleDetails.id, + { + issues: [issueId.toString()], + }, + user + ) + .then(() => { + mutate(ISSUE_DETAILS(issueId as string)); + mutate(PROJECT_ISSUES_ACTIVITY(issueId as string)); + }); + }; + + const removeIssueFromCycle = (bridgeId?: string, cycleId?: string) => { + if (!workspaceSlug || !projectId || !bridgeId || !cycleId || disabled) return; + + mutate( + ISSUE_DETAILS(issueId as string), + (prev) => { + if (!prev) return prev; + + return { + ...prev, + issue_cycle: null, + }; + }, + false + ); + + issuesService + .removeIssueFromCycle(workspaceSlug.toString(), projectId.toString(), cycleId, bridgeId) + .then(() => { + mutate(CYCLE_ISSUES(cycleId)); + mutate(ISSUE_DETAILS(issueId as string)); + mutate(PROJECT_ISSUES_ACTIVITY(issueId as string)); + }) + .catch((e) => { + console.log(e); + }); + }; + + return ( + <> + setIsBottomSheetOpen(false)} + modalTitle="Select Module" + > + ({ + checked: cycle.id === value?.cycle, + label: cycle.name, + value: cycle.id, + onClick: () => { + handleCycleChange(cycle); + setIsBottomSheetOpen(false); + }, + })), + { + checked: !value, + label: "None", + onClick: () => { + setIsBottomSheetOpen(false); + removeIssueFromCycle(value?.id, value?.cycle); + }, + value: "none", + }, + ]} + /> + + + + + ); +}; diff --git a/web/components/web-view/select-duplicate.tsx b/web/components/web-view/select-duplicate.tsx new file mode 100644 index 000000000..16899b833 --- /dev/null +++ b/web/components/web-view/select-duplicate.tsx @@ -0,0 +1,116 @@ +// react +import React, { useState } from "react"; + +// next +import { useRouter } from "next/router"; + +// swr +import { mutate } from "swr"; + +// services +import issuesService from "services/issues.service"; + +// hooks +import useUser from "hooks/use-user"; + +// fetch keys +import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys"; + +// icons +import { ChevronDown } from "lucide-react"; + +// components +import { IssuesSelectBottomSheet } from "components/web-view"; + +// types +import { BlockeIssueDetail, ISearchIssueResponse } from "types"; + +type Props = { + disabled?: boolean; +}; + +export const DuplicateSelect: React.FC = (props) => { + const { disabled = false } = props; + + const [isBottomSheetOpen, setIsBottomSheetOpen] = useState(false); + + const router = useRouter(); + const { workspaceSlug, projectId, issueId } = router.query; + + const { user } = useUser(); + + const onSubmit = async (data: ISearchIssueResponse[]) => { + if (!workspaceSlug || !projectId || !issueId || !user || disabled) return; + + if (data.length === 0) + return console.log( + "toast", + JSON.stringify({ + type: "error", + message: "Please select at least one issue.", + }) + ); + + const selectedIssues: { blocker_issue_detail: BlockeIssueDetail }[] = data.map((i) => ({ + blocker_issue_detail: { + id: i.id, + name: i.name, + sequence_id: i.sequence_id, + project_detail: { + id: i.project_id, + identifier: i.project__identifier, + name: i.project__name, + }, + }, + })); + + if (!user) return; + + issuesService + .createIssueRelation( + workspaceSlug.toString(), + projectId.toString(), + issueId.toString(), + user, + { + related_list: [ + ...selectedIssues.map((issue) => ({ + issue: issueId as string, + issue_detail: issue.blocker_issue_detail, + related_issue: issue.blocker_issue_detail.id, + relation_type: "duplicate" as const, + })), + ], + } + ) + .then(() => { + mutate(ISSUE_DETAILS(issueId as string)); + mutate(PROJECT_ISSUES_ACTIVITY(issueId as string)); + }); + + setIsBottomSheetOpen(false); + }; + + return ( + <> + setIsBottomSheetOpen(false)} + onSubmit={onSubmit} + searchParams={{ issue_relation: true }} + /> + + + + ); +}; diff --git a/web/components/web-view/select-module.tsx b/web/components/web-view/select-module.tsx new file mode 100644 index 000000000..cd7fe9741 --- /dev/null +++ b/web/components/web-view/select-module.tsx @@ -0,0 +1,147 @@ +// react +import React, { useState } from "react"; + +// next +import { useRouter } from "next/router"; + +// swr +import useSWR, { mutate } from "swr"; + +// services +import modulesService from "services/modules.service"; + +// hooks +import useUser from "hooks/use-user"; + +// fetch keys +import { + ISSUE_DETAILS, + MODULE_LIST, + MODULE_ISSUES, + PROJECT_ISSUES_ACTIVITY, +} from "constants/fetch-keys"; + +// icons +import { ChevronDown } from "lucide-react"; + +// components +import { WebViewModal } from "components/web-view"; + +// types +import { IModule, IIssueModule } from "types"; + +type Props = { + disabled?: boolean; + value?: IIssueModule | null; +}; + +export const ModuleSelect: React.FC = (props) => { + const { disabled = false, value } = props; + + const [isBottomSheetOpen, setIsBottomSheetOpen] = useState(false); + + const router = useRouter(); + const { workspaceSlug, projectId, issueId } = router.query; + + const { data: modules } = useSWR( + workspaceSlug && projectId ? MODULE_LIST(projectId as string) : null, + workspaceSlug && projectId + ? () => modulesService.getModules(workspaceSlug as string, projectId as string) + : null + ); + + const { user } = useUser(); + + const handleModuleChange = (moduleDetail: IModule) => { + if (!workspaceSlug || !projectId || !issueId || disabled) return; + + modulesService + .addIssuesToModule( + workspaceSlug as string, + projectId as string, + moduleDetail.id, + { + issues: [issueId.toString()], + }, + user + ) + .then(() => { + mutate(ISSUE_DETAILS(issueId.toString())); + mutate(PROJECT_ISSUES_ACTIVITY(issueId as string)); + }); + }; + + const removeIssueFromModule = (bridgeId?: string, moduleId?: string) => { + if (!workspaceSlug || !projectId || !moduleId || !bridgeId || disabled) return; + + mutate( + ISSUE_DETAILS(issueId as string), + (prev) => { + if (!prev) return prev; + return { + ...prev, + issue_module: null, + }; + }, + false + ); + + modulesService + .removeIssueFromModule(workspaceSlug as string, projectId as string, moduleId, bridgeId) + .then(() => { + mutate(MODULE_ISSUES(moduleId)); + mutate(ISSUE_DETAILS(issueId as string)); + mutate(PROJECT_ISSUES_ACTIVITY(issueId as string)); + }) + .catch((e) => { + console.log(e); + }); + }; + + return ( + <> + setIsBottomSheetOpen(false)} + modalTitle="Select Module" + > + ({ + checked: mod.id === value?.module, + label: mod.name, + value: mod.id, + onClick: () => { + handleModuleChange(mod); + setIsBottomSheetOpen(false); + }, + })), + { + checked: !value, + label: "None", + onClick: () => { + setIsBottomSheetOpen(false); + removeIssueFromModule(value?.id, value?.module); + }, + value: "none", + }, + ]} + /> + + + + + ); +}; diff --git a/web/components/web-view/select-parent.tsx b/web/components/web-view/select-parent.tsx index e5975b7b5..792a30438 100644 --- a/web/components/web-view/select-parent.tsx +++ b/web/components/web-view/select-parent.tsx @@ -14,7 +14,10 @@ import issuesService from "services/issues.service"; import { ISSUE_DETAILS } from "constants/fetch-keys"; // components -import { ParentIssuesListModal } from "components/issues"; +import { IssuesSelectBottomSheet } from "components/web-view"; + +// icons +import { ChevronDown, X } from "lucide-react"; // types import { ISearchIssueResponse } from "types"; @@ -26,7 +29,7 @@ type Props = { }; export const ParentSelect: React.FC = (props) => { - const { value, onChange, disabled = false } = props; + const { onChange, disabled = false } = props; const [isParentModalOpen, setIsParentModalOpen] = useState(false); const [selectedParentIssue, setSelectedParentIssue] = useState(null); @@ -42,35 +45,67 @@ export const ParentSelect: React.FC = (props) => { : null ); + const parentIssueResult = selectedParentIssue + ? `${selectedParentIssue.project__identifier}-${selectedParentIssue.sequence_id}` + : issueDetails?.parent + ? `${issueDetails.parent_detail?.project_detail.identifier}-${issueDetails.parent_detail?.sequence_id}` + : null; // defaults to null + return ( <> - setIsParentModalOpen(false)} - onChange={(issue) => { + onClose={() => setIsParentModalOpen(false)} + singleSelect + onSubmit={async (issues) => { + if (disabled) return; + const issue = issues[0]; onChange(issue.id); setSelectedParentIssue(issue); }} - issueId={issueId as string} - projectId={projectId as string} + searchParams={{ + parent: true, + issue_id: issueId as string, + }} /> - + +
    + ) : ( + + + + )} ); }; diff --git a/web/components/web-view/select-priority.tsx b/web/components/web-view/select-priority.tsx index 8267f22c4..59a22ef45 100644 --- a/web/components/web-view/select-priority.tsx +++ b/web/components/web-view/select-priority.tsx @@ -74,11 +74,13 @@ export const PrioritySelect: React.FC = (props) => { 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" + "relative w-full px-2.5 py-0.5 text-base flex justify-between items-center gap-0.5" } > - {value ? capitalizeFirstLetter(value) : "None"} - + + {value ? capitalizeFirstLetter(value) : "None"} + + ); diff --git a/web/components/web-view/select-relates-to.tsx b/web/components/web-view/select-relates-to.tsx new file mode 100644 index 000000000..56e509d2f --- /dev/null +++ b/web/components/web-view/select-relates-to.tsx @@ -0,0 +1,114 @@ +// react +import React, { useState } from "react"; + +// next +import { useRouter } from "next/router"; + +// swr +import { mutate } from "swr"; + +// services +import issuesService from "services/issues.service"; + +// hooks +import useUser from "hooks/use-user"; + +// fetch keys +import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys"; + +// icons +import { ChevronDown } from "lucide-react"; + +// components +import { IssuesSelectBottomSheet } from "components/web-view"; + +// types +import { BlockeIssueDetail, ISearchIssueResponse } from "types"; + +type Props = { + disabled?: boolean; +}; + +export const RelatesSelect: React.FC = (props) => { + const { disabled = false } = props; + + const [isBottomSheetOpen, setIsBottomSheetOpen] = useState(false); + + const router = useRouter(); + const { workspaceSlug, projectId, issueId } = router.query; + + const { user } = useUser(); + + const onSubmit = async (data: ISearchIssueResponse[]) => { + if (!workspaceSlug || !projectId || !issueId || !user || disabled) return; + + if (data.length === 0) + return console.log( + "toast", + JSON.stringify({ + type: "error", + message: "Please select at least one issue.", + }) + ); + + const selectedIssues: { blocker_issue_detail: BlockeIssueDetail }[] = data.map((i) => ({ + blocker_issue_detail: { + id: i.id, + name: i.name, + sequence_id: i.sequence_id, + project_detail: { + id: i.project_id, + identifier: i.project__identifier, + name: i.project__name, + }, + }, + })); + + issuesService + .createIssueRelation( + workspaceSlug.toString(), + projectId.toString(), + issueId.toString(), + user, + { + related_list: [ + ...selectedIssues.map((issue) => ({ + issue: issueId as string, + issue_detail: issue.blocker_issue_detail, + related_issue: issue.blocker_issue_detail.id, + relation_type: "relates_to" as const, + })), + ], + } + ) + .then(() => { + mutate(ISSUE_DETAILS(issueId as string)); + mutate(PROJECT_ISSUES_ACTIVITY(issueId as string)); + }); + + setIsBottomSheetOpen(false); + }; + + return ( + <> + setIsBottomSheetOpen(false)} + onSubmit={onSubmit} + searchParams={{ issue_relation: true }} + /> + + + + ); +}; diff --git a/web/components/web-view/select-state.tsx b/web/components/web-view/select-state.tsx index c5bfa1257..99c3cba2c 100644 --- a/web/components/web-view/select-state.tsx +++ b/web/components/web-view/select-state.tsx @@ -78,11 +78,11 @@ export const StateSelect: React.FC = (props) => { 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" + "relative w-full px-2.5 py-0.5 text-base flex justify-between items-center gap-0.5" } > - {selectedState?.name || "Select a state"} - + {selectedState?.name || "Select a state"} + ); diff --git a/web/components/web-view/spinner.tsx b/web/components/web-view/spinner.tsx new file mode 100644 index 000000000..9d9db1747 --- /dev/null +++ b/web/components/web-view/spinner.tsx @@ -0,0 +1,5 @@ +export const Spinner: React.FC = () => ( +
    + spinner +
    +); diff --git a/web/components/web-view/sub-issues.tsx b/web/components/web-view/sub-issues.tsx index 4299d9e3a..70aec3d48 100644 --- a/web/components/web-view/sub-issues.tsx +++ b/web/components/web-view/sub-issues.tsx @@ -1,5 +1,5 @@ // react -import React from "react"; +import React, { useState } from "react"; // next import { useRouter } from "next/router"; @@ -8,7 +8,7 @@ import { useRouter } from "next/router"; import useSWR, { mutate } from "swr"; // icons -import { X } from "lucide-react"; +import { X, PlusIcon } from "lucide-react"; // services import issuesService from "services/issues.service"; @@ -21,10 +21,12 @@ import useUser from "hooks/use-user"; // ui import { Spinner } from "components/ui"; -import { IIssue } from "types"; // components -import { Label } from "components/web-view"; +import { Label, IssuesSelectBottomSheet, DeleteConfirmation } from "components/web-view"; + +// types +import { IIssue, ISearchIssueResponse } from "types"; type Props = { issueDetails?: IIssue; @@ -34,7 +36,12 @@ export const SubIssueList: React.FC = (props) => { const { issueDetails } = props; const router = useRouter(); - const { workspaceSlug } = router.query; + const { workspaceSlug, projectId, issueId } = router.query; + + const isArchive = Boolean(router.query.archive); + + const [isBottomSheetOpen, setIsBottomSheetOpen] = useState(false); + const [issueSelectedForDelete, setIssueSelectedForDelete] = useState(null); const { user } = useUser(); @@ -46,8 +53,8 @@ export const SubIssueList: React.FC = (props) => { : null ); - const handleSubIssueRemove = (issue: any) => { - if (!workspaceSlug || !issueDetails || !user) return; + const handleSubIssueRemove = (issue: IIssue | null) => { + if (!workspaceSlug || !issueDetails || !user || !issue) return; mutate( SUB_ISSUES(issueDetails.id), @@ -72,8 +79,40 @@ export const SubIssueList: React.FC = (props) => { .finally(() => mutate(SUB_ISSUES(issueDetails.id))); }; + const addAsSubIssueFromExistingIssues = async (data: ISearchIssueResponse[]) => { + if (!workspaceSlug || !projectId || !issueId || isArchive) return; + + const payload = { + sub_issue_ids: data.map((i) => i.id), + }; + await issuesService + .addSubIssues(workspaceSlug.toString(), projectId.toString(), issueId.toString(), payload) + .finally(() => { + mutate(SUB_ISSUES(issueId.toString())); + }); + }; + return (
    + setIsBottomSheetOpen(false)} + onSubmit={addAsSubIssueFromExistingIssues} + searchParams={{ sub_issue: true, issue_id: issueId as string }} + /> + + setIssueSelectedForDelete(null)} + onConfirm={() => { + if (isArchive) return; + setIssueSelectedForDelete(null); + handleSubIssueRemove(issueSelectedForDelete); + }} + /> +
    {!subIssuesResponse && ( @@ -97,12 +136,28 @@ export const SubIssueList: React.FC = (props) => {

    {subIssue.name}

    -
    ))}
    +
    ); }; diff --git a/web/components/web-view/web-view-modal.tsx b/web/components/web-view/web-view-modal.tsx index 39769dfcd..57f394c90 100644 --- a/web/components/web-view/web-view-modal.tsx +++ b/web/components/web-view/web-view-modal.tsx @@ -63,7 +63,7 @@ export const WebViewModal = (props: Props) => {
    -
    {children}
    +
    {children}
    @@ -75,7 +75,7 @@ export const WebViewModal = (props: Props) => { type OptionsProps = { options: Array<{ - label: string; + label: string | React.ReactNode; value: string | null; checked: boolean; icon?: any; @@ -84,14 +84,14 @@ type OptionsProps = { }; const Options: React.FC = ({ options }) => ( -
    +
    {options.map((option) => (
    -
    +
    @@ -104,5 +104,16 @@ const Options: React.FC = ({ options }) => (
    ); +type FooterProps = { + children: React.ReactNode; + className?: string; +}; + +const Footer: React.FC = ({ children, className }) => ( +
    {children}
    +); + WebViewModal.Options = Options; +WebViewModal.Footer = Footer; WebViewModal.Options.displayName = "WebViewModal.Options"; +WebViewModal.Footer.displayName = "WebViewModal.Footer"; diff --git a/web/layouts/web-view-layout/index.tsx b/web/layouts/web-view-layout/index.tsx index 8a58407c3..bec3798d7 100644 --- a/web/layouts/web-view-layout/index.tsx +++ b/web/layouts/web-view-layout/index.tsx @@ -11,7 +11,7 @@ import { CURRENT_USER } from "constants/fetch-keys"; import { AlertCircle } from "lucide-react"; // ui -import { Spinner } from "components/ui"; +import { Spinner } from "components/web-view"; type Props = { children: React.ReactNode; @@ -40,7 +40,6 @@ const WebViewLayout: React.FC = ({ children, fullScreen = true }) => { return (
    -

    Loading your profile...

    diff --git a/web/pages/m/[workspaceSlug]/editor.tsx b/web/pages/m/[workspaceSlug]/editor.tsx index 2bfac63b8..d19b281ea 100644 --- a/web/pages/m/[workspaceSlug]/editor.tsx +++ b/web/pages/m/[workspaceSlug]/editor.tsx @@ -15,7 +15,8 @@ import WebViewLayout from "layouts/web-view-layout"; // components import { TipTapEditor } from "components/tiptap"; -import { PrimaryButton, Spinner } from "components/ui"; +import { PrimaryButton } from "components/ui"; +import { Spinner } from "components/web-view"; const Editor: NextPage = () => { const [isLoading, setIsLoading] = useState(false); @@ -41,13 +42,13 @@ const Editor: NextPage = () => { }, [isEditable, setValue, router]); return ( - + {isLoading ? (
    ) : ( - <> +
    { editable={isEditable} noBorder={true} workspaceSlug={workspaceSlug?.toString() ?? ""} - debouncedUpdatesEnabled={true} - customClassName="min-h-[150px] shadow-sm" + customClassName="h-full shadow-sm overflow-auto" editorContentCustomClassNames="pb-9" onChange={(description: Object, description_html: string) => { onChange(description_html); @@ -77,7 +77,7 @@ const Editor: NextPage = () => { /> {isEditable && ( { console.log( "submitted", @@ -90,7 +90,7 @@ const Editor: NextPage = () => { Submit )} - +
    )}
    ); diff --git a/web/pages/m/[workspaceSlug]/projects/[projectId]/issues/[issueId].tsx b/web/pages/m/[workspaceSlug]/projects/[projectId]/issues/[issueId].tsx index 6ac0eda76..47f004783 100644 --- a/web/pages/m/[workspaceSlug]/projects/[projectId]/issues/[issueId].tsx +++ b/web/pages/m/[workspaceSlug]/projects/[projectId]/issues/[issueId].tsx @@ -8,7 +8,7 @@ import { useRouter } from "next/router"; import useSWR, { mutate } from "swr"; // react hook forms -import { useForm } from "react-hook-form"; +import { useFormContext, useForm, FormProvider } from "react-hook-form"; // services import issuesService from "services/issues.service"; @@ -23,9 +23,6 @@ import useProjectMembers from "hooks/use-project-members"; // layouts import WebViewLayout from "layouts/web-view-layout"; -// ui -import { Spinner } from "components/ui"; - // components import { IssueWebViewForm, @@ -34,46 +31,58 @@ import { IssuePropertiesDetail, IssueLinks, IssueActivity, + Spinner, } from "components/web-view"; // types import type { IIssue } from "types"; -const MobileWebViewIssueDetail = () => { +const MobileWebViewIssueDetail_ = () => { const router = useRouter(); const { workspaceSlug, projectId, issueId } = router.query; + const isArchive = Boolean(router.query.archive); + const memberRole = useProjectMembers( workspaceSlug as string, projectId as string, !!workspaceSlug && !!projectId ); - const isAllowed = Boolean(memberRole.isMember || memberRole.isOwner); + const isAllowed = Boolean((memberRole.isMember || memberRole.isOwner) && !isArchive); const { user } = useUser(); - const { register, control, reset, handleSubmit, watch } = useForm({ - defaultValues: { - name: "", - description: "", - description_html: "", - state: "", - }, - }); + const formContext = useFormContext(); + const { register, handleSubmit, control, watch, reset } = formContext; const { - data: issueDetails, - mutate: mutateIssueDetails, + data: issue, + mutate: mutateIssue, error, } = useSWR( - workspaceSlug && projectId && issueId ? ISSUE_DETAILS(issueId.toString()) : null, - workspaceSlug && projectId && issueId + workspaceSlug && projectId && issueId && !isArchive ? ISSUE_DETAILS(issueId.toString()) : null, + workspaceSlug && projectId && issueId && !isArchive ? () => issuesService.retrieve(workspaceSlug.toString(), projectId.toString(), issueId.toString()) : null ); + const { data: archiveIssueDetails, mutate: mutateaArchiveIssue } = useSWR( + workspaceSlug && projectId && issueId && isArchive ? ISSUE_DETAILS(issueId as string) : null, + workspaceSlug && projectId && issueId && isArchive + ? () => + issuesService.retrieveArchivedIssue( + workspaceSlug.toString(), + projectId.toString(), + issueId.toString() + ) + : null + ); + + const issueDetails = isArchive ? archiveIssueDetails : issue; + const mutateIssueDetails = isArchive ? mutateaArchiveIssue : mutateIssue; + useEffect(() => { if (!issueDetails) return; reset({ @@ -132,7 +141,6 @@ const MobileWebViewIssueDetail = () => {
    - Loading...
    @@ -147,6 +155,10 @@ const MobileWebViewIssueDetail = () => { return ( + {isArchive && ( +
    + )} +
    { - + @@ -172,4 +184,14 @@ const MobileWebViewIssueDetail = () => { ); }; +const MobileWebViewIssueDetail = () => { + const methods = useForm(); + + return ( + + + + ); +}; + export default MobileWebViewIssueDetail; diff --git a/web/public/web-view-spinner.png b/web/public/web-view-spinner.png new file mode 100644 index 000000000..527f307c2 Binary files /dev/null and b/web/public/web-view-spinner.png differ diff --git a/web/styles/globals.css b/web/styles/globals.css index 3de1e2c57..14a5dd30f 100644 --- a/web/styles/globals.css +++ b/web/styles/globals.css @@ -359,3 +359,114 @@ body { .disable-scroll { overflow: hidden !important; } + +div.web-view-spinner { + position: relative; + width: 54px; + height: 54px; + display: inline-block; + margin-left: 50%; + margin-right: 50%; + padding: 10px; + border-radius: 10px; +} + +div.web-view-spinner div { + width: 6%; + height: 16%; + background: rgb(var(--color-text-400)); + position: absolute; + left: 49%; + top: 43%; + opacity: 0; + border-radius: 50px; + -webkit-border-radius: 50px; + box-shadow: 0 0 3px rgba(0,0,0,0.2); + -webkit-box-shadow: 0 0 3px rgba(0,0,0,0.2); + animation: fade 1s linear infinite; + -webkit-animation: fade 1s linear infinite; +} + +@keyframes fade { + from {opacity: 1;} + to {opacity: 0.25;} +} +@-webkit-keyframes fade { + from {opacity: 1;} + to {opacity: 0.25;} +} + +div.web-view-spinner div.bar1 { + transform: rotate(0deg) translate(0, -130%); + -webkit-transform:rotate(0deg) translate(0, -130%); + animation-delay: 0s; + -webkit-animation-delay: 0s; +} + +div.web-view-spinner div.bar2 { + transform:rotate(30deg) translate(0, -130%); + -webkit-transform:rotate(30deg) translate(0, -130%); + animation-delay: -0.9167s; + -webkit-animation-delay: -0.9167s; +} + +div.web-view-spinner div.bar3 { + transform:rotate(60deg) translate(0, -130%); + -webkit-transform:rotate(60deg) translate(0, -130%); + animation-delay: -0.833s; + -webkit-animation-delay: -0.833s; +} +div.web-view-spinner div.bar4 { + transform:rotate(90deg) translate(0, -130%); + -webkit-transform:rotate(90deg) translate(0, -130%); + animation-delay: -0.7497s; + -webkit-animation-delay: -0.7497s; +} +div.web-view-spinner div.bar5 { + transform:rotate(120deg) translate(0, -130%); + -webkit-transform:rotate(120deg) translate(0, -130%); + animation-delay: -0.667s; + -webkit-animation-delay: -0.667s; +} +div.web-view-spinner div.bar6 { + transform:rotate(150deg) translate(0, -130%); + -webkit-transform:rotate(150deg) translate(0, -130%); + animation-delay: -0.5837s; + -webkit-animation-delay: -0.5837s; +} +div.web-view-spinner div.bar7 { + transform:rotate(180deg) translate(0, -130%); + -webkit-transform:rotate(180deg) translate(0, -130%); + animation-delay: -0.5s; + -webkit-animation-delay: -0.5s; +} +div.web-view-spinner div.bar8 { + transform:rotate(210deg) translate(0, -130%); + -webkit-transform:rotate(210deg) translate(0, -130%); + animation-delay: -0.4167s; + -webkit-animation-delay: -0.4167s; +} +div.web-view-spinner div.bar9 { + transform:rotate(240deg) translate(0, -130%); + -webkit-transform:rotate(240deg) translate(0, -130%); + animation-delay: -0.333s; + -webkit-animation-delay: -0.333s; +} +div.web-view-spinner div.bar10 { + transform:rotate(270deg) translate(0, -130%); + -webkit-transform:rotate(270deg) translate(0, -130%); + animation-delay: -0.2497s; + -webkit-animation-delay: -0.2497s; +} +div.web-view-spinner div.bar11 { + transform:rotate(300deg) translate(0, -130%); + -webkit-transform:rotate(300deg) translate(0, -130%); + animation-delay: -0.167s; + -webkit-animation-delay: -0.167s; +} +div.web-view-spinner div.bar12 { + transform:rotate(330deg) translate(0, -130%); + -webkit-transform:rotate(330deg) translate(0, -130%); + animation-delay: -0.0833s; + -webkit-animation-delay: -0.0833s; +}