mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
feat: issue detail for web view
This commit is contained in:
parent
d3a9a764dc
commit
065a4a3cf7
7
web/components/web-view/index.ts
Normal file
7
web/components/web-view/index.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
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";
|
150
web/components/web-view/issue-attachments.tsx
Normal file
150
web/components/web-view/issue-attachments.tsx
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
// react
|
||||||
|
import React, { useState, useCallback } from "react";
|
||||||
|
|
||||||
|
// next
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
|
// swr
|
||||||
|
import useSWR, { mutate } from "swr";
|
||||||
|
|
||||||
|
// services
|
||||||
|
import issuesService from "services/issues.service";
|
||||||
|
|
||||||
|
// react dropzone
|
||||||
|
import { useDropzone } from "react-dropzone";
|
||||||
|
|
||||||
|
// fetch key
|
||||||
|
import { ISSUE_ATTACHMENTS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
|
||||||
|
|
||||||
|
// hooks
|
||||||
|
import useToast from "hooks/use-toast";
|
||||||
|
|
||||||
|
// icons
|
||||||
|
import { ChevronRightIcon } from "@heroicons/react/24/outline";
|
||||||
|
|
||||||
|
// components
|
||||||
|
import { Label, WebViewModal } from "components/web-view";
|
||||||
|
|
||||||
|
// types
|
||||||
|
import type { IIssueAttachment } from "types";
|
||||||
|
|
||||||
|
export const IssueAttachments: React.FC = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { workspaceSlug, projectId, issueId } = router.query;
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const { setToastAlert } = useToast();
|
||||||
|
|
||||||
|
const onDrop = useCallback(
|
||||||
|
(acceptedFiles: File[]) => {
|
||||||
|
if (!acceptedFiles[0] || !workspaceSlug) 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);
|
||||||
|
|
||||||
|
issuesService
|
||||||
|
.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));
|
||||||
|
setToastAlert({
|
||||||
|
type: "success",
|
||||||
|
title: "Success!",
|
||||||
|
message: "File added successfully.",
|
||||||
|
});
|
||||||
|
setIsLoading(false);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
setIsLoading(false);
|
||||||
|
setToastAlert({
|
||||||
|
type: "error",
|
||||||
|
title: "error!",
|
||||||
|
message: "Something went wrong. please check file type & size (max 5 MB)",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[issueId, projectId, setToastAlert, workspaceSlug]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { getRootProps } = useDropzone({
|
||||||
|
onDrop,
|
||||||
|
maxSize: 5 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: attachments } = useSWR<IIssueAttachment[]>(
|
||||||
|
workspaceSlug && projectId && issueId ? ISSUE_ATTACHMENTS(issueId as string) : null,
|
||||||
|
workspaceSlug && projectId && issueId
|
||||||
|
? () =>
|
||||||
|
issuesService.getIssueAttachment(
|
||||||
|
workspaceSlug.toString(),
|
||||||
|
projectId.toString(),
|
||||||
|
issueId.toString()
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-center">Uploading...</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<h3 className="text-lg">Upload</h3>
|
||||||
|
<ChevronRightIcon 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">
|
||||||
|
{attachment.attributes.name}
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsOpen(true)}
|
||||||
|
className="bg-custom-primary-100/10 border border-dotted border-custom-primary-100 text-center py-2 w-full text-custom-primary-100"
|
||||||
|
>
|
||||||
|
Click to upload file here
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
164
web/components/web-view/issue-web-view-form.tsx
Normal file
164
web/components/web-view/issue-web-view-form.tsx
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
// react
|
||||||
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
// next
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
|
// react hook forms
|
||||||
|
import { Controller } from "react-hook-form";
|
||||||
|
|
||||||
|
// hooks
|
||||||
|
|
||||||
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
|
import useReloadConfirmations from "hooks/use-reload-confirmation";
|
||||||
|
|
||||||
|
// ui
|
||||||
|
import { TextArea } from "components/ui";
|
||||||
|
|
||||||
|
// components
|
||||||
|
import { TipTapEditor } from "components/tiptap";
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const IssueWebViewForm: React.FC<Props> = (props) => {
|
||||||
|
const { isAllowed, issueDetails, submitChanges, register, 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 ? (
|
||||||
|
<TextArea
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
placeholder="Enter issue name"
|
||||||
|
register={register}
|
||||||
|
onFocus={() => setCharacterLimit(true)}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCharacterLimit(false);
|
||||||
|
setIsSubmitting("submitting");
|
||||||
|
debouncedTitleSave();
|
||||||
|
}}
|
||||||
|
required={true}
|
||||||
|
className="min-h-10 block w-full resize-none overflow-hidden rounded border-none 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) return <></>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TipTapEditor
|
||||||
|
value={
|
||||||
|
!value ||
|
||||||
|
value === "" ||
|
||||||
|
(typeof value === "object" && Object.keys(value).length === 0)
|
||||||
|
? "<p></p>"
|
||||||
|
: value
|
||||||
|
}
|
||||||
|
workspaceSlug={workspaceSlug!.toString()}
|
||||||
|
debouncedUpdatesEnabled={true}
|
||||||
|
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")
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
editable={isAllowed}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
7
web/components/web-view/label.tsx
Normal file
7
web/components/web-view/label.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
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>
|
||||||
|
);
|
83
web/components/web-view/select-priority.tsx
Normal file
83
web/components/web-view/select-priority.tsx
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
// react
|
||||||
|
import React, { useState } from "react";
|
||||||
|
|
||||||
|
// icons
|
||||||
|
import { ChevronDownIcon } from "@heroicons/react/24/outline";
|
||||||
|
|
||||||
|
// constants
|
||||||
|
import { PRIORITIES } from "constants/project";
|
||||||
|
|
||||||
|
// components
|
||||||
|
import { getPriorityIcon } from "components/icons";
|
||||||
|
import { WebViewModal } from "./web-view-modal";
|
||||||
|
|
||||||
|
// helpers
|
||||||
|
import { capitalizeFirstLetter } from "helpers/string.helper";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
value: any;
|
||||||
|
onChange: (value: any) => 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
|
||||||
|
selectedOption={value}
|
||||||
|
options={
|
||||||
|
PRIORITIES?.map((priority) => ({
|
||||||
|
label: priority ? capitalizeFirstLetter(priority) : "None",
|
||||||
|
value: 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"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{getPriorityIcon(priority, "text-sm")}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
onClick: () => {
|
||||||
|
setIsOpen(false);
|
||||||
|
if (disabled) return;
|
||||||
|
onChange(priority);
|
||||||
|
},
|
||||||
|
})) || []
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</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 ? capitalizeFirstLetter(value) : "None"}
|
||||||
|
<ChevronDownIcon className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
89
web/components/web-view/select-state.tsx
Normal file
89
web/components/web-view/select-state.tsx
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
// react
|
||||||
|
import React, { useState } from "react";
|
||||||
|
|
||||||
|
// next
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
|
// swr
|
||||||
|
import useSWR from "swr";
|
||||||
|
|
||||||
|
// icons
|
||||||
|
import { ChevronDownIcon } from "@heroicons/react/24/outline";
|
||||||
|
|
||||||
|
// services
|
||||||
|
import stateService from "services/state.service";
|
||||||
|
|
||||||
|
// fetch key
|
||||||
|
import { STATES_LIST } from "constants/fetch-keys";
|
||||||
|
|
||||||
|
// components
|
||||||
|
import { getStateGroupIcon } from "components/icons";
|
||||||
|
import { WebViewModal } from "./web-view-modal";
|
||||||
|
|
||||||
|
// helpers
|
||||||
|
import { getStatesList } from "helpers/state.helper";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
value: any;
|
||||||
|
onChange: (value: any) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
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
|
||||||
|
? () => stateService.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
|
||||||
|
selectedOption={selectedState?.id || null}
|
||||||
|
options={
|
||||||
|
states?.map((state) => ({
|
||||||
|
label: state.name,
|
||||||
|
value: state.id,
|
||||||
|
icon: getStateGroupIcon(state.group, "16", "16", 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 text-custom-text-100"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{selectedState?.name || "Select a state"}
|
||||||
|
<ChevronDownIcon className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
108
web/components/web-view/sub-issues.tsx
Normal file
108
web/components/web-view/sub-issues.tsx
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
// react
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
// next
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
|
// swr
|
||||||
|
import useSWR, { mutate } from "swr";
|
||||||
|
|
||||||
|
// icons
|
||||||
|
import { XMarkIcon } from "@heroicons/react/24/outline";
|
||||||
|
|
||||||
|
// services
|
||||||
|
import issuesService from "services/issues.service";
|
||||||
|
|
||||||
|
// fetch key
|
||||||
|
import { SUB_ISSUES } from "constants/fetch-keys";
|
||||||
|
|
||||||
|
// hooks
|
||||||
|
import useUser from "hooks/use-user";
|
||||||
|
|
||||||
|
// ui
|
||||||
|
import { Spinner } from "components/ui";
|
||||||
|
import { IIssue } from "types";
|
||||||
|
|
||||||
|
// components
|
||||||
|
import { Label } from "components/web-view";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
issueDetails?: IIssue;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SubIssueList: React.FC<Props> = (props) => {
|
||||||
|
const { issueDetails } = props;
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const { workspaceSlug } = router.query;
|
||||||
|
|
||||||
|
const { user } = useUser();
|
||||||
|
|
||||||
|
const { data: subIssuesResponse } = useSWR(
|
||||||
|
workspaceSlug && issueDetails ? SUB_ISSUES(issueDetails.id) : null,
|
||||||
|
workspaceSlug && issueDetails
|
||||||
|
? () =>
|
||||||
|
issuesService.subIssues(workspaceSlug as string, issueDetails.project, issueDetails.id)
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSubIssueRemove = (issue: any) => {
|
||||||
|
if (!workspaceSlug || !issueDetails || !user) 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
|
||||||
|
);
|
||||||
|
|
||||||
|
issuesService
|
||||||
|
.patchIssue(workspaceSlug.toString(), issue.project, issue.id, { parent: null }, user)
|
||||||
|
.finally(() => mutate(SUB_ISSUES(issueDetails.id)));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<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="flex items-center">
|
||||||
|
<p className="mr-3 text-sm text-custom-text-300">
|
||||||
|
{subIssue.project_detail.identifier}-{subIssue.sequence_id}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-normal">{subIssue.name}</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={() => handleSubIssueRemove(subIssue)}>
|
||||||
|
<XMarkIcon className="w-5 h-5 text-custom-text-200" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
108
web/components/web-view/web-view-modal.tsx
Normal file
108
web/components/web-view/web-view-modal.tsx
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
// react
|
||||||
|
import React, { Fragment } from "react";
|
||||||
|
|
||||||
|
// headless ui
|
||||||
|
import { Transition, Dialog } from "@headlessui/react";
|
||||||
|
|
||||||
|
// icons
|
||||||
|
import { XMarkIcon } from "@heroicons/react/24/outline";
|
||||||
|
|
||||||
|
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 inset-0 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-[4px] rounded-tl-[4px] bg-custom-background-100 p-6 text-left shadow-xl transition-all sm:my-8 w-full sm:p-6">
|
||||||
|
<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}
|
||||||
|
>
|
||||||
|
<XMarkIcon className="w-6 h-6 text-custom-text-200" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6">{children}</div>
|
||||||
|
</Dialog.Panel>
|
||||||
|
</Transition.Child>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</Transition.Root>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type OptionsProps = {
|
||||||
|
selectedOption: string | null;
|
||||||
|
options: Array<{
|
||||||
|
label: string;
|
||||||
|
value: string | null;
|
||||||
|
icon?: any;
|
||||||
|
onClick: () => void;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Options: React.FC<OptionsProps> = ({ options, selectedOption }) => (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{options.map((option) => (
|
||||||
|
<div key={option.value} className="flex items-center justify-between gap-2 py-2">
|
||||||
|
<div className="flex items-center gap-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={option.value === selectedOption}
|
||||||
|
onClick={option.onClick}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
|
||||||
|
WebViewModal.Options = Options;
|
||||||
|
WebViewModal.Options.displayName = "WebViewModal.Options";
|
@ -229,6 +229,8 @@ export const INBOX_ISSUE_DETAILS = (inboxId: string, issueId: string) =>
|
|||||||
|
|
||||||
// Issues
|
// Issues
|
||||||
export const ISSUE_DETAILS = (issueId: string) => `ISSUE_DETAILS_${issueId.toUpperCase()}`;
|
export const ISSUE_DETAILS = (issueId: string) => `ISSUE_DETAILS_${issueId.toUpperCase()}`;
|
||||||
|
export const M_ISSUE_DETAILS = (workspaceSlug: string, projectId: string, issueId: string) =>
|
||||||
|
`M_ISSUE_DETAILS_${workspaceSlug.toUpperCase()}_${projectId.toUpperCase()}_${issueId}`;
|
||||||
export const SUB_ISSUES = (issueId: string) => `SUB_ISSUES_${issueId.toUpperCase()}`;
|
export const SUB_ISSUES = (issueId: string) => `SUB_ISSUES_${issueId.toUpperCase()}`;
|
||||||
export const ISSUE_ATTACHMENTS = (issueId: string) => `ISSUE_ATTACHMENTS_${issueId.toUpperCase()}`;
|
export const ISSUE_ATTACHMENTS = (issueId: string) => `ISSUE_ATTACHMENTS_${issueId.toUpperCase()}`;
|
||||||
export const ARCHIVED_ISSUE_DETAILS = (issueId: string) =>
|
export const ARCHIVED_ISSUE_DETAILS = (issueId: string) =>
|
||||||
@ -285,7 +287,9 @@ export const ANALYTICS = (workspaceSlug: string, params: IAnalyticsParams) =>
|
|||||||
params.segment
|
params.segment
|
||||||
}_${params.project?.toString()}`;
|
}_${params.project?.toString()}`;
|
||||||
export const DEFAULT_ANALYTICS = (workspaceSlug: string, params?: Partial<IAnalyticsParams>) =>
|
export const DEFAULT_ANALYTICS = (workspaceSlug: string, params?: Partial<IAnalyticsParams>) =>
|
||||||
`DEFAULT_ANALYTICS_${workspaceSlug.toUpperCase()}_${params?.project?.toString()}_${params?.cycle}_${params?.module}`;
|
`DEFAULT_ANALYTICS_${workspaceSlug.toUpperCase()}_${params?.project?.toString()}_${
|
||||||
|
params?.cycle
|
||||||
|
}_${params?.module}`;
|
||||||
|
|
||||||
// notifications
|
// notifications
|
||||||
export const USER_WORKSPACE_NOTIFICATIONS = (
|
export const USER_WORKSPACE_NOTIFICATIONS = (
|
||||||
|
@ -0,0 +1,214 @@
|
|||||||
|
// react
|
||||||
|
import React, { useCallback, useEffect } from "react";
|
||||||
|
|
||||||
|
// next
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
|
// swr
|
||||||
|
import useSWR, { mutate } from "swr";
|
||||||
|
|
||||||
|
// react hook forms
|
||||||
|
import { useForm, Controller } from "react-hook-form";
|
||||||
|
|
||||||
|
// services
|
||||||
|
import issuesService from "services/issues.service";
|
||||||
|
|
||||||
|
// fetch key
|
||||||
|
import { M_ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
|
||||||
|
|
||||||
|
// hooks
|
||||||
|
import useUser from "hooks/use-user";
|
||||||
|
import { useProjectMyMembership } from "contexts/project-member.context";
|
||||||
|
|
||||||
|
// layouts
|
||||||
|
import DefaultLayout from "layouts/default-layout";
|
||||||
|
|
||||||
|
// ui
|
||||||
|
import { Spinner, Icon } from "components/ui";
|
||||||
|
|
||||||
|
// components
|
||||||
|
import {
|
||||||
|
StateSelect,
|
||||||
|
PrioritySelect,
|
||||||
|
IssueWebViewForm,
|
||||||
|
SubIssueList,
|
||||||
|
IssueAttachments,
|
||||||
|
} from "components/web-view";
|
||||||
|
|
||||||
|
// types
|
||||||
|
import type { IIssue } from "types";
|
||||||
|
|
||||||
|
const Label: React.FC<
|
||||||
|
React.DetailedHTMLProps<React.LabelHTMLAttributes<HTMLLabelElement>, HTMLLabelElement>
|
||||||
|
> = (props) => (
|
||||||
|
<label className="block text-base font-medium mb-[5px]" {...props}>
|
||||||
|
{props.children}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
|
||||||
|
const MobileWebViewIssueDetail = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { workspaceSlug, projectId, issueId } = router.query;
|
||||||
|
|
||||||
|
const { memberRole } = useProjectMyMembership();
|
||||||
|
|
||||||
|
const isAllowed = memberRole.isMember || memberRole.isOwner;
|
||||||
|
|
||||||
|
const { user } = useUser();
|
||||||
|
|
||||||
|
const { register, control, reset, handleSubmit, watch } = useForm<IIssue>({
|
||||||
|
defaultValues: {
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
description_html: "",
|
||||||
|
state: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: issueDetails,
|
||||||
|
mutate: mutateIssueDetails,
|
||||||
|
error,
|
||||||
|
} = useSWR(
|
||||||
|
workspaceSlug && projectId && issueId
|
||||||
|
? M_ISSUE_DETAILS(workspaceSlug.toString(), projectId.toString(), issueId.toString())
|
||||||
|
: null,
|
||||||
|
workspaceSlug && projectId && issueId
|
||||||
|
? () =>
|
||||||
|
issuesService.retrieve(workspaceSlug.toString(), projectId.toString(), issueId.toString())
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
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>(
|
||||||
|
M_ISSUE_DETAILS(workspaceSlug.toString(), projectId.toString(), issueId.toString()),
|
||||||
|
(prevData) => {
|
||||||
|
if (!prevData) return prevData;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prevData,
|
||||||
|
...formData,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
const payload: Partial<IIssue> = {
|
||||||
|
...formData,
|
||||||
|
};
|
||||||
|
|
||||||
|
delete payload.blocker_issues;
|
||||||
|
delete payload.blocked_issues;
|
||||||
|
|
||||||
|
await issuesService
|
||||||
|
.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 (
|
||||||
|
<DefaultLayout>
|
||||||
|
<div className="px-4 py-2 h-full">
|
||||||
|
<div className="h-full flex justify-center items-center">
|
||||||
|
<Spinner />
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DefaultLayout>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (error)
|
||||||
|
return (
|
||||||
|
<DefaultLayout>
|
||||||
|
<div className="px-4 py-2">{error?.response?.data || "Something went wrong"}</div>
|
||||||
|
</DefaultLayout>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DefaultLayout>
|
||||||
|
<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!} />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Details</Label>
|
||||||
|
<div className="space-y-2 mb-[6px]">
|
||||||
|
<div className="border border-custom-border-200 rounded-[4px] p-2 flex justify-between items-center">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Icon iconName="grid_view" />
|
||||||
|
<span className="text-sm text-custom-text-200">State</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="state"
|
||||||
|
render={({ field: { value } }) => (
|
||||||
|
<StateSelect
|
||||||
|
value={value}
|
||||||
|
onChange={(val: string) => submitChanges({ state: val })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="border border-custom-border-200 rounded-[4px] p-2 flex justify-between items-center">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Icon iconName="grid_view" />
|
||||||
|
<span className="text-sm text-custom-text-200">Priority</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="priority"
|
||||||
|
render={({ field: { value } }) => (
|
||||||
|
<PrioritySelect
|
||||||
|
value={value}
|
||||||
|
onChange={(val: string) => submitChanges({ priority: val })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<IssueAttachments />
|
||||||
|
</div>
|
||||||
|
</DefaultLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MobileWebViewIssueDetail;
|
Loading…
Reference in New Issue
Block a user