mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
feat: add links and permission to perform actions
refactor: divided file into components
This commit is contained in:
parent
065a4a3cf7
commit
b40059ea21
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>
|
||||
);
|
||||
};
|
@ -5,3 +5,6 @@ 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";
|
||||
|
@ -29,7 +29,13 @@ import { Label, WebViewModal } from "components/web-view";
|
||||
// types
|
||||
import type { IIssueAttachment } from "types";
|
||||
|
||||
export const IssueAttachments: React.FC = () => {
|
||||
type Props = {
|
||||
allowed: boolean;
|
||||
};
|
||||
|
||||
export const IssueAttachments: React.FC<Props> = (props) => {
|
||||
const { allowed } = props;
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, issueId } = router.query;
|
||||
|
||||
@ -89,6 +95,7 @@ export const IssueAttachments: React.FC = () => {
|
||||
const { getRootProps } = useDropzone({
|
||||
onDrop,
|
||||
maxSize: 5 * 1024 * 1024,
|
||||
disabled: !allowed || isLoading,
|
||||
});
|
||||
|
||||
const { data: attachments } = useSWR<IIssueAttachment[]>(
|
||||
@ -109,7 +116,9 @@ export const IssueAttachments: React.FC = () => {
|
||||
<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"
|
||||
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>
|
||||
|
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>
|
||||
);
|
||||
};
|
@ -36,7 +36,7 @@ export const WebViewModal = (props: Props) => {
|
||||
<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="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}
|
||||
@ -47,7 +47,7 @@ export const WebViewModal = (props: Props) => {
|
||||
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">
|
||||
<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"
|
||||
|
@ -8,7 +8,7 @@ import { useRouter } from "next/router";
|
||||
import useSWR, { mutate } from "swr";
|
||||
|
||||
// react hook forms
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
@ -18,41 +18,37 @@ 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";
|
||||
import useProjectMembers from "hooks/use-project-members";
|
||||
|
||||
// layouts
|
||||
import DefaultLayout from "layouts/default-layout";
|
||||
|
||||
// ui
|
||||
import { Spinner, Icon } from "components/ui";
|
||||
import { Spinner } from "components/ui";
|
||||
|
||||
// components
|
||||
import {
|
||||
StateSelect,
|
||||
PrioritySelect,
|
||||
IssueWebViewForm,
|
||||
SubIssueList,
|
||||
IssueAttachments,
|
||||
IssuePropertiesDetail,
|
||||
IssueLinks,
|
||||
} 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 memberRole = useProjectMembers(
|
||||
workspaceSlug as string,
|
||||
projectId as string,
|
||||
!!workspaceSlug && !!projectId
|
||||
);
|
||||
|
||||
const isAllowed = memberRole.isMember || memberRole.isOwner;
|
||||
const isAllowed = Boolean(memberRole.isMember || memberRole.isOwner);
|
||||
|
||||
const { user } = useUser();
|
||||
|
||||
@ -161,51 +157,11 @@ const MobileWebViewIssueDetail = () => {
|
||||
|
||||
<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>
|
||||
<IssuePropertiesDetail control={control} submitChanges={submitChanges} />
|
||||
|
||||
<IssueAttachments />
|
||||
<IssueAttachments allowed={isAllowed} />
|
||||
|
||||
<IssueLinks allowed={isAllowed} links={issueDetails?.issue_link} />
|
||||
</div>
|
||||
</DefaultLayout>
|
||||
);
|
||||
|
Loading…
Reference in New Issue
Block a user