mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
Merge pull request #2097 from makeplane/feat/issue_detail_for_webview
feat: issue detail for web-view
This commit is contained in:
commit
70ed3c1fdf
172
web/components/web-view/create-update-link-form.tsx
Normal file
172
web/components/web-view/create-update-link-form.tsx
Normal file
@ -0,0 +1,172 @@
|
||||
// react
|
||||
import React from "react";
|
||||
|
||||
// next
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// swr
|
||||
import { mutate } from "swr";
|
||||
|
||||
// react hooks form
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
|
||||
// fetch keys
|
||||
import { M_ISSUE_DETAILS } from "constants/fetch-keys";
|
||||
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
|
||||
// ui
|
||||
import { PrimaryButton, Input } from "components/ui";
|
||||
|
||||
// types
|
||||
import type { linkDetails, IIssueLink } from "types";
|
||||
|
||||
type Props = {
|
||||
links?: linkDetails[];
|
||||
data?: linkDetails;
|
||||
onSuccess: () => void;
|
||||
};
|
||||
|
||||
export const CreateUpdateLinkForm: React.FC<Props> = (props) => {
|
||||
const { data, links, onSuccess } = props;
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, issueId } = router.query;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm({
|
||||
defaultValues: {
|
||||
title: "",
|
||||
url: "",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (formData: IIssueLink) => {
|
||||
if (!workspaceSlug || !projectId || !issueId) return;
|
||||
|
||||
const payload = { metadata: {}, ...formData };
|
||||
|
||||
if (!data)
|
||||
await issuesService
|
||||
.createIssueLink(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
issueId.toString(),
|
||||
payload
|
||||
)
|
||||
.then(() => {
|
||||
onSuccess();
|
||||
mutate(
|
||||
M_ISSUE_DETAILS(workspaceSlug.toString(), projectId.toString(), 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(
|
||||
M_ISSUE_DETAILS(workspaceSlug.toString(), projectId.toString(), issueId.toString()),
|
||||
(prevData) => ({ ...prevData, issue_link: updatedLinks }),
|
||||
false
|
||||
);
|
||||
|
||||
await issuesService
|
||||
.updateIssueLink(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
issueId.toString(),
|
||||
data!.id,
|
||||
payload
|
||||
)
|
||||
.then(() => {
|
||||
onSuccess();
|
||||
mutate(
|
||||
M_ISSUE_DETAILS(workspaceSlug.toString(), projectId.toString(), 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>
|
||||
<Input
|
||||
id="url"
|
||||
label="URL"
|
||||
name="url"
|
||||
type="url"
|
||||
placeholder="https://..."
|
||||
autoComplete="off"
|
||||
error={errors.url}
|
||||
register={register}
|
||||
validations={{
|
||||
required: "URL is required",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
id="title"
|
||||
label="Title (optional)"
|
||||
name="title"
|
||||
type="text"
|
||||
placeholder="Enter title"
|
||||
autoComplete="off"
|
||||
error={errors.title}
|
||||
register={register}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<PrimaryButton
|
||||
type="submit"
|
||||
loading={isSubmitting}
|
||||
className="w-full !py-2 text-custom-text-300 !text-base flex items-center justify-center"
|
||||
>
|
||||
{data
|
||||
? isSubmitting
|
||||
? "Updating Link..."
|
||||
: "Update Link"
|
||||
: isSubmitting
|
||||
? "Adding Link..."
|
||||
: "Add Link"}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
10
web/components/web-view/index.ts
Normal file
10
web/components/web-view/index.ts
Normal file
@ -0,0 +1,10 @@
|
||||
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";
|
159
web/components/web-view/issue-attachments.tsx
Normal file
159
web/components/web-view/issue-attachments.tsx
Normal file
@ -0,0 +1,159 @@
|
||||
// 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";
|
||||
|
||||
type Props = {
|
||||
allowed: boolean;
|
||||
};
|
||||
|
||||
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 { 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,
|
||||
disabled: !allowed || isLoading,
|
||||
});
|
||||
|
||||
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 ${
|
||||
!allowed || isLoading ? "cursor-not-allowed" : "cursor-pointer"
|
||||
}`}
|
||||
>
|
||||
{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>
|
||||
);
|
||||
};
|
64
web/components/web-view/issue-link-list.tsx
Normal file
64
web/components/web-view/issue-link-list.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
// react
|
||||
import React, { useState } from "react";
|
||||
|
||||
// next
|
||||
import Link from "next/link";
|
||||
|
||||
// icons
|
||||
import { LinkIcon, PlusIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
// components
|
||||
import { Label, WebViewModal, CreateUpdateLinkForm } from "components/web-view";
|
||||
|
||||
// ui
|
||||
import { SecondaryButton } from "components/ui";
|
||||
|
||||
// types
|
||||
import type { linkDetails } from "types";
|
||||
|
||||
type Props = {
|
||||
allowed: boolean;
|
||||
links?: linkDetails[];
|
||||
};
|
||||
|
||||
export const IssueLinks: React.FC<Props> = (props) => {
|
||||
const { links, allowed } = props;
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<WebViewModal isOpen={isOpen} onClose={() => setIsOpen(false)} modalTitle="Add Link">
|
||||
<CreateUpdateLinkForm links={links} onSuccess={() => setIsOpen(false)} />
|
||||
</WebViewModal>
|
||||
|
||||
<Label>Attachments</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"
|
||||
>
|
||||
<Link href={link.url}>
|
||||
<a target="_blank" className="text-custom-text-200 truncate">
|
||||
<span>
|
||||
<LinkIcon className="w-4 h-4 inline-block mr-1" />
|
||||
</span>
|
||||
<span>{link.title}</span>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
disabled={!allowed}
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="w-full !py-2 text-custom-text-300 !text-base flex items-center justify-center"
|
||||
>
|
||||
<PlusIcon className="w-4 h-4 inline-block mr-1" />
|
||||
<span>Add</span>
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
69
web/components/web-view/issue-properties-detail.tsx
Normal file
69
web/components/web-view/issue-properties-detail.tsx
Normal file
@ -0,0 +1,69 @@
|
||||
// react
|
||||
import React from "react";
|
||||
|
||||
// react hook forms
|
||||
import { Controller } from "react-hook-form";
|
||||
|
||||
// ui
|
||||
import { Icon } from "components/ui";
|
||||
|
||||
// components
|
||||
import { Label, StateSelect, PrioritySelect } from "components/web-view";
|
||||
|
||||
// types
|
||||
import type { IIssue } from "types";
|
||||
|
||||
type Props = {
|
||||
control: any;
|
||||
submitChanges: (data: Partial<IIssue>) => Promise<void>;
|
||||
};
|
||||
|
||||
export const IssuePropertiesDetail: React.FC<Props> = (props) => {
|
||||
const { control, submitChanges } = props;
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
};
|
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 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-[4px] rounded-tl-[4px] 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}
|
||||
>
|
||||
<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
|
||||
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 ISSUE_ATTACHMENTS = (issueId: string) => `ISSUE_ATTACHMENTS_${issueId.toUpperCase()}`;
|
||||
export const ARCHIVED_ISSUE_DETAILS = (issueId: string) =>
|
||||
@ -285,7 +287,9 @@ export const ANALYTICS = (workspaceSlug: string, params: IAnalyticsParams) =>
|
||||
params.segment
|
||||
}_${params.project?.toString()}`;
|
||||
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
|
||||
export const USER_WORKSPACE_NOTIFICATIONS = (
|
||||
|
@ -0,0 +1,170 @@
|
||||
// react
|
||||
import React, { useCallback, useEffect } from "react";
|
||||
|
||||
// next
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// swr
|
||||
import useSWR, { mutate } from "swr";
|
||||
|
||||
// react hook forms
|
||||
import { useForm } 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 useProjectMembers from "hooks/use-project-members";
|
||||
|
||||
// layouts
|
||||
import DefaultLayout from "layouts/default-layout";
|
||||
|
||||
// ui
|
||||
import { Spinner } from "components/ui";
|
||||
|
||||
// components
|
||||
import {
|
||||
IssueWebViewForm,
|
||||
SubIssueList,
|
||||
IssueAttachments,
|
||||
IssuePropertiesDetail,
|
||||
IssueLinks,
|
||||
} from "components/web-view";
|
||||
|
||||
// types
|
||||
import type { IIssue } from "types";
|
||||
|
||||
const MobileWebViewIssueDetail = () => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, issueId } = router.query;
|
||||
|
||||
const memberRole = useProjectMembers(
|
||||
workspaceSlug as string,
|
||||
projectId as string,
|
||||
!!workspaceSlug && !!projectId
|
||||
);
|
||||
|
||||
const isAllowed = Boolean(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!} />
|
||||
|
||||
<IssuePropertiesDetail control={control} submitChanges={submitChanges} />
|
||||
|
||||
<IssueAttachments allowed={isAllowed} />
|
||||
|
||||
<IssueLinks allowed={isAllowed} links={issueDetails?.issue_link} />
|
||||
</div>
|
||||
</DefaultLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileWebViewIssueDetail;
|
Loading…
Reference in New Issue
Block a user