mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
chore: delete unused files (#2585)
This commit is contained in:
parent
2d64caef90
commit
10e35d9a06
@ -1,512 +0,0 @@
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// icons
|
||||
import {
|
||||
CalendarDays,
|
||||
CopyPlus,
|
||||
History,
|
||||
LinkIcon,
|
||||
MessageSquare,
|
||||
Paperclip,
|
||||
Rocket,
|
||||
Signal,
|
||||
Tag,
|
||||
Users2,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DoubleCircleIcon,
|
||||
Tooltip,
|
||||
BlockedIcon,
|
||||
BlockerIcon,
|
||||
RelatedIcon,
|
||||
UserGroupIcon,
|
||||
ArchiveIcon,
|
||||
ContrastIcon,
|
||||
LayersIcon,
|
||||
DiceIcon,
|
||||
} from "@plane/ui";
|
||||
// helpers
|
||||
import { renderShortDateWithYearFormat } from "helpers/date-time.helper";
|
||||
import { capitalizeFirstLetter } from "helpers/string.helper";
|
||||
// types
|
||||
import { IIssueActivity } from "types";
|
||||
|
||||
const IssueLink = ({ activity }: { activity: IIssueActivity }) => (
|
||||
<Tooltip tooltipContent={activity.issue_detail ? activity.issue_detail.name : "This issue has been deleted"}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
console.log(
|
||||
"issue",
|
||||
JSON.stringify({
|
||||
project_id: activity.project,
|
||||
issue_id: activity.issue,
|
||||
})
|
||||
)
|
||||
}
|
||||
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
|
||||
>
|
||||
{activity.issue_detail ? `${activity.project_detail.identifier}-${activity.issue_detail.sequence_id}` : "Issue"}
|
||||
<Rocket className="h-3 w-3" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const UserLink = ({ activity }: { activity: IIssueActivity }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => console.log("user", activity.new_identifier ?? activity.old_identifier)}
|
||||
className="font-medium text-custom-text-100 inline-flex items-center hover:underline"
|
||||
>
|
||||
{activity.new_value && activity.new_value !== "" ? activity.new_value : activity.old_value}
|
||||
</button>
|
||||
);
|
||||
|
||||
const activityDetails: {
|
||||
[key: string]: {
|
||||
message: (activity: IIssueActivity, showIssue: boolean, workspaceSlug: string) => React.ReactNode;
|
||||
icon: React.ReactNode;
|
||||
};
|
||||
} = {
|
||||
assignees: {
|
||||
message: (activity, showIssue) => (
|
||||
<>
|
||||
{activity.old_value === "" ? "added a new assignee " : "removed the assignee "}
|
||||
<UserLink activity={activity} />
|
||||
{showIssue && (
|
||||
<>
|
||||
{" "}
|
||||
to <IssueLink activity={activity} />
|
||||
</>
|
||||
)}
|
||||
.
|
||||
</>
|
||||
),
|
||||
icon: <UserGroupIcon className="h-3 w-3" />,
|
||||
},
|
||||
|
||||
archived_at: {
|
||||
message: (activity) => {
|
||||
if (activity.new_value === "restore") return "restored the issue.";
|
||||
else return "archived the issue.";
|
||||
},
|
||||
icon: <ArchiveIcon className="h-5 w-5" />,
|
||||
},
|
||||
|
||||
attachment: {
|
||||
message: (activity, showIssue) => (
|
||||
<>
|
||||
{activity.verb === "created" ? "uploaded a new " : "removed an "}
|
||||
{activity.new_value && activity.new_value !== "" ? (
|
||||
<button type="button" onClick={() => console.log("attachment", activity.new_value)}>
|
||||
attachment
|
||||
</button>
|
||||
) : (
|
||||
"attachment"
|
||||
)}
|
||||
{showIssue && activity.verb === "created" ? " to " : " from "}
|
||||
{showIssue && <IssueLink activity={activity} />}
|
||||
</>
|
||||
),
|
||||
icon: <Paperclip className="h-5 w-5" aria-hidden="true" />,
|
||||
},
|
||||
|
||||
blocking: {
|
||||
message: (activity) => (
|
||||
<>
|
||||
{activity.old_value === "" ? "marked this issue is blocking issue " : "removed the blocking issue "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
console.log(
|
||||
"issue",
|
||||
JSON.stringify({
|
||||
project_id: activity.project,
|
||||
issue_id: activity.new_identifier ?? activity.old_identifier,
|
||||
issue_identifier:
|
||||
activity.new_value && activity.new_value !== "" ? activity.new_value : activity.old_value,
|
||||
})
|
||||
)
|
||||
}
|
||||
className="font-medium text-custom-text-100"
|
||||
>
|
||||
{activity.old_value === "" ? activity.new_value : activity.old_value}
|
||||
</button>
|
||||
.
|
||||
</>
|
||||
),
|
||||
icon: <BlockerIcon height="12" width="12" color="#6b7280" />,
|
||||
},
|
||||
|
||||
blocked_by: {
|
||||
message: (activity) => (
|
||||
<>
|
||||
{activity.old_value === ""
|
||||
? "marked this issue is being blocked by issue "
|
||||
: "removed this issue being blocked by issue "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
console.log(
|
||||
"issue",
|
||||
JSON.stringify({
|
||||
project_id: activity.project,
|
||||
issue_id: activity.new_identifier ?? activity.old_identifier,
|
||||
issue_identifier:
|
||||
activity.new_value && activity.new_value !== "" ? activity.new_value : activity.old_value,
|
||||
})
|
||||
)
|
||||
}
|
||||
className="font-medium text-custom-text-100"
|
||||
>
|
||||
{activity.old_value === "" ? activity.new_value : activity.old_value}
|
||||
</button>
|
||||
.
|
||||
</>
|
||||
),
|
||||
icon: <BlockedIcon height="12" width="12" color="#6b7280" />,
|
||||
},
|
||||
|
||||
duplicate: {
|
||||
message: (activity) => (
|
||||
<>
|
||||
{activity.old_value === "" ? "marked this issue as duplicate of " : "removed this issue as a duplicate of "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
console.log(
|
||||
"issue",
|
||||
JSON.stringify({
|
||||
project_id: activity.project,
|
||||
issue_id: activity.new_identifier ?? activity.old_identifier,
|
||||
issue_identifier:
|
||||
activity.new_value && activity.new_value !== "" ? activity.new_value : activity.old_value,
|
||||
})
|
||||
)
|
||||
}
|
||||
className="font-medium text-custom-text-100"
|
||||
>
|
||||
{activity.verb === "created" ? activity.new_value : activity.old_value}
|
||||
</button>
|
||||
.
|
||||
</>
|
||||
),
|
||||
icon: <CopyPlus size={12} color="#6b7280" />,
|
||||
},
|
||||
|
||||
relates_to: {
|
||||
message: (activity) => (
|
||||
<>
|
||||
{activity.old_value === "" ? "marked that this issue relates to " : "removed the relation from "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
console.log(
|
||||
"issue",
|
||||
JSON.stringify({
|
||||
project_id: activity.project,
|
||||
issue_id: activity.new_identifier ?? activity.old_identifier,
|
||||
issue_identifier:
|
||||
activity.new_value && activity.new_value !== "" ? activity.new_value : activity.old_value,
|
||||
})
|
||||
);
|
||||
}}
|
||||
className="font-medium text-custom-text-100"
|
||||
>
|
||||
{activity.old_value === "" ? activity.new_value : activity.old_value}
|
||||
</button>
|
||||
.
|
||||
</>
|
||||
),
|
||||
icon: <RelatedIcon height="12" width="12" color="#6b7280" />,
|
||||
},
|
||||
|
||||
cycles: {
|
||||
message: (activity) => (
|
||||
<>
|
||||
{activity.verb === "created" && "added this issue to the cycle "}
|
||||
{activity.verb === "updated" && "set the cycle to "}
|
||||
{activity.verb === "deleted" && "removed the issue from the cycle "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
console.log(
|
||||
"cycle",
|
||||
JSON.stringify({
|
||||
cycle_id: activity.new_identifier ?? activity.old_identifier,
|
||||
project_id: activity.project,
|
||||
cycle_name: !activity.new_value || activity.new_value === "" ? activity.old_value : activity.new_value,
|
||||
})
|
||||
)
|
||||
}
|
||||
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
|
||||
>
|
||||
{activity.verb === "created" || activity.verb === "updated" ? activity.new_value : activity.old_value}
|
||||
<Rocket className="h-3 w-3" />
|
||||
</button>
|
||||
</>
|
||||
),
|
||||
icon: <ContrastIcon className="h-5 w-5" aria-hidden="true" />,
|
||||
},
|
||||
|
||||
description: {
|
||||
message: (activity, showIssue) => (
|
||||
<>
|
||||
updated the description
|
||||
{showIssue && (
|
||||
<>
|
||||
{" "}
|
||||
of <IssueLink activity={activity} />
|
||||
</>
|
||||
)}
|
||||
.
|
||||
</>
|
||||
),
|
||||
icon: <MessageSquare className="h-5 w-5" />,
|
||||
},
|
||||
|
||||
estimate_point: {
|
||||
message: (activity, showIssue) => (
|
||||
<>
|
||||
{activity.new_value ? "set the estimate point to " : "removed the estimate point "}
|
||||
{activity.new_value && <span className="font-medium text-custom-text-100">{activity.new_value}</span>}
|
||||
{showIssue && (
|
||||
<>
|
||||
{" "}
|
||||
for <IssueLink activity={activity} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
icon: <History className="h-5 w-5" />,
|
||||
},
|
||||
|
||||
issue: {
|
||||
message: (activity) => {
|
||||
if (activity.verb === "created") return "created the issue.";
|
||||
else return "deleted an issue.";
|
||||
},
|
||||
icon: <LayersIcon className="h-5 w-5" />,
|
||||
},
|
||||
|
||||
labels: {
|
||||
message: (activity, showIssue) => (
|
||||
<>
|
||||
{activity.old_value === "" ? "added a new label " : "removed the label "}
|
||||
<span className="inline-flex items-center gap-3 rounded-full border border-custom-border-300 px-2 py-0.5 text-xs">
|
||||
<span
|
||||
className="h-1.5 w-1.5 rounded-full"
|
||||
style={{
|
||||
backgroundColor: "#000000",
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="font-medium text-custom-text-100">
|
||||
{activity.old_value === "" ? activity.new_value : activity.old_value}
|
||||
</span>
|
||||
</span>
|
||||
{showIssue && (
|
||||
<>
|
||||
{" "}
|
||||
to <IssueLink activity={activity} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
icon: <Tag className="h-5 w-5" />,
|
||||
},
|
||||
|
||||
link: {
|
||||
message: (activity, showIssue) => (
|
||||
<>
|
||||
{activity.verb === "created" && "added this "}
|
||||
{activity.verb === "updated" && "updated this "}
|
||||
{activity.verb === "deleted" && "removed this "}
|
||||
<button
|
||||
onClick={() => console.log("link", activity.verb === "created" ? activity.new_value : activity.old_value)}
|
||||
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
|
||||
>
|
||||
link
|
||||
<Rocket className="h-3 w-3" />
|
||||
</button>
|
||||
{showIssue && (
|
||||
<>
|
||||
{" "}
|
||||
to <IssueLink activity={activity} />
|
||||
</>
|
||||
)}
|
||||
.
|
||||
</>
|
||||
),
|
||||
icon: <LinkIcon className="h-5 w-5" />,
|
||||
},
|
||||
|
||||
modules: {
|
||||
message: (activity) => (
|
||||
<>
|
||||
{activity.verb === "created" && "added this "}
|
||||
{activity.verb === "updated" && "updated this "}
|
||||
{activity.verb === "deleted" && "removed this "}
|
||||
module{" "}
|
||||
<button
|
||||
onClick={() =>
|
||||
console.log(
|
||||
"module",
|
||||
JSON.stringify({
|
||||
module_id: activity.new_identifier ?? activity.old_identifier,
|
||||
project_id: activity.project,
|
||||
module_name: !activity.new_value || activity.new_value === "" ? activity.old_value : activity.new_value,
|
||||
})
|
||||
)
|
||||
}
|
||||
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
|
||||
>
|
||||
{activity.verb === "created" || activity.verb === "updated" ? activity.new_value : activity.old_value}
|
||||
<Rocket className="h-5 w-5" />
|
||||
</button>
|
||||
.
|
||||
</>
|
||||
),
|
||||
icon: <DiceIcon className="h-5 w-5" />,
|
||||
},
|
||||
|
||||
name: {
|
||||
message: (activity, showIssue) => (
|
||||
<>
|
||||
set the name to {activity.new_value}
|
||||
{showIssue && (
|
||||
<>
|
||||
{" "}
|
||||
of <IssueLink activity={activity} />
|
||||
</>
|
||||
)}
|
||||
.
|
||||
</>
|
||||
),
|
||||
icon: <MessageSquare className="h-5 w-5" />,
|
||||
},
|
||||
|
||||
parent: {
|
||||
message: (activity, showIssue) => (
|
||||
<>
|
||||
{activity.new_value ? "set the parent to " : "removed the parent "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
console.log(
|
||||
"issue",
|
||||
JSON.stringify({
|
||||
project_id: activity.project,
|
||||
issue_id: activity.new_identifier ?? activity.old_identifier,
|
||||
issue_identifier:
|
||||
activity.new_value && activity.new_value !== "" ? activity.new_value : activity.old_value,
|
||||
})
|
||||
);
|
||||
}}
|
||||
className="font-medium text-custom-text-100"
|
||||
>
|
||||
{activity.new_value ? activity.new_value : activity.old_value}
|
||||
</button>
|
||||
{showIssue && (
|
||||
<>
|
||||
{" "}
|
||||
for <IssueLink activity={activity} />
|
||||
</>
|
||||
)}
|
||||
.
|
||||
</>
|
||||
),
|
||||
icon: <Users2 className="h-5 w-5" />,
|
||||
},
|
||||
|
||||
priority: {
|
||||
message: (activity, showIssue) => (
|
||||
<>
|
||||
set the priority to{" "}
|
||||
<span className="font-medium text-custom-text-100">
|
||||
{activity.new_value ? capitalizeFirstLetter(activity.new_value) : "None"}
|
||||
</span>
|
||||
{showIssue && (
|
||||
<>
|
||||
{" "}
|
||||
for <IssueLink activity={activity} />
|
||||
</>
|
||||
)}
|
||||
.
|
||||
</>
|
||||
),
|
||||
icon: <Signal className="h-5 w-5" />,
|
||||
},
|
||||
|
||||
start_date: {
|
||||
message: (activity, showIssue) => (
|
||||
<>
|
||||
{activity.new_value ? "set the start date to " : "removed the start date "}
|
||||
<span className="font-medium text-custom-text-100">
|
||||
{activity.new_value ? renderShortDateWithYearFormat(activity.new_value) : "None"}
|
||||
</span>
|
||||
{showIssue && (
|
||||
<>
|
||||
{" "}
|
||||
for <IssueLink activity={activity} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
icon: <CalendarDays className="h-5 w-5" />,
|
||||
},
|
||||
|
||||
state: {
|
||||
message: (activity, showIssue) => (
|
||||
<>
|
||||
set the state to <span className="font-medium text-custom-text-100">{activity.new_value}</span>
|
||||
{showIssue && (
|
||||
<>
|
||||
{" "}
|
||||
for <IssueLink activity={activity} />
|
||||
</>
|
||||
)}
|
||||
.
|
||||
</>
|
||||
),
|
||||
icon: <DoubleCircleIcon className="h-3 w-3" aria-hidden="true" />,
|
||||
},
|
||||
|
||||
target_date: {
|
||||
message: (activity, showIssue) => (
|
||||
<>
|
||||
{activity.new_value ? "set the target date to " : "removed the target date "}
|
||||
{activity.new_value && (
|
||||
<span className="font-medium text-custom-text-100">{renderShortDateWithYearFormat(activity.new_value)}</span>
|
||||
)}
|
||||
|
||||
{showIssue && (
|
||||
<>
|
||||
{" "}
|
||||
for <IssueLink activity={activity} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
icon: <CalendarDays className="h-5 w-5" />,
|
||||
},
|
||||
};
|
||||
|
||||
export const ActivityIcon = ({ activity }: { activity: IIssueActivity }) => (
|
||||
<>{activityDetails[activity.field as keyof typeof activityDetails]?.icon}</>
|
||||
);
|
||||
|
||||
export const ActivityMessage = ({ activity, showIssue = false }: { activity: IIssueActivity; showIssue?: boolean }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
return (
|
||||
<>
|
||||
{activityDetails[activity.field as keyof typeof activityDetails]?.message(
|
||||
activity,
|
||||
showIssue,
|
||||
workspaceSlug?.toString() ?? ""
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
@ -1,107 +0,0 @@
|
||||
import React from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
// hooks
|
||||
import useProjectDetails from "hooks/use-project-details";
|
||||
// components
|
||||
import { LiteTextEditorWithRef } from "@plane/lite-text-editor";
|
||||
import { Button } from "@plane/ui";
|
||||
// icons
|
||||
import { Send } from "lucide-react";
|
||||
// types
|
||||
import type { IIssueComment } from "types";
|
||||
// services
|
||||
import { FileService } from "services/file.service";
|
||||
|
||||
const defaultValues: Partial<IIssueComment> = {
|
||||
access: "INTERNAL",
|
||||
comment_html: "",
|
||||
};
|
||||
|
||||
type Props = {
|
||||
disabled?: boolean;
|
||||
onSubmit: (data: IIssueComment) => Promise<void>;
|
||||
};
|
||||
|
||||
type commentAccessType = {
|
||||
icon: string;
|
||||
key: string;
|
||||
label: "Private" | "Public";
|
||||
};
|
||||
|
||||
const commentAccess: commentAccessType[] = [
|
||||
{
|
||||
icon: "lock",
|
||||
key: "INTERNAL",
|
||||
label: "Private",
|
||||
},
|
||||
{
|
||||
icon: "public",
|
||||
key: "EXTERNAL",
|
||||
label: "Public",
|
||||
},
|
||||
];
|
||||
|
||||
const fileService = new FileService();
|
||||
|
||||
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 || false;
|
||||
|
||||
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">
|
||||
<Controller
|
||||
name="access"
|
||||
control={control}
|
||||
render={({ field: { onChange: onAccessChange, value: accessValue } }) => (
|
||||
<Controller
|
||||
name="comment_html"
|
||||
control={control}
|
||||
render={({ field: { onChange: onCommentChange, value: commentValue } }) => (
|
||||
<LiteTextEditorWithRef
|
||||
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
|
||||
deleteFile={fileService.deleteImage}
|
||||
ref={editorRef}
|
||||
value={!commentValue || commentValue === "" ? "<p></p>" : commentValue}
|
||||
customClassName="p-3 min-h-[100px] shadow-sm"
|
||||
debouncedUpdatesEnabled={false}
|
||||
onChange={(comment_json: Object, comment_html: string) => onCommentChange(comment_html)}
|
||||
commentAccessSpecifier={{ accessValue, onAccessChange, showAccessSpecifier, commentAccess }}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="inline">
|
||||
<Button variant="primary" type="submit" disabled={isSubmitting || disabled}>
|
||||
<Send className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
@ -1,188 +0,0 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
// react-hook-form
|
||||
import { useForm } from "react-hook-form";
|
||||
// icons
|
||||
import { Check, Globe2, Lock, MessageSquare, Pencil, Trash2, X } from "lucide-react";
|
||||
// service
|
||||
import { FileService } from "services/file.service";
|
||||
// hooks
|
||||
import useUser from "hooks/use-user";
|
||||
// ui
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
import { CommentReaction } from "components/issues";
|
||||
import { LiteTextEditorWithRef, LiteReadOnlyEditorWithRef } from "@plane/lite-text-editor";
|
||||
|
||||
// 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<IIssueComment>) => void;
|
||||
showAccessSpecifier?: boolean;
|
||||
workspaceSlug: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
// services
|
||||
const fileService = new FileService();
|
||||
|
||||
export const CommentCard: React.FC<Props> = (props) => {
|
||||
const { comment, handleCommentDeletion, onSubmit, showAccessSpecifier = false, workspaceSlug, disabled } = props;
|
||||
|
||||
const { user } = useUser();
|
||||
|
||||
const editorRef = React.useRef<any>(null);
|
||||
const showEditorRef = React.useRef<any>(null);
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
const {
|
||||
formState: { isSubmitting },
|
||||
handleSubmit,
|
||||
setFocus,
|
||||
watch,
|
||||
setValue,
|
||||
} = useForm<IIssueComment>({
|
||||
defaultValues: comment,
|
||||
});
|
||||
|
||||
const onEnter = (formData: Partial<IIssueComment>) => {
|
||||
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 (
|
||||
<div className="relative flex items-start space-x-3">
|
||||
<div className="relative px-1">
|
||||
{comment.actor_detail.avatar && comment.actor_detail.avatar !== "" ? (
|
||||
<img
|
||||
src={comment.actor_detail.avatar}
|
||||
alt={
|
||||
comment.actor_detail.is_bot ? comment.actor_detail.first_name + " Bot" : comment.actor_detail.display_name
|
||||
}
|
||||
height={30}
|
||||
width={30}
|
||||
className="grid h-7 w-7 place-items-center rounded-full border-2 border-custom-border-200"
|
||||
/>
|
||||
) : (
|
||||
<div className={`grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-500 text-white`}>
|
||||
{comment.actor_detail.is_bot
|
||||
? comment.actor_detail.first_name.charAt(0)
|
||||
: comment.actor_detail.display_name.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span className="absolute -bottom-0.5 -right-1 rounded-tl bg-custom-background-80 px-0.5 py-px">
|
||||
<MessageSquare className="h-3.5 w-3.5 text-custom-text-200" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div>
|
||||
<div className="text-xs">
|
||||
{comment.actor_detail.is_bot ? comment.actor_detail.first_name + " Bot" : comment.actor_detail.display_name}
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-custom-text-200">commented {timeAgo(comment.created_at)}</p>
|
||||
</div>
|
||||
<div className="issue-comments-section p-0">
|
||||
<form className={`flex-col gap-2 ${isEditing ? "flex" : "hidden"}`}>
|
||||
<div>
|
||||
<LiteTextEditorWithRef
|
||||
uploadFile={fileService.getUploadFileFunction(workspaceSlug)}
|
||||
deleteFile={fileService.deleteImage}
|
||||
onEnterKeyPress={handleSubmit(onEnter)}
|
||||
ref={editorRef}
|
||||
value={watch("comment_html")}
|
||||
debouncedUpdatesEnabled={false}
|
||||
customClassName="min-h-[50px] p-3 shadow-sm"
|
||||
onChange={(comment_json: Object, comment_html: string) => {
|
||||
setValue("comment_json", comment_json);
|
||||
setValue("comment_html", comment_html);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1 self-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || disabled}
|
||||
className="group rounded border border-green-500 bg-green-500/20 p-2 shadow-md duration-300 hover:bg-green-500"
|
||||
>
|
||||
<Check className="h-3 w-3 text-green-500 duration-300 group-hover:text-white" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="group rounded border border-red-500 bg-red-500/20 p-2 shadow-md duration-300 hover:bg-red-500"
|
||||
onClick={() => setIsEditing(false)}
|
||||
>
|
||||
<X className="h-3 w-3 text-red-500 duration-300 group-hover:text-white" />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div className={`relative ${isEditing ? "hidden" : ""}`}>
|
||||
{showAccessSpecifier && (
|
||||
<div className="absolute top-1 right-1.5 z-[1] text-custom-text-300">
|
||||
{comment.access === "INTERNAL" ? <Lock className="h-3 w-3" /> : <Globe2 className="h-3 w-3" />}
|
||||
</div>
|
||||
)}
|
||||
<LiteReadOnlyEditorWithRef
|
||||
ref={showEditorRef}
|
||||
value={comment.comment_html}
|
||||
customClassName="text-xs border border-custom-border-200 bg-custom-background-100"
|
||||
/>
|
||||
<CommentReaction readonly={disabled} projectId={comment.project} commentId={comment.id} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{user?.id === comment.actor && !disabled && (
|
||||
<CustomMenu ellipsis>
|
||||
<CustomMenu.MenuItem onClick={() => setIsEditing(true)} className="flex items-center gap-1">
|
||||
<Pencil className="h-3 w-3" />
|
||||
Edit comment
|
||||
</CustomMenu.MenuItem>
|
||||
{showAccessSpecifier && (
|
||||
<>
|
||||
{comment.access === "INTERNAL" ? (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => onSubmit(comment.id, { access: "EXTERNAL" })}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Globe2 className="h-3 w-3" />
|
||||
Switch to public comment
|
||||
</CustomMenu.MenuItem>
|
||||
) : (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => onSubmit(comment.id, { access: "INTERNAL" })}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Lock className="h-3 w-3" />
|
||||
Switch to private comment
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
handleCommentDeletion(comment.id);
|
||||
}}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
Delete comment
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
@ -1,30 +0,0 @@
|
||||
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<DeleteConfirmationProps> = (props) => {
|
||||
const { isOpen, onCancel, onConfirm, title, content } = props;
|
||||
|
||||
return (
|
||||
<WebViewModal isOpen={isOpen} onClose={onCancel} modalTitle={title}>
|
||||
<div className="text-custom-text-200">
|
||||
<p>{content}</p>
|
||||
</div>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
className="w-full py-2 flex items-center justify-center rounded-[4px] bg-red-500/10 text-red-500 border border-red-500 text-base font-medium"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</WebViewModal>
|
||||
);
|
||||
};
|
@ -1,175 +0,0 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { mutate } from "swr";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// services
|
||||
import { IssueService } from "services/issue";
|
||||
// fetch keys
|
||||
import { ISSUE_DETAILS } from "constants/fetch-keys";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// ui
|
||||
import { Button, Input } from "@plane/ui";
|
||||
// types
|
||||
import type { linkDetails, IIssueLink } from "types";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
data?: linkDetails;
|
||||
links?: linkDetails[];
|
||||
onSuccess: () => void;
|
||||
};
|
||||
|
||||
const issueService = new IssueService();
|
||||
|
||||
export const CreateUpdateLinkForm: React.FC<Props> = (props) => {
|
||||
const { isOpen, data, links, onSuccess } = props;
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, issueId } = router.query;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm({
|
||||
defaultValues: {
|
||||
title: "",
|
||||
url: "",
|
||||
},
|
||||
});
|
||||
|
||||
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) => {
|
||||
if (!workspaceSlug || !projectId || !issueId) return;
|
||||
|
||||
const payload = { metadata: {}, ...formData };
|
||||
|
||||
if (!data)
|
||||
await issueService
|
||||
.createIssueLink(workspaceSlug.toString(), projectId.toString(), issueId.toString(), payload)
|
||||
.then(() => {
|
||||
onSuccess();
|
||||
mutate(ISSUE_DETAILS(issueId.toString()));
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err?.status === 400)
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "This URL already exists for this issue.",
|
||||
});
|
||||
else
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Something went wrong. Please try again.",
|
||||
});
|
||||
});
|
||||
else {
|
||||
const updatedLinks = links?.map((l) =>
|
||||
l.id === data.id
|
||||
? {
|
||||
...l,
|
||||
title: formData.title,
|
||||
url: formData.url,
|
||||
}
|
||||
: l
|
||||
);
|
||||
|
||||
mutate(ISSUE_DETAILS(issueId.toString()), (prevData: any) => ({ ...prevData, issue_link: updatedLinks }), false);
|
||||
|
||||
await issueService
|
||||
.updateIssueLink(workspaceSlug.toString(), projectId.toString(), issueId.toString(), data!.id, payload)
|
||||
.then(() => {
|
||||
onSuccess();
|
||||
mutate(ISSUE_DETAILS(issueId.toString()));
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="space-y-5" onSubmit={handleSubmit(onSubmit)}>
|
||||
<div>
|
||||
<div className="space-y-5">
|
||||
<div className="mt-2 space-y-3">
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="url"
|
||||
rules={{
|
||||
required: "URL is required",
|
||||
}}
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<>
|
||||
<label htmlFor="url" className="text-custom-text-200 mb-2">
|
||||
URL
|
||||
</label>
|
||||
<Input
|
||||
id="url"
|
||||
name="url"
|
||||
type="url"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.url)}
|
||||
placeholder="https://..."
|
||||
className="w-full"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="title"
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<>
|
||||
<label htmlFor="title" className="text-custom-text-200 mb-2">
|
||||
{`Title (optional)`}
|
||||
</label>
|
||||
<Input
|
||||
id="title"
|
||||
name="title"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.title)}
|
||||
placeholder="Enter title"
|
||||
className="w-full"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<Button variant="primary" type="submit" loading={isSubmitting}>
|
||||
{data ? (isSubmitting ? "Updating Link..." : "Update Link") : isSubmitting ? "Adding Link..." : "Add Link"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
@ -1,188 +0,0 @@
|
||||
// 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> = (props) => {
|
||||
const {
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
renderAs = "button",
|
||||
noBorder = true,
|
||||
error,
|
||||
className,
|
||||
} = props;
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedDate, setSelectedDate] = useState<Date | null>(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 (
|
||||
<>
|
||||
<WebViewModal
|
||||
isOpen={isOpen}
|
||||
modalTitle="Select due-date"
|
||||
onClose={() => {
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className="w-full flex justify-center items-center">
|
||||
<DatePicker
|
||||
inline
|
||||
selected={selectedDate ? new Date(selectedDate) : null}
|
||||
className={`${
|
||||
renderAs === "input"
|
||||
? "block px-2 py-2 text-sm focus:outline-none"
|
||||
: renderAs === "button"
|
||||
? `px-2 py-1 text-xs shadow-sm ${
|
||||
disabled ? "" : "hover:bg-custom-background-80"
|
||||
} duration-300`
|
||||
: ""
|
||||
} ${error ? "border-red-500 bg-red-100" : ""} ${
|
||||
disabled ? "cursor-not-allowed" : "cursor-pointer"
|
||||
} ${
|
||||
noBorder ? "" : "border border-custom-border-200"
|
||||
} w-full rounded-md caret-transparent outline-none ${className}`}
|
||||
dateFormat="MMM dd, yyyy"
|
||||
{...props}
|
||||
onChange={(val) => {
|
||||
if (!val) setSelectedDate(null);
|
||||
else setSelectedDate(val);
|
||||
}}
|
||||
renderCustomHeader={({
|
||||
date,
|
||||
decreaseMonth,
|
||||
increaseMonth,
|
||||
prevMonthButtonDisabled,
|
||||
nextMonthButtonDisabled,
|
||||
}) => (
|
||||
<div className="flex justify-between px-5 text-lg font-medium">
|
||||
<h4>
|
||||
{date.toLocaleString("default", { month: "long" })} {date.getFullYear()}
|
||||
</h4>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={decreaseMonth}
|
||||
disabled={prevMonthButtonDisabled}
|
||||
className="text-custom-text-100"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M11.2285 14.5416L7.10352 10.4166C7.03407 10.3472 6.98546 10.2778 6.95768 10.2083C6.9299 10.1389 6.91602 10.0625 6.91602 9.97915C6.91602 9.89581 6.9299 9.81942 6.95768 9.74998C6.98546 9.68053 7.03407 9.61109 7.10352 9.54165L11.2493 5.39581C11.3743 5.27081 11.5237 5.20831 11.6973 5.20831C11.8709 5.20831 12.0202 5.27081 12.1452 5.39581C12.2702 5.52081 12.3292 5.67359 12.3223 5.85415C12.3153 6.0347 12.2493 6.18748 12.1243 6.31248L8.45768 9.97915L12.1452 13.6666C12.2702 13.7916 12.3327 13.9375 12.3327 14.1041C12.3327 14.2708 12.2702 14.4166 12.1452 14.5416C12.0202 14.6666 11.8674 14.7291 11.6868 14.7291C11.5063 14.7291 11.3535 14.6666 11.2285 14.5416Z"
|
||||
fill="#171717"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={increaseMonth}
|
||||
disabled={nextMonthButtonDisabled}
|
||||
className="text-custom-text-100"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M7.37496 14.5417C7.26385 14.4028 7.20482 14.25 7.19788 14.0834C7.19093 13.9167 7.24996 13.7709 7.37496 13.6459L11.0416 9.97919L7.35413 6.29169C7.24302 6.18058 7.19093 6.03128 7.19788 5.84378C7.20482 5.65628 7.26385 5.50697 7.37496 5.39586C7.51385 5.25697 7.66316 5.191 7.82288 5.19794C7.9826 5.20489 8.12496 5.27086 8.24996 5.39586L12.3958 9.54169C12.4652 9.61114 12.5139 9.68058 12.5416 9.75003C12.5694 9.81947 12.5833 9.89586 12.5833 9.97919C12.5833 10.0625 12.5694 10.1389 12.5416 10.2084C12.5139 10.2778 12.4652 10.3473 12.3958 10.4167L8.27079 14.5417C8.14579 14.6667 7.99996 14.7257 7.83329 14.7188C7.66663 14.7118 7.51385 14.6528 7.37496 14.5417Z"
|
||||
fill="#171717"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<WebViewModal.Footer className="flex items-center gap-2">
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
onChange(null);
|
||||
setSelectedDate(null);
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
Clear
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
onClick={() => {
|
||||
if (!selectedDate) onChange(null);
|
||||
else onChange(renderDateFormat(selectedDate));
|
||||
setIsOpen(false);
|
||||
}}
|
||||
type="button"
|
||||
className="w-full"
|
||||
>
|
||||
Apply
|
||||
</PrimaryButton>
|
||||
</WebViewModal.Footer>
|
||||
</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-200"
|
||||
}
|
||||
>
|
||||
{value ? (
|
||||
<div className="-my-0.5 flex items-center gap-2">
|
||||
{new Date(value).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
"Due date"
|
||||
)}
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
@ -1,27 +0,0 @@
|
||||
export * from "./web-view-modal";
|
||||
export * from "./select-state";
|
||||
export * from "./select-priority";
|
||||
export * from "./issue-web-view-form";
|
||||
export * from "./label";
|
||||
export * from "./sub-issues";
|
||||
export * from "./issue-attachments";
|
||||
export * from "./issue-properties-detail";
|
||||
export * from "./issue-link-list";
|
||||
export * from "./create-update-link-form";
|
||||
export * from "./issue-activity";
|
||||
export * from "./select-assignee";
|
||||
export * from "./select-estimate";
|
||||
export * from "./add-comment";
|
||||
export * from "./select-parent";
|
||||
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 "./comment-card";
|
||||
export * from "./date-selector";
|
@ -1,206 +0,0 @@
|
||||
// react
|
||||
import React from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import useSWR, { mutate } from "swr";
|
||||
|
||||
// fetch key
|
||||
import { PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
|
||||
// services
|
||||
import { IssueService, IssueCommentService } from "services/issue";
|
||||
// hooks
|
||||
import useUser from "hooks/use-user";
|
||||
// components
|
||||
import { Label, AddComment, ActivityMessage, ActivityIcon, CommentCard } from "components/web-view";
|
||||
// helpers
|
||||
import { timeAgo } from "helpers/date-time.helper";
|
||||
// ui
|
||||
import { History } from "lucide-react";
|
||||
// types
|
||||
import type { IIssue, IIssueComment } from "types";
|
||||
|
||||
type Props = {
|
||||
allowed: boolean;
|
||||
issueDetails: IIssue;
|
||||
};
|
||||
|
||||
// services
|
||||
const issueService = new IssueService();
|
||||
const issueCommentService = new IssueCommentService();
|
||||
|
||||
export const IssueActivity: React.FC<Props> = (props) => {
|
||||
const { issueDetails, allowed } = props;
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, issueId } = router.query;
|
||||
|
||||
const { user } = useUser();
|
||||
|
||||
const { data: issueActivities, mutate: mutateIssueActivity } = useSWR(
|
||||
workspaceSlug && projectId && issueId ? PROJECT_ISSUES_ACTIVITY(issueId.toString()) : null,
|
||||
workspaceSlug && projectId && issueId
|
||||
? () => issueService.getIssueActivities(workspaceSlug.toString(), projectId.toString(), issueId.toString())
|
||||
: null
|
||||
);
|
||||
|
||||
const handleCommentUpdate = async (comment: any, formData: any) => {
|
||||
if (!workspaceSlug || !projectId || !issueId || !allowed) return;
|
||||
|
||||
await issueCommentService
|
||||
.patchIssueComment(workspaceSlug as string, projectId as string, issueId as string, comment, formData, user)
|
||||
.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 || !allowed) return;
|
||||
|
||||
mutateIssueActivity((prevData: any) => prevData?.filter((p: any) => p.id !== commentId), false);
|
||||
|
||||
await issueCommentService
|
||||
.deleteIssueComment(workspaceSlug as string, projectId as string, issueId as string, commentId, user)
|
||||
.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 || !allowed) return;
|
||||
|
||||
await issueCommentService
|
||||
.createIssueComment(workspaceSlug.toString(), issueDetails.project, issueDetails.id, formData, user)
|
||||
.then(() => {
|
||||
mutate(PROJECT_ISSUES_ACTIVITY(issueDetails.id));
|
||||
})
|
||||
.catch(() =>
|
||||
console.log(
|
||||
"toast",
|
||||
JSON.stringify({
|
||||
type: "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 border-custom-border-200 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" ? (
|
||||
<History className="h-3.5 w-3.5 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 h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="text-gray font-medium"
|
||||
onClick={() => console.log("user", activityItem.actor)}
|
||||
>
|
||||
{activityItem.actor_detail.is_bot
|
||||
? activityItem.actor_detail.first_name
|
||||
: activityItem.actor_detail.display_name}
|
||||
</button>
|
||||
)}{" "}
|
||||
{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}
|
||||
disabled={
|
||||
!allowed || !issueDetails || issueDetails.state === "closed" || issueDetails.state === "archived"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{allowed && (
|
||||
<li>
|
||||
<div className="my-4">
|
||||
<AddComment
|
||||
onSubmit={handleAddComment}
|
||||
disabled={
|
||||
!allowed || !issueDetails || issueDetails.state === "closed" || issueDetails.state === "archived"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -1,208 +0,0 @@
|
||||
import React, { useState, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import useSWR, { mutate } from "swr";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
// services
|
||||
import { IssueAttachmentService } from "services/issue";
|
||||
// fetch key
|
||||
import { ISSUE_ATTACHMENTS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
|
||||
// icons
|
||||
import { FileText, ChevronRight, X, Image as ImageIcon } from "lucide-react";
|
||||
// components
|
||||
import { Label, WebViewModal, DeleteConfirmation } from "components/web-view";
|
||||
// helpers
|
||||
import { getFileName } from "helpers/attachment.helper";
|
||||
// types
|
||||
import type { IIssueAttachment } from "types";
|
||||
|
||||
type Props = {
|
||||
allowed: boolean;
|
||||
};
|
||||
|
||||
const isImage = (fileName: string) => /\.(gif|jpe?g|tiff?|png|webp|bmp)$/i.test(fileName);
|
||||
|
||||
const issueAttachmentService = new IssueAttachmentService();
|
||||
|
||||
export const IssueAttachments: React.FC<Props> = (props) => {
|
||||
const { allowed } = props;
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, issueId } = router.query;
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const [deleteAttachment, setDeleteAttachment] = useState<IIssueAttachment | null>(null);
|
||||
const [attachmentDeleteModal, setAttachmentDeleteModal] = useState<boolean>(false);
|
||||
|
||||
const onDrop = useCallback(
|
||||
(acceptedFiles: File[]) => {
|
||||
if (!acceptedFiles[0] || !workspaceSlug || !allowed) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("asset", acceptedFiles[0]);
|
||||
formData.append(
|
||||
"attributes",
|
||||
JSON.stringify({
|
||||
name: acceptedFiles[0].name,
|
||||
size: acceptedFiles[0].size,
|
||||
})
|
||||
);
|
||||
setIsLoading(true);
|
||||
|
||||
issueAttachmentService
|
||||
.uploadIssueAttachment(workspaceSlug as string, projectId as string, issueId as string, formData)
|
||||
.then((res) => {
|
||||
mutate<IIssueAttachment[]>(
|
||||
ISSUE_ATTACHMENTS(issueId as string),
|
||||
(prevData) => [res, ...(prevData ?? [])],
|
||||
false
|
||||
);
|
||||
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
|
||||
console.log(
|
||||
"toast",
|
||||
JSON.stringify({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: "File added successfully.",
|
||||
})
|
||||
);
|
||||
setIsOpen(false);
|
||||
setIsLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setIsLoading(false);
|
||||
console.log(
|
||||
"toast",
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
title: "error!",
|
||||
message: "Something went wrong. please check file type & size (max 5 MB)",
|
||||
})
|
||||
);
|
||||
});
|
||||
},
|
||||
[issueId, projectId, workspaceSlug, allowed]
|
||||
);
|
||||
|
||||
const handleDeletion = async (assetId: string) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
mutate<IIssueAttachment[]>(
|
||||
ISSUE_ATTACHMENTS(issueId as string),
|
||||
(prevData) => (prevData ?? [])?.filter((p) => p.id !== assetId),
|
||||
false
|
||||
);
|
||||
|
||||
await issueAttachmentService
|
||||
.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,
|
||||
disabled: !allowed || isLoading,
|
||||
});
|
||||
|
||||
const { data: attachments } = useSWR<IIssueAttachment[]>(
|
||||
workspaceSlug && projectId && issueId ? ISSUE_ATTACHMENTS(issueId as string) : null,
|
||||
workspaceSlug && projectId && issueId
|
||||
? () =>
|
||||
issueAttachmentService.getIssueAttachment(workspaceSlug.toString(), projectId.toString(), issueId.toString())
|
||||
: null
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DeleteConfirmation
|
||||
title="Delete Attachment"
|
||||
content={
|
||||
<p className="text-sm text-custom-text-200">
|
||||
Are you sure you want to delete attachment-{" "}
|
||||
<span className="font-bold">{getFileName(deleteAttachment?.attributes?.name ?? "")}</span>? This attachment
|
||||
will be permanently removed. This action cannot be undone.
|
||||
</p>
|
||||
}
|
||||
isOpen={allowed && attachmentDeleteModal}
|
||||
onCancel={() => setAttachmentDeleteModal(false)}
|
||||
onConfirm={() => {
|
||||
if (!deleteAttachment) return;
|
||||
handleDeletion(deleteAttachment.id);
|
||||
setAttachmentDeleteModal(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<WebViewModal isOpen={isOpen} onClose={() => setIsOpen(false)} modalTitle="Insert file">
|
||||
<div className="space-y-6">
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`border-b w-full py-2 text-custom-text-100 px-2 flex justify-between items-center ${
|
||||
!allowed || isLoading ? "cursor-not-allowed" : "cursor-pointer"
|
||||
}`}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
{isLoading ? (
|
||||
<p className="text-center">Uploading...</p>
|
||||
) : (
|
||||
<>
|
||||
<h3 className="text-lg">Upload</h3>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</WebViewModal>
|
||||
|
||||
<Label>Attachments</Label>
|
||||
<div className="mt-1 space-y-[6px]">
|
||||
{attachments?.map((attachment) => (
|
||||
<div
|
||||
key={attachment.id}
|
||||
className="px-3 border border-custom-border-200 rounded-[4px] py-2 flex justify-between items-center bg-custom-background-100"
|
||||
>
|
||||
<Link href={attachment.asset}>
|
||||
<a target="_blank" className="text-custom-text-200 truncate flex items-center">
|
||||
{isImage(attachment.attributes.name) ? (
|
||||
<ImageIcon className="w-5 h-5 mr-2 flex-shrink-0 text-custom-text-400" />
|
||||
) : (
|
||||
<FileText className="w-5 h-5 mr-2 flex-shrink-0 text-custom-text-400" />
|
||||
)}
|
||||
<span className="truncate">{attachment.attributes.name}</span>
|
||||
</a>
|
||||
</Link>
|
||||
{allowed && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setDeleteAttachment(attachment);
|
||||
setAttachmentDeleteModal(true);
|
||||
}}
|
||||
>
|
||||
<X className="w-4 h-4 text-custom-text-400" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
disabled={!allowed}
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="bg-custom-primary-100/10 border border-dotted rounded-[4px] border-custom-primary-100 text-center py-2 w-full text-custom-primary-100"
|
||||
>
|
||||
Click to upload file here
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -1,138 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { mutate } from "swr";
|
||||
// services
|
||||
import { IssueService } from "services/issue";
|
||||
// icons
|
||||
import { Link as LinkIcon, Plus, Pencil, X } from "lucide-react";
|
||||
// components
|
||||
import { Label, WebViewModal, CreateUpdateLinkForm, DeleteConfirmation } from "components/web-view";
|
||||
// ui
|
||||
import { Button } from "@plane/ui";
|
||||
// fetch keys
|
||||
import { ISSUE_DETAILS } from "constants/fetch-keys";
|
||||
// types
|
||||
import type { IIssue } from "types";
|
||||
|
||||
type Props = {
|
||||
allowed: boolean;
|
||||
issueDetails: IIssue;
|
||||
};
|
||||
|
||||
const issueService = new IssueService();
|
||||
|
||||
export const IssueLinks: React.FC<Props> = (props) => {
|
||||
const { issueDetails, allowed } = props;
|
||||
|
||||
const links = issueDetails?.issue_link;
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedLink, setSelectedLink] = useState<string | null>(null);
|
||||
const [deleteSelected, setDeleteSelected] = useState<string | null>(null);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const handleDeleteLink = async (linkId: string) => {
|
||||
if (!workspaceSlug || !projectId || !issueDetails || !allowed) 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 issueService
|
||||
.deleteIssueLink(workspaceSlug as string, projectId as string, issueDetails.id, linkId)
|
||||
.then(() => {
|
||||
mutate(ISSUE_DETAILS(issueDetails.id));
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<WebViewModal
|
||||
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>
|
||||
|
||||
<DeleteConfirmation
|
||||
title="Delete Link"
|
||||
content="Are you sure you want to delete this link?"
|
||||
isOpen={!!deleteSelected}
|
||||
onCancel={() => setDeleteSelected(null)}
|
||||
onConfirm={() => {
|
||||
if (!deleteSelected || !allowed) return;
|
||||
handleDeleteLink(deleteSelected);
|
||||
setDeleteSelected(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Label>Links</Label>
|
||||
<div className="mt-1 space-y-[6px]">
|
||||
{links?.map((link) => (
|
||||
<div
|
||||
key={link.id}
|
||||
className="px-3 border border-custom-border-200 rounded-[4px] py-2 flex justify-between items-center bg-custom-background-100"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
console.log("link", link.url);
|
||||
}}
|
||||
className="text-custom-text-200 truncate"
|
||||
>
|
||||
<span>
|
||||
<LinkIcon className="w-4 h-4 inline-block mr-1" />
|
||||
</span>
|
||||
<span>{link.title || link.metadata?.title || link.url}</span>
|
||||
</button>
|
||||
{allowed && (
|
||||
<div className="flex gap-2 items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsOpen(true);
|
||||
setSelectedLink(link.id);
|
||||
}}
|
||||
>
|
||||
<Pencil className="w-4 h-4 text-custom-text-400" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setDeleteSelected(link.id);
|
||||
}}
|
||||
>
|
||||
<X className="w-4 h-4 text-custom-text-400" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<Button variant="neutral-primary" prependIcon={<Plus />} disabled={!allowed} onClick={() => setIsOpen(true)}>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -1,536 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { mutate } from "swr";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
// hooks
|
||||
import useUser from "hooks/use-user";
|
||||
import { useProjectMyMembership } from "contexts/project-member.context";
|
||||
import useEstimateOption from "hooks/use-estimate-option";
|
||||
// fetch keys
|
||||
import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
|
||||
// icons
|
||||
import { PlayIcon, User, X, CalendarDays, LayoutGrid, Users, CopyPlus } from "lucide-react";
|
||||
//services
|
||||
import { IssueService } from "services/issue";
|
||||
// components
|
||||
import { Button, DiceIcon, BlockedIcon, BlockerIcon, RelatedIcon, ContrastIcon } from "@plane/ui";
|
||||
import {
|
||||
Label,
|
||||
StateSelect,
|
||||
PrioritySelect,
|
||||
AssigneeSelect,
|
||||
EstimateSelect,
|
||||
ParentSelect,
|
||||
BlockerSelect,
|
||||
BlockedBySelect,
|
||||
RelatesSelect,
|
||||
DuplicateSelect,
|
||||
ModuleSelect,
|
||||
CycleSelect,
|
||||
DateSelector,
|
||||
} from "components/web-view";
|
||||
// types
|
||||
import type { IIssue } from "types";
|
||||
|
||||
type Props = {
|
||||
submitChanges: (data: Partial<IIssue>) => Promise<void>;
|
||||
};
|
||||
|
||||
const issueService = new IssueService();
|
||||
|
||||
export const IssuePropertiesDetail: React.FC<Props> = (props) => {
|
||||
const { submitChanges } = props;
|
||||
|
||||
const { watch, control } = useFormContext<IIssue>();
|
||||
|
||||
const blockerIssues = watch("issue_relations")?.filter((i) => i.relation_type === "blocked_by") || [];
|
||||
|
||||
const blockedByIssues = watch("related_issues")?.filter((i) => i.relation_type === "blocked_by");
|
||||
|
||||
const relatedToIssueRelation = [
|
||||
...(watch("related_issues")?.filter((i) => i.relation_type === "relates_to") ?? []),
|
||||
...(watch("issue_relations") ?? [])
|
||||
?.filter((i) => i.relation_type === "relates_to")
|
||||
.map((i) => ({
|
||||
...i,
|
||||
issue_detail: i.issue_detail,
|
||||
related_issue: i.issue_detail?.id,
|
||||
})),
|
||||
];
|
||||
|
||||
const duplicateIssuesRelation = [
|
||||
...(watch("related_issues")?.filter((i) => i.relation_type === "duplicate") ?? []),
|
||||
...(watch("issue_relations") ?? [])
|
||||
?.filter((i) => i.relation_type === "duplicate")
|
||||
.map((i) => ({
|
||||
...i,
|
||||
issue_detail: i.issue_detail,
|
||||
related_issue: i.issue_detail?.id,
|
||||
})),
|
||||
];
|
||||
|
||||
const startDate = watch("start_date");
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, issueId } = router.query;
|
||||
|
||||
const isArchive = Boolean(router.query.archive);
|
||||
|
||||
const { memberRole } = useProjectMyMembership();
|
||||
|
||||
const { user } = useUser();
|
||||
|
||||
const [isViewAllOpen, setIsViewAllOpen] = useState(false);
|
||||
|
||||
const { isEstimateActive } = useEstimateOption();
|
||||
|
||||
return (
|
||||
<div className="space-y-[6px]">
|
||||
<Label>Details</Label>
|
||||
<div>
|
||||
<div className="border border-custom-border-200 rounded-[4px] p-2 flex justify-between items-center">
|
||||
<div className="flex items-center gap-1">
|
||||
<LayoutGrid className="h-4 w-4 flex-shrink-0 text-custom-text-400" />
|
||||
<span className="text-sm text-custom-text-400">State</span>
|
||||
</div>
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="state"
|
||||
render={({ field: { value } }) => (
|
||||
<StateSelect
|
||||
value={value}
|
||||
disabled={isArchive || memberRole.isGuest || memberRole.isViewer}
|
||||
onChange={(val: string) => submitChanges({ state: val })}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="border border-custom-border-200 rounded-[4px] p-2 flex justify-between items-center">
|
||||
<div className="flex items-center gap-1 text-custom-text-400">
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M13.5862 14.5239C13.3459 14.5239 13.1416 14.4398 12.9733 14.2715C12.805 14.1032 12.7209 13.8989 12.7209 13.6585V3.76429C12.7209 3.52391 12.805 3.31958 12.9733 3.15132C13.1416 2.98306 13.3459 2.89893 13.5862 2.89893C13.8266 2.89893 14.031 2.98306 14.1992 3.15132C14.3675 3.31958 14.4516 3.52391 14.4516 3.76429V13.6585C14.4516 13.8989 14.3675 14.1032 14.1992 14.2715C14.031 14.4398 13.8266 14.5239 13.5862 14.5239ZM5.1629 14.5239C5.04676 14.5239 4.93557 14.5018 4.82932 14.4576C4.72308 14.4133 4.63006 14.3513 4.55025 14.2715C4.47045 14.1917 4.40843 14.0986 4.36419 13.9922C4.31996 13.8858 4.29785 13.7746 4.29785 13.6585V11.2643C4.29785 11.0239 4.38198 10.8196 4.55025 10.6513C4.71851 10.4831 4.92283 10.3989 5.16322 10.3989C5.40359 10.3989 5.60791 10.4831 5.77618 10.6513C5.94445 10.8196 6.02859 11.0239 6.02859 11.2643V13.6585C6.02859 13.7746 6.00647 13.8858 5.96223 13.9922C5.91801 14.0986 5.85599 14.1917 5.77618 14.2715C5.69638 14.3513 5.60325 14.4133 5.49678 14.4576C5.39033 14.5018 5.27904 14.5239 5.1629 14.5239ZM9.37473 14.5239C9.13436 14.5239 8.93003 14.4398 8.76176 14.2715C8.59349 14.1032 8.50936 13.8989 8.50936 13.6585V7.5143C8.50936 7.27391 8.59349 7.06958 8.76176 6.90132C8.93003 6.73306 9.13436 6.64893 9.37473 6.64893C9.61511 6.64893 9.81943 6.73306 9.98771 6.90132C10.156 7.06958 10.2401 7.27391 10.2401 7.5143V13.6585C10.2401 13.8989 10.156 14.1032 9.98771 14.2715C9.81943 14.4398 9.61511 14.5239 9.37473 14.5239Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<span className="text-sm text-custom-text-400">Priority</span>
|
||||
</div>
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="priority"
|
||||
render={({ field: { value } }) => (
|
||||
<PrioritySelect
|
||||
value={value}
|
||||
disabled={isArchive || memberRole.isGuest || memberRole.isViewer}
|
||||
onChange={(val) => submitChanges({ priority: val })}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="border border-custom-border-200 rounded-[4px] p-2 flex justify-between items-center">
|
||||
<div className="flex items-center gap-1">
|
||||
<Users className="h-4 w-4 flex-shrink-0 text-custom-text-400" />
|
||||
<span className="text-sm text-custom-text-400">Assignee</span>
|
||||
</div>
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="assignees"
|
||||
render={({ field: { value } }) => (
|
||||
<AssigneeSelect
|
||||
value={value}
|
||||
disabled={isArchive || memberRole.isGuest || memberRole.isViewer}
|
||||
onChange={(val: string) => {
|
||||
const assignees = value?.includes(val) ? value?.filter((i) => i !== val) : [...(value ?? []), val];
|
||||
|
||||
submitChanges({ assignees: assignees });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isViewAllOpen && (
|
||||
<>
|
||||
{isEstimateActive && (
|
||||
<div>
|
||||
<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 text-custom-text-400" />
|
||||
<span className="text-sm text-custom-text-400">Estimate</span>
|
||||
</div>
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="estimate_point"
|
||||
render={({ field: { value } }) => (
|
||||
<EstimateSelect
|
||||
value={value}
|
||||
disabled={isArchive || memberRole.isGuest || memberRole.isViewer}
|
||||
onChange={(val) => submitChanges({ estimate_point: val })}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="border border-custom-border-200 rounded-[4px] p-2 flex justify-between items-center">
|
||||
<div className="flex items-center gap-1">
|
||||
<User className="h-4 w-4 flex-shrink-0 text-custom-text-400" />
|
||||
<span className="text-sm text-custom-text-400">Parent</span>
|
||||
</div>
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="parent"
|
||||
render={({ field: { value } }) => (
|
||||
<ParentSelect
|
||||
value={value}
|
||||
disabled={isArchive || memberRole.isGuest || memberRole.isViewer}
|
||||
onChange={(val) => submitChanges({ parent: val })}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* blocker to / blocking */}
|
||||
<div>
|
||||
<div className="border border-custom-border-200 rounded-[4px] p-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-1">
|
||||
<BlockerIcon height={16} width={16} />
|
||||
<span className="text-sm text-custom-text-400">Blocking</span>
|
||||
</div>
|
||||
<div>
|
||||
<BlockerSelect disabled={isArchive || memberRole.isGuest || memberRole.isViewer} />
|
||||
</div>
|
||||
</div>
|
||||
{blockerIssues &&
|
||||
blockerIssues.map((relation) => (
|
||||
<div
|
||||
key={relation.issue_detail?.id}
|
||||
className="group inline-flex mr-1 cursor-pointer items-center gap-1 rounded-2xl border border-custom-border-200 px-1.5 py-0.5 text-xs text-yellow-500 duration-300 hover:border-yellow-500/20 hover:bg-yellow-500/20"
|
||||
>
|
||||
<span
|
||||
onClick={() =>
|
||||
console.log(
|
||||
"issue",
|
||||
JSON.stringify({
|
||||
issue_id: relation.issue_detail?.id,
|
||||
project_id: relation.issue_detail?.project_detail.id,
|
||||
issue_identifier: `${relation.issue_detail?.project_detail.identifier}-${relation.issue_detail?.sequence_id}`,
|
||||
})
|
||||
)
|
||||
}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<BlockerIcon height={10} width={10} />
|
||||
{`${relation.issue_detail?.project_detail.identifier}-${relation.issue_detail?.sequence_id}`}
|
||||
</span>
|
||||
{!isArchive && (
|
||||
<button
|
||||
type="button"
|
||||
className="duration-300"
|
||||
onClick={() => {
|
||||
if (memberRole.isGuest || memberRole.isViewer || !user) return;
|
||||
|
||||
issueService
|
||||
.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));
|
||||
});
|
||||
}}
|
||||
>
|
||||
<X className="h-2 w-2" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* blocked by */}
|
||||
<div>
|
||||
<div className="border border-custom-border-200 rounded-[4px] p-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-1">
|
||||
<BlockedIcon height={16} width={16} />
|
||||
<span className="text-sm text-custom-text-400">Blocked by</span>
|
||||
</div>
|
||||
<div>
|
||||
<BlockedBySelect disabled={isArchive || memberRole.isGuest || memberRole.isViewer} />
|
||||
</div>
|
||||
</div>
|
||||
{blockedByIssues &&
|
||||
blockedByIssues.map((relation) => (
|
||||
<div
|
||||
key={relation.issue_detail?.id}
|
||||
className="group inline-flex mr-1 cursor-pointer items-center gap-1 rounded-2xl border border-custom-border-200 px-1.5 py-0.5 text-xs text-red-500 duration-300 hover:border-red-500/20 hover:bg-red-500/20"
|
||||
>
|
||||
<span
|
||||
onClick={() =>
|
||||
console.log(
|
||||
"issue",
|
||||
JSON.stringify({
|
||||
issue_id: relation.issue_detail?.id,
|
||||
project_id: relation.issue_detail?.project_detail.id,
|
||||
issue_identifier: `${relation.issue_detail?.project_detail.identifier}-${relation.issue_detail?.sequence_id}`,
|
||||
})
|
||||
)
|
||||
}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<BlockedIcon height={10} width={10} />
|
||||
{`${relation?.issue_detail?.project_detail?.identifier}-${relation?.issue_detail?.sequence_id}`}
|
||||
</span>
|
||||
{!isArchive && !(memberRole.isGuest || memberRole.isViewer) && (
|
||||
<button
|
||||
type="button"
|
||||
className="duration-300"
|
||||
onClick={() => {
|
||||
if (memberRole.isGuest || memberRole.isViewer || !user) return;
|
||||
|
||||
issueService
|
||||
.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));
|
||||
});
|
||||
}}
|
||||
>
|
||||
<X className="h-2 w-2" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* duplicate */}
|
||||
<div>
|
||||
<div className="border border-custom-border-200 rounded-[4px] p-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-1">
|
||||
<CopyPlus height={16} width={16} className="text-custom-text-400" />
|
||||
<span className="text-sm text-custom-text-400">Duplicate</span>
|
||||
</div>
|
||||
<div>
|
||||
<DuplicateSelect disabled={isArchive || memberRole.isGuest || memberRole.isViewer} />
|
||||
</div>
|
||||
</div>
|
||||
{duplicateIssuesRelation &&
|
||||
duplicateIssuesRelation.map((relation) => (
|
||||
<div
|
||||
key={relation.issue_detail?.id}
|
||||
className="group inline-flex mr-1 cursor-pointer items-center gap-1 rounded-2xl border border-custom-border-200 px-1.5 py-0.5 text-xs text-red-500 duration-300 hover:border-red-500/20 hover:bg-red-500/20"
|
||||
>
|
||||
<span
|
||||
onClick={() =>
|
||||
console.log(
|
||||
"issue",
|
||||
JSON.stringify({
|
||||
issue_id: relation.issue_detail?.id,
|
||||
project_id: relation.issue_detail?.project_detail.id,
|
||||
issue_identifier: `${relation.issue_detail?.project_detail.identifier}-${relation.issue_detail?.sequence_id}`,
|
||||
})
|
||||
)
|
||||
}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<CopyPlus height={10} width={10} />
|
||||
{`${relation?.issue_detail?.project_detail?.identifier}-${relation?.issue_detail?.sequence_id}`}
|
||||
</span>
|
||||
{!isArchive && !(memberRole.isGuest || memberRole.isViewer) && (
|
||||
<button
|
||||
type="button"
|
||||
className="duration-300"
|
||||
onClick={() => {
|
||||
if (memberRole.isGuest || memberRole.isViewer || !user) return;
|
||||
|
||||
issueService
|
||||
.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));
|
||||
});
|
||||
}}
|
||||
>
|
||||
<X className="h-2 w-2" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* relates to */}
|
||||
<div>
|
||||
<div className="border border-custom-border-200 rounded-[4px] p-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-1">
|
||||
<RelatedIcon height={16} width={16} color="rgb(var(--color-text-400))" />
|
||||
<span className="text-sm text-custom-text-400">Relates To</span>
|
||||
</div>
|
||||
<div>
|
||||
<RelatesSelect disabled={isArchive || memberRole.isGuest || memberRole.isViewer} />
|
||||
</div>
|
||||
</div>
|
||||
{relatedToIssueRelation &&
|
||||
relatedToIssueRelation.map((relation) => (
|
||||
<div
|
||||
key={relation.issue_detail?.id}
|
||||
className="group inline-flex mr-1 cursor-pointer items-center gap-1 rounded-2xl border border-custom-border-200 px-1.5 py-0.5 text-xs text-red-500 duration-300 hover:border-red-500/20 hover:bg-red-500/20"
|
||||
>
|
||||
<span
|
||||
onClick={() =>
|
||||
console.log(
|
||||
"issue",
|
||||
JSON.stringify({
|
||||
issue_id: relation.issue_detail?.id,
|
||||
project_id: relation.issue_detail?.project_detail.id,
|
||||
issue_identifier: `${relation.issue_detail?.project_detail.identifier}-${relation.issue_detail?.sequence_id}`,
|
||||
})
|
||||
)
|
||||
}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<RelatedIcon height={10} width={10} />
|
||||
{`${relation?.issue_detail?.project_detail?.identifier}-${relation?.issue_detail?.sequence_id}`}
|
||||
</span>
|
||||
{!isArchive && !(memberRole.isGuest || memberRole.isViewer) && (
|
||||
<button
|
||||
type="button"
|
||||
className="duration-300"
|
||||
onClick={() => {
|
||||
if (memberRole.isGuest || memberRole.isViewer || !user) return;
|
||||
|
||||
issueService
|
||||
.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));
|
||||
});
|
||||
}}
|
||||
>
|
||||
<X className="h-2 w-2" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="border border-custom-border-200 rounded-[4px] p-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-1">
|
||||
<CalendarDays className="w-4 h-4 text-custom-text-400" />
|
||||
<span className="text-sm text-custom-text-400">Due date</span>
|
||||
</div>
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="target_date"
|
||||
render={({ field: { value } }) => (
|
||||
<DateSelector
|
||||
placeholderText="Due date"
|
||||
value={value ?? undefined}
|
||||
wrapperClassName="w-full"
|
||||
disabled={isArchive || memberRole.isGuest || memberRole.isViewer}
|
||||
onChange={(val) =>
|
||||
submitChanges({
|
||||
target_date: val,
|
||||
})
|
||||
}
|
||||
className="border-transparent !shadow-none !w-[6.75rem]"
|
||||
minDate={startDate ? new Date(startDate) : undefined}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="border border-custom-border-200 rounded-[4px] p-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-1">
|
||||
<DiceIcon className="h-4 w-4 flex-shrink-0 text-custom-text-400" />
|
||||
<span className="text-sm text-custom-text-400">Module</span>
|
||||
</div>
|
||||
<div>
|
||||
<ModuleSelect
|
||||
value={watch("issue_module")}
|
||||
disabled={isArchive || memberRole.isGuest || memberRole.isViewer}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="border border-custom-border-200 rounded-[4px] p-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-1">
|
||||
<ContrastIcon color="rgba(var(--color-text-400))" className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="text-sm text-custom-text-400">Cycle</span>
|
||||
</div>
|
||||
<div>
|
||||
<CycleSelect
|
||||
value={watch("issue_cycle")}
|
||||
disabled={isArchive || memberRole.isGuest || memberRole.isViewer}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div>
|
||||
<Button type="button" onClick={() => setIsViewAllOpen((prev) => !prev)}>
|
||||
{isViewAllOpen ? "View less" : "View all"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -1,153 +0,0 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { Controller } from "react-hook-form";
|
||||
|
||||
// services
|
||||
import { FileService } from "services/file.service";
|
||||
// hooks
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import useReloadConfirmations from "hooks/use-reload-confirmation";
|
||||
// ui
|
||||
import { TextArea } from "@plane/ui";
|
||||
// components
|
||||
import { RichTextEditor } from "@plane/rich-text-editor";
|
||||
import { Label } from "components/web-view";
|
||||
// types
|
||||
import type { IIssue } from "types";
|
||||
|
||||
type Props = {
|
||||
isAllowed: boolean;
|
||||
issueDetails: IIssue;
|
||||
submitChanges: (data: Partial<IIssue>) => Promise<void>;
|
||||
register: any;
|
||||
control: any;
|
||||
watch: any;
|
||||
handleSubmit: any;
|
||||
};
|
||||
|
||||
// services
|
||||
const fileService = new FileService();
|
||||
|
||||
export const IssueWebViewForm: React.FC<Props> = (props) => {
|
||||
const { isAllowed, issueDetails, submitChanges, control, watch, handleSubmit } = props;
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
const [characterLimit, setCharacterLimit] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved");
|
||||
|
||||
const { setShowAlert } = useReloadConfirmations();
|
||||
|
||||
useEffect(() => {
|
||||
if (isSubmitting === "submitted") {
|
||||
setShowAlert(false);
|
||||
setTimeout(async () => {
|
||||
setIsSubmitting("saved");
|
||||
}, 2000);
|
||||
} else if (isSubmitting === "submitting") {
|
||||
setShowAlert(true);
|
||||
}
|
||||
}, [isSubmitting, setShowAlert]);
|
||||
|
||||
const debouncedTitleSave = useDebouncedCallback(async () => {
|
||||
setTimeout(async () => {
|
||||
handleSubmit(handleDescriptionFormSubmit)().finally(() => setIsSubmitting("submitted"));
|
||||
}, 500);
|
||||
}, 1000);
|
||||
|
||||
const handleDescriptionFormSubmit = useCallback(
|
||||
async (formData: Partial<IIssue>) => {
|
||||
if (!formData?.name || formData?.name.length === 0 || formData?.name.length > 255) return;
|
||||
|
||||
await submitChanges({
|
||||
name: formData.name ?? "",
|
||||
description_html: formData.description_html ?? "<p></p>",
|
||||
});
|
||||
},
|
||||
[submitChanges]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col">
|
||||
<Label>Title</Label>
|
||||
<div className="relative">
|
||||
{isAllowed ? (
|
||||
<Controller
|
||||
name="name"
|
||||
control={control}
|
||||
render={({ field: { value } }) => (
|
||||
<TextArea
|
||||
id="name"
|
||||
name="name"
|
||||
value={value}
|
||||
placeholder="Enter issue name"
|
||||
onFocus={() => setCharacterLimit(true)}
|
||||
onChange={() => {
|
||||
setCharacterLimit(false);
|
||||
setIsSubmitting("submitting");
|
||||
debouncedTitleSave();
|
||||
}}
|
||||
required
|
||||
className="min-h-10 block w-full resize-none overflow-hidden rounded border bg-transparent px-3 py-2 text-xl outline-none ring-0 focus:ring-1 focus:ring-custom-primary"
|
||||
role="textbox"
|
||||
disabled={!isAllowed}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<h4 className="break-words text-2xl font-semibold">{issueDetails?.name}</h4>
|
||||
)}
|
||||
{characterLimit && isAllowed && (
|
||||
<div className="pointer-events-none absolute bottom-1 right-1 z-[2] rounded bg-custom-background-100 text-custom-text-200 p-0.5 text-xs">
|
||||
<span className={`${watch("name").length === 0 || watch("name").length > 255 ? "text-red-500" : ""}`}>
|
||||
{watch("name").length}
|
||||
</span>
|
||||
/255
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Description</Label>
|
||||
<div className="relative">
|
||||
<Controller
|
||||
name="description_html"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => {
|
||||
if(value==null)return <></>;
|
||||
return <RichTextEditor
|
||||
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
|
||||
deleteFile={fileService.deleteImage}
|
||||
value={
|
||||
!value || value === "" || (typeof value === "object" && Object.keys(value).length === 0)
|
||||
? "<p></p>"
|
||||
: value
|
||||
}
|
||||
debouncedUpdatesEnabled
|
||||
setShouldShowAlert={setShowAlert}
|
||||
setIsSubmitting={setIsSubmitting}
|
||||
customClassName={isAllowed ? "min-h-[150px] shadow-sm" : "!p-0 !pt-2 text-custom-text-200"}
|
||||
noBorder={!isAllowed}
|
||||
onChange={(description: Object, description_html: string) => {
|
||||
setShowAlert(true);
|
||||
setIsSubmitting("submitting");
|
||||
onChange(description_html);
|
||||
handleSubmit(handleDescriptionFormSubmit)().finally(() => setIsSubmitting("submitted"));
|
||||
}}
|
||||
/>
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={`absolute right-5 bottom-5 text-xs text-custom-text-200 border border-custom-border-400 rounded-xl w-[6.5rem] py-1 z-10 flex items-center justify-center ${
|
||||
isSubmitting === "saved" ? "fadeOut" : "fadeIn"
|
||||
}`}
|
||||
>
|
||||
{isSubmitting === "submitting" ? "Saving..." : "Saved"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
@ -1,170 +0,0 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// hooks
|
||||
import useUser from "hooks/use-user";
|
||||
import useDebounce from "hooks/use-debounce";
|
||||
// services
|
||||
import { ProjectService } from "services/project";
|
||||
// components
|
||||
import { WebViewModal } from "components/web-view";
|
||||
import { LayersIcon } from "@plane/ui";
|
||||
import { Loader, PrimaryButton, SecondaryButton, ToggleSwitch } from "components/ui";
|
||||
// types
|
||||
import { ISearchIssueResponse, TProjectIssuesSearchParams } from "types";
|
||||
|
||||
type IssuesSelectBottomSheetProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: ISearchIssueResponse[]) => Promise<void>;
|
||||
searchParams: Partial<TProjectIssuesSearchParams>;
|
||||
singleSelect?: boolean;
|
||||
};
|
||||
|
||||
// services
|
||||
const projectService = new ProjectService();
|
||||
|
||||
export const IssuesSelectBottomSheet: React.FC<IssuesSelectBottomSheetProps> = (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<ISearchIssueResponse[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [selectedIssues, setSelectedIssues] = useState<ISearchIssueResponse[]>([]);
|
||||
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 (
|
||||
<WebViewModal isOpen={isOpen} onClose={handleClose} modalTitle="Select issue">
|
||||
{!isSearching && issues.length === 0 && searchTerm !== "" && debouncedSearchTerm !== "" && (
|
||||
<div className="flex flex-col items-center justify-center gap-4 px-3 py-8 text-center">
|
||||
<LayersIcon height="52" width="52" />
|
||||
<h3 className="text-custom-text-200">
|
||||
No issues found. Create a new issue with{" "}
|
||||
<pre className="inline rounded bg-custom-background-80 px-2 py-1 text-sm">C</pre>.
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`flex-shrink-0 flex items-center gap-1 text-xs pb-3 cursor-pointer ${
|
||||
isWorkspaceLevel ? "text-custom-text-100" : "text-custom-text-200"
|
||||
}`}
|
||||
>
|
||||
<ToggleSwitch value={isWorkspaceLevel} onChange={() => setIsWorkspaceLevel((prevData) => !prevData)} />
|
||||
<button type="button" onClick={() => setIsWorkspaceLevel((prevData) => !prevData)} className="flex-shrink-0">
|
||||
Workspace Level
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isSearching && (
|
||||
<Loader className="space-y-3 p-3">
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
</Loader>
|
||||
)}
|
||||
|
||||
{!isSearching && (
|
||||
<WebViewModal.Options
|
||||
options={issues.map((issue) => ({
|
||||
value: issue.id,
|
||||
label: (
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: issue.state__color,
|
||||
}}
|
||||
/>
|
||||
<span className="flex-shrink-0 text-xs">
|
||||
{issue.project__identifier}-{issue.sequence_id}
|
||||
</span>
|
||||
{issue.name}
|
||||
</div>
|
||||
),
|
||||
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 && (
|
||||
<WebViewModal.Footer className="flex items-center justify-end gap-2">
|
||||
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
|
||||
<PrimaryButton
|
||||
onClick={() => {
|
||||
handleSelect(selectedIssues);
|
||||
}}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Adding..." : "Add selected issues"}
|
||||
</PrimaryButton>
|
||||
</WebViewModal.Footer>
|
||||
)}
|
||||
</WebViewModal>
|
||||
);
|
||||
};
|
@ -1,7 +0,0 @@
|
||||
export const Label: React.FC<
|
||||
React.DetailedHTMLProps<React.LabelHTMLAttributes<HTMLLabelElement>, HTMLLabelElement>
|
||||
> = (props) => (
|
||||
<label className="block text-base font-medium mb-[5px]" {...props}>
|
||||
{props.children}
|
||||
</label>
|
||||
);
|
@ -1,86 +0,0 @@
|
||||
// react
|
||||
import React, { useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import useSWR from "swr";
|
||||
|
||||
// icons
|
||||
import { ChevronDown } from "lucide-react";
|
||||
// services
|
||||
import { ProjectService } from "services/project";
|
||||
// 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;
|
||||
};
|
||||
|
||||
// services
|
||||
const projectService = new ProjectService();
|
||||
|
||||
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.fetchProjectMembers(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
const selectedAssignees = members?.filter((member) => value?.includes(member.member.id));
|
||||
|
||||
return (
|
||||
<>
|
||||
<WebViewModal
|
||||
isOpen={isOpen}
|
||||
modalTitle="Select assignees"
|
||||
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"}
|
||||
>
|
||||
{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-200 text-xs">{selectedAssignees?.length} Assignees</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-custom-text-200">No assignees</span>
|
||||
)}
|
||||
<ChevronDown className="w-4 h-4 text-custom-text-200" />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
@ -1,112 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { mutate } from "swr";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
|
||||
// services
|
||||
import { IssueService } from "services/issue";
|
||||
// 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;
|
||||
};
|
||||
|
||||
// services
|
||||
const issueService = new IssueService();
|
||||
|
||||
export const BlockedBySelect: React.FC<Props> = (props) => {
|
||||
const { disabled = false } = props;
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, issueId } = router.query;
|
||||
|
||||
const { watch } = useFormContext<IIssue>();
|
||||
|
||||
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 issueService
|
||||
.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<IIssue>(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 (
|
||||
<>
|
||||
<IssuesSelectBottomSheet
|
||||
isOpen={isBlockedModalOpen}
|
||||
onSubmit={onSubmit}
|
||||
onClose={() => setIsBlockedModalOpen(false)}
|
||||
searchParams={{ issue_relation: true }}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setIsBlockedModalOpen(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"
|
||||
}
|
||||
>
|
||||
<span className="text-custom-text-200">Select issue</span>
|
||||
<ChevronDown className="w-4 h-4 text-custom-text-200" />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
@ -1,122 +0,0 @@
|
||||
// react
|
||||
import React, { useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { mutate } from "swr";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
|
||||
// services
|
||||
import { IssueService } from "services/issue";
|
||||
// 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;
|
||||
};
|
||||
|
||||
// services
|
||||
const issueService = new IssueService();
|
||||
|
||||
export const BlockerSelect: React.FC<Props> = (props) => {
|
||||
const { disabled = false } = props;
|
||||
|
||||
const [isBlockerModalOpen, setIsBlockerModalOpen] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, issueId } = router.query;
|
||||
|
||||
const { watch } = useFormContext<IIssue>();
|
||||
|
||||
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") || [];
|
||||
|
||||
issueService
|
||||
.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 (
|
||||
<>
|
||||
<IssuesSelectBottomSheet
|
||||
isOpen={isBlockerModalOpen}
|
||||
onClose={() => setIsBlockerModalOpen(false)}
|
||||
onSubmit={onSubmit}
|
||||
searchParams={{ issue_relation: true }}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setIsBlockerModalOpen(true)}
|
||||
className={"relative w-full px-2.5 py-0.5 text-base flex justify-between items-center gap-0.5"}
|
||||
>
|
||||
<span className="text-custom-text-200">Select issue</span>
|
||||
<ChevronDown className="w-4 h-4 text-custom-text-200" />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
@ -1,142 +0,0 @@
|
||||
// react
|
||||
import React, { useState } from "react";
|
||||
|
||||
// next
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// swr
|
||||
import useSWR, { mutate } from "swr";
|
||||
|
||||
// services
|
||||
import { IssueService } from "services/issue";
|
||||
import { CycleService } from "services/cycle.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, IIssue, IIssueCycle } from "types";
|
||||
|
||||
type Props = {
|
||||
disabled?: boolean;
|
||||
value?: IIssueCycle | null;
|
||||
};
|
||||
|
||||
// services
|
||||
const issueService = new IssueService();
|
||||
const cycleService = new CycleService();
|
||||
|
||||
export const CycleSelect: React.FC<Props> = (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
|
||||
? () => cycleService.getCyclesWithParams(workspaceSlug as string, projectId as string, "incomplete")
|
||||
: null
|
||||
);
|
||||
|
||||
const { user } = useUser();
|
||||
|
||||
const handleCycleChange = (cycleDetails: ICycle) => {
|
||||
if (!workspaceSlug || !projectId || !issueId || disabled) return;
|
||||
|
||||
issueService
|
||||
.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<IIssue>(
|
||||
ISSUE_DETAILS(issueId as string),
|
||||
(prev) => {
|
||||
if (!prev) return prev;
|
||||
|
||||
return {
|
||||
...prev,
|
||||
issue_cycle: null,
|
||||
};
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
issueService
|
||||
.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 (
|
||||
<>
|
||||
<WebViewModal
|
||||
isOpen={isBottomSheetOpen}
|
||||
onClose={() => setIsBottomSheetOpen(false)}
|
||||
modalTitle="Select Cycle"
|
||||
>
|
||||
<WebViewModal.Options
|
||||
options={[
|
||||
...(incompleteCycles ?? []).map((cycle) => ({
|
||||
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",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</WebViewModal>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setIsBottomSheetOpen(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"
|
||||
}
|
||||
>
|
||||
<span className="text-custom-text-200">{value?.cycle_detail.name ?? "Select cycle"}</span>
|
||||
<ChevronDown className="w-4 h-4 text-custom-text-200" />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
@ -1,101 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { mutate } from "swr";
|
||||
|
||||
// services
|
||||
import { IssueService } from "services/issue";
|
||||
// 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;
|
||||
};
|
||||
|
||||
// services
|
||||
const issueService = new IssueService();
|
||||
|
||||
export const DuplicateSelect: React.FC<Props> = (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;
|
||||
|
||||
issueService
|
||||
.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 (
|
||||
<>
|
||||
<IssuesSelectBottomSheet
|
||||
isOpen={isBottomSheetOpen}
|
||||
onClose={() => setIsBottomSheetOpen(false)}
|
||||
onSubmit={onSubmit}
|
||||
searchParams={{ issue_relation: true }}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setIsBottomSheetOpen(true)}
|
||||
className={"relative w-full px-2.5 py-0.5 text-base flex justify-between items-center gap-0.5"}
|
||||
>
|
||||
<span className="text-custom-text-200">Select issue</span>
|
||||
<ChevronDown className="w-4 h-4 text-custom-text-200" />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
@ -1,83 +0,0 @@
|
||||
// react
|
||||
import React, { useState } from "react";
|
||||
|
||||
// icons
|
||||
import { ChevronDown, 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"
|
||||
)}
|
||||
<ChevronDown className="w-5 h-5" />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
@ -1,127 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import useSWR, { mutate } from "swr";
|
||||
|
||||
// services
|
||||
import { ModuleService } from "services/module.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;
|
||||
};
|
||||
|
||||
// services
|
||||
const moduleService = new ModuleService();
|
||||
|
||||
export const ModuleSelect: React.FC<Props> = (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 ? () => moduleService.getModules(workspaceSlug as string, projectId as string) : null
|
||||
);
|
||||
|
||||
const { user } = useUser();
|
||||
|
||||
const handleModuleChange = (moduleDetail: IModule) => {
|
||||
if (!workspaceSlug || !projectId || !issueId || disabled) return;
|
||||
|
||||
moduleService
|
||||
.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
|
||||
);
|
||||
|
||||
moduleService
|
||||
.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 (
|
||||
<>
|
||||
<WebViewModal isOpen={isBottomSheetOpen} onClose={() => setIsBottomSheetOpen(false)} modalTitle="Select Module">
|
||||
<WebViewModal.Options
|
||||
options={[
|
||||
...(modules ?? []).map((mod) => ({
|
||||
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",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</WebViewModal>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setIsBottomSheetOpen(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"
|
||||
}
|
||||
>
|
||||
<span className="text-custom-text-200">{value?.module_detail?.name ?? "Select module"}</span>
|
||||
<ChevronDown className="w-4 h-4 text-custom-text-200" />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
@ -1,101 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import useSWR from "swr";
|
||||
|
||||
// services
|
||||
import { IssueService } from "services/issue";
|
||||
// constants
|
||||
import { ISSUE_DETAILS } from "constants/fetch-keys";
|
||||
// components
|
||||
import { IssuesSelectBottomSheet } from "components/web-view";
|
||||
// icons
|
||||
import { ChevronDown, X } from "lucide-react";
|
||||
// types
|
||||
import { ISearchIssueResponse } from "types";
|
||||
|
||||
type Props = {
|
||||
value: string | null;
|
||||
onChange: (value: any) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
const issueService = new IssueService();
|
||||
|
||||
export const ParentSelect: React.FC<Props> = (props) => {
|
||||
const { onChange, disabled = false } = props;
|
||||
|
||||
const [isParentModalOpen, setIsParentModalOpen] = useState(false);
|
||||
const [selectedParentIssue, setSelectedParentIssue] = useState<ISearchIssueResponse | null>(null);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, issueId } = router.query;
|
||||
|
||||
const { data: issueDetails } = useSWR(
|
||||
workspaceSlug && projectId && issueId ? ISSUE_DETAILS(issueId.toString()) : null,
|
||||
workspaceSlug && projectId && issueId
|
||||
? () => issueService.retrieve(workspaceSlug.toString(), projectId.toString(), issueId.toString())
|
||||
: 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 (
|
||||
<>
|
||||
<IssuesSelectBottomSheet
|
||||
isOpen={isParentModalOpen}
|
||||
onClose={() => setIsParentModalOpen(false)}
|
||||
singleSelect
|
||||
onSubmit={async (issues) => {
|
||||
if (disabled) return;
|
||||
const issue = issues[0];
|
||||
onChange(issue.id);
|
||||
setSelectedParentIssue(issue);
|
||||
}}
|
||||
searchParams={{
|
||||
parent: true,
|
||||
issue_id: issueId as string,
|
||||
}}
|
||||
/>
|
||||
|
||||
{parentIssueResult ? (
|
||||
<div className="flex justify-between items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsParentModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<span>{parentIssueResult}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className="pr-2.5"
|
||||
onClick={() => {
|
||||
onChange(null);
|
||||
setSelectedParentIssue(null);
|
||||
}}
|
||||
>
|
||||
<X className="w-4 h-4 text-custom-text-200" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
setIsParentModalOpen(true);
|
||||
}}
|
||||
className={"relative w-full px-2.5 py-0.5 text-base flex justify-between items-center gap-0.5"}
|
||||
>
|
||||
<span className="text-custom-text-200">Select issue</span>
|
||||
<ChevronDown className="w-4 h-4 text-custom-text-200" />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
@ -1,83 +0,0 @@
|
||||
// react
|
||||
import React, { useState } from "react";
|
||||
|
||||
// icons
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
// constants
|
||||
import { PRIORITIES } from "constants/project";
|
||||
|
||||
// components
|
||||
import { PriorityIcon } from "@plane/ui";
|
||||
import { WebViewModal } from "./web-view-modal";
|
||||
|
||||
// helpers
|
||||
import { capitalizeFirstLetter } from "helpers/string.helper";
|
||||
// types
|
||||
import { TIssuePriorities } from "types";
|
||||
|
||||
type Props = {
|
||||
value: any;
|
||||
onChange: (value: TIssuePriorities) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const PrioritySelect: React.FC<Props> = (props) => {
|
||||
const { value, onChange, disabled = false } = props;
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<WebViewModal
|
||||
isOpen={isOpen}
|
||||
modalTitle="Select priority"
|
||||
onClose={() => {
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
<WebViewModal.Options
|
||||
options={
|
||||
PRIORITIES?.map((priority) => ({
|
||||
label: capitalizeFirstLetter(priority),
|
||||
value: priority,
|
||||
checked: priority === value,
|
||||
onClick: () => {
|
||||
setIsOpen(false);
|
||||
if (disabled) return;
|
||||
onChange(priority);
|
||||
},
|
||||
icon: (
|
||||
<span
|
||||
className={`text-left text-xs capitalize rounded ${
|
||||
priority === "urgent"
|
||||
? "border-red-500/20 text-red-500"
|
||||
: priority === "high"
|
||||
? "border-orange-500/20 text-orange-500"
|
||||
: priority === "medium"
|
||||
? "border-yellow-500/20 text-yellow-500"
|
||||
: priority === "low"
|
||||
? "border-green-500/20 text-green-500"
|
||||
: "border-custom-border-200 text-custom-text-200"
|
||||
}`}
|
||||
>
|
||||
<PriorityIcon priority={priority} className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
),
|
||||
})) || []
|
||||
}
|
||||
/>
|
||||
</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"}
|
||||
>
|
||||
<span className="text-custom-text-200">{value ? capitalizeFirstLetter(value) : "None"}</span>
|
||||
<ChevronDown className="w-4 h-4 text-custom-text-200" />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
@ -1,101 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { mutate } from "swr";
|
||||
|
||||
// services
|
||||
import { IssueService } from "services/issue";
|
||||
// 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;
|
||||
};
|
||||
|
||||
// services
|
||||
const issueService = new IssueService();
|
||||
|
||||
export const RelatesSelect: React.FC<Props> = (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,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
issueService
|
||||
.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 (
|
||||
<>
|
||||
<IssuesSelectBottomSheet
|
||||
isOpen={isBottomSheetOpen}
|
||||
onClose={() => setIsBottomSheetOpen(false)}
|
||||
onSubmit={onSubmit}
|
||||
searchParams={{ issue_relation: true }}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setIsBottomSheetOpen(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"
|
||||
}
|
||||
>
|
||||
<span className="text-custom-text-200">Select issue</span>
|
||||
<ChevronDown className="w-4 h-4 text-custom-text-200" />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
@ -1,81 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import useSWR from "swr";
|
||||
|
||||
// icons
|
||||
import { ChevronDown } from "lucide-react";
|
||||
// services
|
||||
import { ProjectStateService } from "services/project";
|
||||
// fetch key
|
||||
import { STATES_LIST } from "constants/fetch-keys";
|
||||
// components
|
||||
import { StateGroupIcon } from "@plane/ui";
|
||||
import { WebViewModal } from "./web-view-modal";
|
||||
// helpers
|
||||
import { getStatesList } from "helpers/state.helper";
|
||||
|
||||
type Props = {
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
// services
|
||||
const projectStateService = new ProjectStateService();
|
||||
|
||||
export const StateSelect: 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: stateGroups } = useSWR(
|
||||
workspaceSlug && projectId ? STATES_LIST(projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => projectStateService.getStates(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
const states = getStatesList(stateGroups);
|
||||
|
||||
const selectedState = states?.find((s) => s.id === value);
|
||||
|
||||
return (
|
||||
<>
|
||||
<WebViewModal
|
||||
isOpen={isOpen}
|
||||
modalTitle="Select state"
|
||||
onClose={() => {
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
<WebViewModal.Options
|
||||
options={
|
||||
states?.map((state) => ({
|
||||
label: state.name,
|
||||
value: state.id,
|
||||
checked: state.id === selectedState?.id,
|
||||
icon: <StateGroupIcon stateGroup={state.group} color={state.color} />,
|
||||
onClick: () => {
|
||||
setIsOpen(false);
|
||||
if (disabled) return;
|
||||
onChange(state.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"}
|
||||
>
|
||||
<span className="text-custom-text-200">{selectedState?.name || "Select a state"}</span>
|
||||
<ChevronDown className="w-4 h-4 text-custom-text-200" />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
@ -1,5 +0,0 @@
|
||||
export const Spinner: React.FC = () => (
|
||||
<div className="animate-spin duration-[2500ms]">
|
||||
<img src="/web-view-spinner.png" alt="spinner" />
|
||||
</div>
|
||||
);
|
@ -1,154 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import useSWR, { mutate } from "swr";
|
||||
|
||||
// icons
|
||||
import { X, PlusIcon } from "lucide-react";
|
||||
// services
|
||||
import { IssueService } from "services/issue";
|
||||
// fetch key
|
||||
import { SUB_ISSUES } from "constants/fetch-keys";
|
||||
// hooks
|
||||
import useUser from "hooks/use-user";
|
||||
// ui
|
||||
import { Spinner } from "components/ui";
|
||||
// components
|
||||
import { Label, IssuesSelectBottomSheet, DeleteConfirmation } from "components/web-view";
|
||||
// types
|
||||
import { IIssue, ISearchIssueResponse } from "types";
|
||||
|
||||
type Props = {
|
||||
issueDetails?: IIssue;
|
||||
};
|
||||
|
||||
// services
|
||||
const issueService = new IssueService();
|
||||
|
||||
export const SubIssueList: React.FC<Props> = (props) => {
|
||||
const { issueDetails } = props;
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, issueId } = router.query;
|
||||
|
||||
const isArchive = Boolean(router.query.archive);
|
||||
|
||||
const [isBottomSheetOpen, setIsBottomSheetOpen] = useState(false);
|
||||
const [issueSelectedForDelete, setIssueSelectedForDelete] = useState<IIssue | null>(null);
|
||||
|
||||
const { user } = useUser();
|
||||
|
||||
const { data: subIssuesResponse } = useSWR(
|
||||
workspaceSlug && issueDetails ? SUB_ISSUES(issueDetails.id) : null,
|
||||
workspaceSlug && issueDetails
|
||||
? () => issueService.subIssues(workspaceSlug as string, issueDetails.project, issueDetails.id)
|
||||
: null
|
||||
);
|
||||
|
||||
const handleSubIssueRemove = (issue: IIssue | null) => {
|
||||
if (!workspaceSlug || !issueDetails || !user || !issue) return;
|
||||
|
||||
mutate(
|
||||
SUB_ISSUES(issueDetails.id),
|
||||
(prevData) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
const stateDistribution = { ...prevData.state_distribution };
|
||||
|
||||
const issueGroup = issue.state_detail.group;
|
||||
stateDistribution[issueGroup] = stateDistribution[issueGroup] - 1;
|
||||
|
||||
return {
|
||||
state_distribution: stateDistribution,
|
||||
sub_issues: prevData.sub_issues.filter((i: any) => i.id !== issue.id),
|
||||
};
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
issueService
|
||||
.patchIssue(workspaceSlug.toString(), issue.project, issue.id, { parent: null }, user)
|
||||
.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 issueService
|
||||
.addSubIssues(workspaceSlug.toString(), projectId.toString(), issueId.toString(), payload)
|
||||
.finally(() => {
|
||||
mutate(SUB_ISSUES(issueId.toString()));
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<IssuesSelectBottomSheet
|
||||
isOpen={isBottomSheetOpen}
|
||||
onClose={() => setIsBottomSheetOpen(false)}
|
||||
onSubmit={addAsSubIssueFromExistingIssues}
|
||||
searchParams={{ sub_issue: true, issue_id: issueId as string }}
|
||||
/>
|
||||
|
||||
<DeleteConfirmation
|
||||
title="Remove sub issue"
|
||||
content="Are you sure you want to remove this sub issue?"
|
||||
isOpen={!!issueSelectedForDelete}
|
||||
onCancel={() => setIssueSelectedForDelete(null)}
|
||||
onConfirm={() => {
|
||||
if (isArchive) return;
|
||||
setIssueSelectedForDelete(null);
|
||||
handleSubIssueRemove(issueSelectedForDelete);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Label>Sub Issues</Label>
|
||||
<div className="p-3 border border-custom-border-200 rounded-[4px]">
|
||||
{!subIssuesResponse && (
|
||||
<div className="flex justify-center items-center">
|
||||
<Spinner />
|
||||
Loading...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{subIssuesResponse?.sub_issues.length === 0 && (
|
||||
<div className="flex justify-center items-center">
|
||||
<p className="text-sm text-custom-text-200">No sub issues</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{subIssuesResponse?.sub_issues?.map((subIssue) => (
|
||||
<div key={subIssue.id} className="flex items-center justify-between gap-2 py-2">
|
||||
<div className="grid grid-flow-col ">
|
||||
<p className="mr-3 text-sm text-custom-text-300">
|
||||
{subIssue.project_detail.identifier}-{subIssue.sequence_id}
|
||||
</p>
|
||||
<p className="text-sm font-normal truncate ">{subIssue.name}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isArchive}
|
||||
onClick={() => {
|
||||
if (isArchive) return;
|
||||
setIssueSelectedForDelete(subIssue);
|
||||
}}
|
||||
>
|
||||
<X className="w-4 h-4 text-custom-text-400" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isArchive}
|
||||
onClick={() => setIsBottomSheetOpen(true)}
|
||||
className="flex items-center gap-x-1 mt-3"
|
||||
>
|
||||
<PlusIcon className="w-4 h-4 text-custom-text-400" />
|
||||
<p className="text-sm text-custom-text-400">Add sub issue</p>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -1,116 +0,0 @@
|
||||
// react
|
||||
import React, { Fragment } from "react";
|
||||
|
||||
// headless ui
|
||||
import { Transition, Dialog } from "@headlessui/react";
|
||||
|
||||
// icons
|
||||
import { X } from "lucide-react";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
modalTitle: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const WebViewModal = (props: Props) => {
|
||||
const { isOpen, onClose, modalTitle, children } = props;
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-10" onClose={handleClose}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-custom-backdrop bg-opacity-50 transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed bottom-0 left-0 w-full z-10 overflow-y-auto">
|
||||
<div className="flex min-h-full items-end justify-center text-center sm:items-center">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative transform overflow-hidden rounded-none rounded-tr-[20px] rounded-tl-[20px] bg-custom-background-100 p-6 text-left shadow-xl transition-all sm:mt-8 w-full">
|
||||
<div className="flex justify-between items-center w-full">
|
||||
<Dialog.Title as="h3" className="text-2xl font-semibold leading-6 text-custom-text-100">
|
||||
{modalTitle}
|
||||
</Dialog.Title>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex justify-center items-center p-2 rounded-md text-custom-text-200 hover:text-custom-text-100 focus:outline-none"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<X className="w-6 h-6 text-custom-text-200" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col mt-6 h-full max-h-[70vh]">{children}</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
};
|
||||
|
||||
type OptionsProps = {
|
||||
options: Array<{
|
||||
label: string | React.ReactNode;
|
||||
value: string | null;
|
||||
checked: boolean;
|
||||
icon?: any;
|
||||
onClick: () => void;
|
||||
}>;
|
||||
};
|
||||
|
||||
const Options: React.FC<OptionsProps> = ({ options }) => (
|
||||
<div className="divide-y divide-custom-border-300 flex-1 overflow-auto">
|
||||
{options.map((option) => (
|
||||
<div key={option.value} className="flex items-center justify-between gap-2 py-[14px]">
|
||||
<div onClick={option.onClick} className="flex items-center gap-x-2 w-full">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={option.checked}
|
||||
readOnly
|
||||
className="rounded-full border border-custom-border-200 bg-custom-background-100 w-4 h-4"
|
||||
/>
|
||||
|
||||
{option.icon}
|
||||
|
||||
<p className="text-sm font-normal">{option.label}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
type FooterProps = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const Footer: React.FC<FooterProps> = ({ children, className }) => (
|
||||
<div className={`mt-2 flex-shrink-0 ${className ? className : ""}`}>{children}</div>
|
||||
);
|
||||
|
||||
WebViewModal.Options = Options;
|
||||
WebViewModal.Footer = Footer;
|
||||
WebViewModal.Options.displayName = "WebViewModal.Options";
|
||||
WebViewModal.Footer.displayName = "WebViewModal.Footer";
|
@ -1,2 +0,0 @@
|
||||
export * from "./project-setting-layout";
|
||||
export * from "./workspace-setting-layout";
|
2
web/layouts/settings-layout/index.ts
Normal file
2
web/layouts/settings-layout/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./project";
|
||||
export * from "./workspace";
|
@ -1,62 +0,0 @@
|
||||
import useSWR from "swr";
|
||||
|
||||
// services
|
||||
import { UserService } from "services/user.service";
|
||||
// fetch keys
|
||||
import { CURRENT_USER } from "constants/fetch-keys";
|
||||
// icons
|
||||
import { AlertCircle } from "lucide-react";
|
||||
// ui
|
||||
import { Spinner } from "@plane/ui";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
fullScreen?: boolean;
|
||||
};
|
||||
|
||||
// services
|
||||
const userService = new UserService();
|
||||
|
||||
const getIfInWebview = (userAgent: NavigatorID["userAgent"]) => {
|
||||
const safari = /safari/.test(userAgent);
|
||||
|
||||
if (safari) return false;
|
||||
else if (/iphone|ipod|ipad/.test(userAgent) || userAgent.includes("wv")) return true;
|
||||
else return false;
|
||||
};
|
||||
|
||||
const useMobileDetect = () => {
|
||||
const userAgent = typeof navigator === "undefined" ? "SSR" : navigator.userAgent;
|
||||
return getIfInWebview(userAgent);
|
||||
};
|
||||
|
||||
const WebViewLayout: React.FC<Props> = ({ children, fullScreen = true }) => {
|
||||
const { data: currentUser, error } = useSWR(CURRENT_USER, () => userService.currentUser());
|
||||
|
||||
const isWebview = useMobileDetect();
|
||||
|
||||
if (!currentUser && !error) {
|
||||
return (
|
||||
<div className="h-screen grid place-items-center p-4">
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={fullScreen ? "h-screen w-full bg-custom-background-100" : ""}>
|
||||
{error || !isWebview ? (
|
||||
<div className="flex flex-col items-center justify-center gap-y-3 h-full text-center text-custom-text-200">
|
||||
<AlertCircle size={64} />
|
||||
<h2 className="text-2xl font-semibold">You are not authorized to view this page.</h2>
|
||||
</div>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WebViewLayout;
|
@ -4,7 +4,7 @@ import Link from "next/link";
|
||||
// services
|
||||
import { UserService } from "services/user.service";
|
||||
// layouts
|
||||
import { WorkspaceSettingLayout } from "layouts/setting-layout";
|
||||
import { WorkspaceSettingLayout } from "layouts/settings-layout";
|
||||
import { AppLayout } from "layouts/app-layout";
|
||||
// components
|
||||
import { ActivityIcon, ActivityMessage } from "components/core";
|
||||
|
@ -10,7 +10,7 @@ import useUserAuth from "hooks/use-user-auth";
|
||||
import useToast from "hooks/use-toast";
|
||||
// layouts
|
||||
import { AppLayout } from "layouts/app-layout";
|
||||
import { WorkspaceSettingLayout } from "layouts/setting-layout";
|
||||
import { WorkspaceSettingLayout } from "layouts/settings-layout";
|
||||
// components
|
||||
import { ImagePickerPopover, ImageUploadModal } from "components/core";
|
||||
import { WorkspaceSettingHeader } from "components/headers";
|
||||
|
@ -6,7 +6,7 @@ import { useMobxStore } from "lib/mobx/store-provider";
|
||||
import useToast from "hooks/use-toast";
|
||||
// layouts
|
||||
import { AppLayout } from "layouts/app-layout";
|
||||
import { WorkspaceSettingLayout } from "layouts/setting-layout";
|
||||
import { WorkspaceSettingLayout } from "layouts/settings-layout";
|
||||
// components
|
||||
import { CustomThemeSelector, ThemeSwitch } from "components/core";
|
||||
import { WorkspaceSettingHeader } from "components/headers";
|
||||
|
@ -8,7 +8,7 @@ import useSWR, { mutate } from "swr";
|
||||
import { ProjectService } from "services/project";
|
||||
// layouts
|
||||
import { AppLayout } from "layouts/app-layout";
|
||||
import { ProjectSettingLayout } from "layouts/setting-layout";
|
||||
import { ProjectSettingLayout } from "layouts/settings-layout";
|
||||
// hooks
|
||||
import useUserAuth from "hooks/use-user-auth";
|
||||
import useProjectDetails from "hooks/use-project-details";
|
||||
|
@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
// layouts
|
||||
import { AppLayout } from "layouts/app-layout";
|
||||
import { ProjectSettingLayout } from "layouts/setting-layout";
|
||||
import { ProjectSettingLayout } from "layouts/settings-layout";
|
||||
// components
|
||||
import { ProjectSettingHeader } from "components/headers";
|
||||
import { EstimatesList } from "components/estimates/estimate-list";
|
||||
|
@ -5,7 +5,7 @@ import useSWR from "swr";
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// layouts
|
||||
import { AppLayout } from "layouts/app-layout";
|
||||
import { ProjectSettingLayout } from "layouts/setting-layout";
|
||||
import { ProjectSettingLayout } from "layouts/settings-layout";
|
||||
// hooks
|
||||
import useUserAuth from "hooks/use-user-auth";
|
||||
// components
|
||||
|
@ -4,7 +4,7 @@ import { useRouter } from "next/router";
|
||||
import useSWR from "swr";
|
||||
// layouts
|
||||
import { AppLayout } from "layouts/app-layout";
|
||||
import { ProjectSettingLayout } from "layouts/setting-layout";
|
||||
import { ProjectSettingLayout } from "layouts/settings-layout";
|
||||
// components
|
||||
import { ProjectSettingHeader } from "components/headers";
|
||||
import {
|
||||
|
@ -6,7 +6,7 @@ import useSWR from "swr";
|
||||
|
||||
// layouts
|
||||
import { AppLayout } from "layouts/app-layout";
|
||||
import { ProjectSettingLayout } from "layouts/setting-layout";
|
||||
import { ProjectSettingLayout } from "layouts/settings-layout";
|
||||
// services
|
||||
import { IntegrationService } from "services/integrations";
|
||||
import { ProjectService } from "services/project";
|
||||
|
@ -2,7 +2,7 @@ import React from "react";
|
||||
|
||||
// layouts
|
||||
import { AppLayout } from "layouts/app-layout";
|
||||
import { ProjectSettingLayout } from "layouts/setting-layout";
|
||||
import { ProjectSettingLayout } from "layouts/settings-layout";
|
||||
// components
|
||||
import { ProjectSettingsLabelList } from "components/labels";
|
||||
import { ProjectSettingHeader } from "components/headers";
|
||||
|
@ -1,6 +1,6 @@
|
||||
// layouts
|
||||
import { AppLayout } from "layouts/app-layout";
|
||||
import { ProjectSettingLayout } from "layouts/setting-layout";
|
||||
import { ProjectSettingLayout } from "layouts/settings-layout";
|
||||
// components
|
||||
import { ProjectSettingHeader } from "components/headers";
|
||||
import { ProjectMemberList, ProjectSettingsMemberDefaults } from "components/project";
|
||||
|
@ -1,9 +1,9 @@
|
||||
import React from "react";
|
||||
// layout
|
||||
import { AppLayout } from "layouts/app-layout";
|
||||
import { ProjectSettingLayout } from "layouts/settings-layout";
|
||||
// components
|
||||
import { ProjectSettingStateList } from "components/states";
|
||||
import { ProjectSettingLayout } from "layouts/setting-layout";
|
||||
import { ProjectSettingHeader } from "components/headers";
|
||||
// types
|
||||
import type { NextPage } from "next";
|
||||
|
@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
// layouts
|
||||
import { AppLayout } from "layouts/app-layout";
|
||||
import { WorkspaceSettingLayout } from "layouts/setting-layout";
|
||||
import { WorkspaceSettingLayout } from "layouts/settings-layout";
|
||||
// component
|
||||
import { WorkspaceSettingHeader } from "components/headers";
|
||||
// ui
|
||||
|
@ -1,6 +1,6 @@
|
||||
// layout
|
||||
import { AppLayout } from "layouts/app-layout";
|
||||
import { WorkspaceSettingLayout } from "layouts/setting-layout";
|
||||
import { WorkspaceSettingLayout } from "layouts/settings-layout";
|
||||
// components
|
||||
import { WorkspaceSettingHeader } from "components/headers";
|
||||
import ExportGuide from "components/exporter/guide";
|
||||
|
@ -1,5 +1,5 @@
|
||||
// layouts
|
||||
import { WorkspaceSettingLayout } from "layouts/setting-layout";
|
||||
import { WorkspaceSettingLayout } from "layouts/settings-layout";
|
||||
// components
|
||||
import { AppLayout } from "layouts/app-layout";
|
||||
import IntegrationGuide from "components/integration/guide";
|
||||
|
@ -1,6 +1,6 @@
|
||||
// layouts
|
||||
import { AppLayout } from "layouts/app-layout";
|
||||
import { WorkspaceSettingLayout } from "layouts/setting-layout";
|
||||
import { WorkspaceSettingLayout } from "layouts/settings-layout";
|
||||
// components
|
||||
import { WorkspaceSettingHeader } from "components/headers";
|
||||
import { WorkspaceDetails } from "components/workspace";
|
||||
|
@ -8,7 +8,7 @@ import useSWR from "swr";
|
||||
import { IntegrationService } from "services/integrations";
|
||||
// layouts
|
||||
import { AppLayout } from "layouts/app-layout";
|
||||
import { WorkspaceSettingLayout } from "layouts/setting-layout";
|
||||
import { WorkspaceSettingLayout } from "layouts/settings-layout";
|
||||
// components
|
||||
import { SingleIntegrationCard } from "components/integration";
|
||||
import { WorkspaceSettingHeader } from "components/headers";
|
||||
|
@ -4,7 +4,7 @@ import { useRouter } from "next/router";
|
||||
import useUser from "hooks/use-user";
|
||||
// layouts
|
||||
import { AppLayout } from "layouts/app-layout";
|
||||
import { WorkspaceSettingLayout } from "layouts/setting-layout";
|
||||
import { WorkspaceSettingLayout } from "layouts/settings-layout";
|
||||
// components
|
||||
import { WorkspaceSettingHeader } from "components/headers";
|
||||
import { SendWorkspaceInvitationModal, WorkspaceMembersList } from "components/workspace";
|
||||
|
@ -1,99 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// next
|
||||
import type { NextPage } from "next";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// cookies
|
||||
import Cookies from "js-cookie";
|
||||
|
||||
// react-hook-form
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
|
||||
// layouts
|
||||
import WebViewLayout from "layouts/web-view-layout";
|
||||
|
||||
// components
|
||||
import { Button, Spinner } from "@plane/ui";
|
||||
import { RichTextEditor } from "@plane/rich-text-editor";
|
||||
// services
|
||||
import { FileService } from "services/file.service";
|
||||
const fileService = new FileService();
|
||||
|
||||
const Editor: NextPage = () => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, editable } = router.query;
|
||||
|
||||
const isEditable = editable === "true";
|
||||
|
||||
const { watch, setValue, control } = useForm({
|
||||
defaultValues: {
|
||||
data: "",
|
||||
data_html: "",
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
if (!router?.query?.["editable"]) return;
|
||||
setIsLoading(false);
|
||||
const data_html = Cookies.get("data_html");
|
||||
setValue("data_html", data_html ?? "");
|
||||
}, [isEditable, setValue, router]);
|
||||
|
||||
return (
|
||||
<WebViewLayout fullScreen>
|
||||
{isLoading ? (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full h-full flex flex-col justify-between">
|
||||
<Controller
|
||||
name="data_html"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => {
|
||||
if (value == null) return <></>;
|
||||
return (
|
||||
<RichTextEditor
|
||||
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
|
||||
deleteFile={fileService.deleteImage}
|
||||
borderOnFocus={false}
|
||||
value={!value || value === "" ? "<p></p>" : value}
|
||||
noBorder
|
||||
customClassName="h-full shadow-sm overflow-auto"
|
||||
editorContentCustomClassNames="pb-9"
|
||||
onChange={(description: Object, description_html: string) => {
|
||||
onChange(description_html);
|
||||
setValue("data_html", description_html);
|
||||
setValue("data", JSON.stringify(description));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{isEditable && (
|
||||
<Button
|
||||
variant="primary"
|
||||
className="mt-4 w-[calc(100%-30px)] h-[45px] mx-[15px] text-[17px]"
|
||||
onClick={() => {
|
||||
console.log(
|
||||
"submitted",
|
||||
JSON.stringify({
|
||||
data_html: watch("data_html"),
|
||||
})
|
||||
);
|
||||
}}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</WebViewLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Editor;
|
@ -1,176 +0,0 @@
|
||||
import React, { useCallback, useEffect } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import useSWR, { mutate } from "swr";
|
||||
import { useFormContext, useForm, FormProvider } from "react-hook-form";
|
||||
|
||||
// services
|
||||
import { IssueService, IssueArchiveService } from "services/issue";
|
||||
// fetch key
|
||||
import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
|
||||
// hooks
|
||||
import useUser from "hooks/use-user";
|
||||
import useProjectMembers from "hooks/use-project-members";
|
||||
// layouts
|
||||
import WebViewLayout from "layouts/web-view-layout";
|
||||
// ui
|
||||
import { Spinner } from "@plane/ui";
|
||||
// components
|
||||
import {
|
||||
IssueWebViewForm,
|
||||
SubIssueList,
|
||||
IssueAttachments,
|
||||
IssuePropertiesDetail,
|
||||
IssueLinks,
|
||||
IssueActivity,
|
||||
} from "components/web-view";
|
||||
// types
|
||||
import type { IIssue } from "types";
|
||||
|
||||
// services
|
||||
const issueService = new IssueService();
|
||||
const issueArchiveService = new IssueArchiveService();
|
||||
|
||||
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) && !isArchive);
|
||||
|
||||
const { user } = useUser();
|
||||
|
||||
const formContext = useFormContext<IIssue>();
|
||||
const { register, handleSubmit, control, watch, reset } = formContext;
|
||||
|
||||
const {
|
||||
data: issue,
|
||||
mutate: mutateIssue,
|
||||
error,
|
||||
} = useSWR(
|
||||
workspaceSlug && projectId && issueId && !isArchive ? ISSUE_DETAILS(issueId.toString()) : null,
|
||||
workspaceSlug && projectId && issueId && !isArchive
|
||||
? () => issueService.retrieve(workspaceSlug.toString(), projectId.toString(), issueId.toString())
|
||||
: null
|
||||
);
|
||||
|
||||
const { data: archiveIssueDetails, mutate: mutateArchiveIssue } = useSWR<IIssue | undefined>(
|
||||
workspaceSlug && projectId && issueId && isArchive ? ISSUE_DETAILS(issueId as string) : null,
|
||||
workspaceSlug && projectId && issueId && isArchive
|
||||
? () =>
|
||||
issueArchiveService.retrieveArchivedIssue(workspaceSlug.toString(), projectId.toString(), issueId.toString())
|
||||
: null
|
||||
);
|
||||
|
||||
const issueDetails = isArchive ? archiveIssueDetails : issue;
|
||||
const mutateIssueDetails = isArchive ? mutateArchiveIssue : mutateIssue;
|
||||
|
||||
useEffect(() => {
|
||||
if (!issueDetails) return;
|
||||
|
||||
reset({
|
||||
...issueDetails,
|
||||
name: issueDetails.name,
|
||||
description: issueDetails.description,
|
||||
description_html: issueDetails.description_html,
|
||||
state: issueDetails.state,
|
||||
});
|
||||
}, [issueDetails, reset]);
|
||||
|
||||
const submitChanges = useCallback(
|
||||
async (formData: Partial<IIssue>) => {
|
||||
if (!workspaceSlug || !projectId || !issueId) return;
|
||||
|
||||
mutate<IIssue>(
|
||||
ISSUE_DETAILS(issueId.toString()),
|
||||
(prevData) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
return {
|
||||
...prevData,
|
||||
...formData,
|
||||
};
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
const payload: Partial<IIssue> = {
|
||||
...formData,
|
||||
};
|
||||
|
||||
delete payload.issue_relations;
|
||||
delete payload.related_issues;
|
||||
|
||||
await issueService
|
||||
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload, user)
|
||||
.then(() => {
|
||||
mutateIssueDetails();
|
||||
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
});
|
||||
},
|
||||
[workspaceSlug, issueId, projectId, mutateIssueDetails, user]
|
||||
);
|
||||
|
||||
if (!error && !issueDetails)
|
||||
return (
|
||||
<WebViewLayout>
|
||||
<div className="px-4 py-2 h-full">
|
||||
<div className="h-full flex justify-center items-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
</div>
|
||||
</WebViewLayout>
|
||||
);
|
||||
|
||||
if (error)
|
||||
return (
|
||||
<WebViewLayout>
|
||||
<div className="px-4 py-2">{error?.response?.data || "Something went wrong"}</div>
|
||||
</WebViewLayout>
|
||||
);
|
||||
|
||||
return (
|
||||
<WebViewLayout>
|
||||
{isArchive && <div className="w-full h-screen top-0 left-0 fixed z-50 bg-white/20 pointer-events-none" />}
|
||||
|
||||
<div className="px-6 py-2 h-full overflow-auto space-y-3">
|
||||
<IssueWebViewForm
|
||||
isAllowed={isAllowed}
|
||||
issueDetails={issueDetails!}
|
||||
submitChanges={submitChanges}
|
||||
register={register}
|
||||
control={control}
|
||||
watch={watch}
|
||||
handleSubmit={handleSubmit}
|
||||
/>
|
||||
|
||||
<SubIssueList issueDetails={issueDetails!} />
|
||||
|
||||
<IssuePropertiesDetail submitChanges={submitChanges} />
|
||||
|
||||
<IssueAttachments allowed={isAllowed} />
|
||||
|
||||
<IssueLinks allowed={isAllowed} issueDetails={issueDetails!} />
|
||||
|
||||
<IssueActivity allowed={isAllowed} issueDetails={issueDetails!} />
|
||||
</div>
|
||||
</WebViewLayout>
|
||||
);
|
||||
};
|
||||
|
||||
const MobileWebViewIssueDetail = () => {
|
||||
const methods = useForm();
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<MobileWebViewIssueDetail_ />
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileWebViewIssueDetail;
|
Loading…
Reference in New Issue
Block a user