Merge pull request #2097 from makeplane/feat/issue_detail_for_webview

feat: issue detail for web-view
This commit is contained in:
sriram veeraghanta 2023-09-05 17:56:33 +05:30 committed by GitHub
commit 70ed3c1fdf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 1208 additions and 1 deletions

View 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>
);
};

View 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";

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
</>
);
};

View 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>
);

View 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>
</>
);
};

View 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>
</>
);
};

View 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>
);
};

View 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";

View File

@ -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 = (

View File

@ -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;