forked from github/plane
chore: link edit functionality (#1895)
This commit is contained in:
parent
d74ec7bda9
commit
c3c6ba9e34
@ -1,4 +1,4 @@
|
||||
import React from "react";
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
// react-hook-form
|
||||
import { useForm } from "react-hook-form";
|
||||
@ -7,12 +7,15 @@ import { Dialog, Transition } from "@headlessui/react";
|
||||
// ui
|
||||
import { Input, PrimaryButton, SecondaryButton } from "components/ui";
|
||||
// types
|
||||
import type { IIssueLink, ModuleLink } from "types";
|
||||
import type { IIssueLink, linkDetails, ModuleLink } from "types";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
handleClose: () => void;
|
||||
onFormSubmit: (formData: IIssueLink | ModuleLink) => Promise<void>;
|
||||
data?: linkDetails | null;
|
||||
status: boolean;
|
||||
createIssueLink: (formData: IIssueLink | ModuleLink) => Promise<void>;
|
||||
updateIssueLink: (formData: IIssueLink | ModuleLink, linkId: string) => Promise<void>;
|
||||
};
|
||||
|
||||
const defaultValues: IIssueLink | ModuleLink = {
|
||||
@ -20,7 +23,14 @@ const defaultValues: IIssueLink | ModuleLink = {
|
||||
url: "",
|
||||
};
|
||||
|
||||
export const LinkModal: React.FC<Props> = ({ isOpen, handleClose, onFormSubmit }) => {
|
||||
export const LinkModal: React.FC<Props> = ({
|
||||
isOpen,
|
||||
handleClose,
|
||||
createIssueLink,
|
||||
updateIssueLink,
|
||||
status,
|
||||
data,
|
||||
}) => {
|
||||
const {
|
||||
register,
|
||||
formState: { errors, isSubmitting },
|
||||
@ -30,11 +40,6 @@ export const LinkModal: React.FC<Props> = ({ isOpen, handleClose, onFormSubmit }
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const onSubmit = async (formData: IIssueLink | ModuleLink) => {
|
||||
await onFormSubmit({ title: formData.title, url: formData.url });
|
||||
onClose();
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
handleClose();
|
||||
const timeout = setTimeout(() => {
|
||||
@ -43,6 +48,27 @@ export const LinkModal: React.FC<Props> = ({ isOpen, handleClose, onFormSubmit }
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (formData: IIssueLink | ModuleLink) => {
|
||||
if (!data) await createIssueLink({ title: formData.title, url: formData.url });
|
||||
else await updateIssueLink({ title: formData.title, url: formData.url }, data.id);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleCreateUpdatePage = async (formData: IIssueLink | ModuleLink) => {
|
||||
await handleFormSubmit(formData);
|
||||
|
||||
reset({
|
||||
...defaultValues,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
reset({
|
||||
...defaultValues,
|
||||
...data,
|
||||
});
|
||||
}, [data, reset]);
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={React.Fragment}>
|
||||
<Dialog as="div" className="relative z-20" onClose={onClose}>
|
||||
@ -70,14 +96,14 @@ export const LinkModal: React.FC<Props> = ({ isOpen, handleClose, onFormSubmit }
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-custom-background-100 border border-custom-border-200 px-5 py-8 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<form onSubmit={handleSubmit(handleCreateUpdatePage)}>
|
||||
<div>
|
||||
<div className="space-y-5">
|
||||
<Dialog.Title
|
||||
as="h3"
|
||||
className="text-lg font-medium leading-6 text-custom-text-100"
|
||||
>
|
||||
Add Link
|
||||
{status ? "Update Link" : "Add Link"}
|
||||
</Dialog.Title>
|
||||
<div className="mt-2 space-y-3">
|
||||
<div>
|
||||
@ -113,7 +139,13 @@ export const LinkModal: React.FC<Props> = ({ isOpen, handleClose, onFormSubmit }
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<SecondaryButton onClick={onClose}>Cancel</SecondaryButton>
|
||||
<PrimaryButton type="submit" loading={isSubmitting}>
|
||||
{isSubmitting ? "Adding Link..." : "Add Link"}
|
||||
{status
|
||||
? isSubmitting
|
||||
? "Updating Link..."
|
||||
: "Update Link"
|
||||
: isSubmitting
|
||||
? "Adding Link..."
|
||||
: "Add Link"}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
|
@ -1,25 +1,24 @@
|
||||
// icons
|
||||
import { ArrowTopRightOnSquareIcon, LinkIcon, TrashIcon } from "@heroicons/react/24/outline";
|
||||
import { Icon } from "components/ui";
|
||||
// helpers
|
||||
import { timeAgo } from "helpers/date-time.helper";
|
||||
// types
|
||||
import { IUserLite, UserAuth } from "types";
|
||||
import { linkDetails, UserAuth } from "types";
|
||||
|
||||
type Props = {
|
||||
links: {
|
||||
id: string;
|
||||
created_at: Date;
|
||||
created_by: string;
|
||||
created_by_detail: IUserLite;
|
||||
metadata: any;
|
||||
title: string;
|
||||
url: string;
|
||||
}[];
|
||||
links: linkDetails[];
|
||||
handleDeleteLink: (linkId: string) => void;
|
||||
handleEditLink: (link: linkDetails) => void;
|
||||
userAuth: UserAuth;
|
||||
};
|
||||
|
||||
export const LinksList: React.FC<Props> = ({ links, handleDeleteLink, userAuth }) => {
|
||||
export const LinksList: React.FC<Props> = ({
|
||||
links,
|
||||
handleDeleteLink,
|
||||
handleEditLink,
|
||||
userAuth,
|
||||
}) => {
|
||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
|
||||
|
||||
return (
|
||||
@ -28,6 +27,13 @@ export const LinksList: React.FC<Props> = ({ links, handleDeleteLink, userAuth }
|
||||
<div key={link.id} className="relative">
|
||||
{!isNotAllowed && (
|
||||
<div className="absolute top-1.5 right-1.5 z-[1] flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-7 w-7 place-items-center rounded bg-custom-background-90 p-1 outline-none hover:bg-custom-background-80"
|
||||
onClick={() => handleEditLink(link)}
|
||||
>
|
||||
<Icon iconName="edit" className="text-custom-text-200" />
|
||||
</button>
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
|
@ -37,7 +37,7 @@ import { LinkIcon, CalendarDaysIcon, TrashIcon, PlusIcon } from "@heroicons/reac
|
||||
// helpers
|
||||
import { copyTextToClipboard } from "helpers/string.helper";
|
||||
// types
|
||||
import type { ICycle, IIssue, IIssueLink, IModule } from "types";
|
||||
import type { ICycle, IIssue, IIssueLink, linkDetails, IModule } from "types";
|
||||
// fetch-keys
|
||||
import { ISSUE_DETAILS } from "constants/fetch-keys";
|
||||
|
||||
@ -77,6 +77,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
||||
}) => {
|
||||
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
|
||||
const [linkModal, setLinkModal] = useState(false);
|
||||
const [selectedLinkToUpdate, setSelectedLinkToUpdate] = useState<linkDetails | null>(null);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, issueId } = router.query;
|
||||
@ -156,6 +157,43 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateLink = async (formData: IIssueLink, linkId: string) => {
|
||||
if (!workspaceSlug || !projectId || !issueDetail) return;
|
||||
|
||||
const payload = { metadata: {}, ...formData };
|
||||
|
||||
const updatedLinks = issueDetail.issue_link.map((l) =>
|
||||
l.id === linkId
|
||||
? {
|
||||
...l,
|
||||
title: formData.title,
|
||||
url: formData.url,
|
||||
}
|
||||
: l
|
||||
);
|
||||
|
||||
mutate<IIssue>(
|
||||
ISSUE_DETAILS(issueDetail.id),
|
||||
(prevData) => ({ ...(prevData as IIssue), issue_link: updatedLinks }),
|
||||
false
|
||||
);
|
||||
|
||||
await issuesService
|
||||
.updateIssueLink(
|
||||
workspaceSlug as string,
|
||||
projectId as string,
|
||||
issueDetail.id,
|
||||
linkId,
|
||||
payload
|
||||
)
|
||||
.then((res) => {
|
||||
mutate(ISSUE_DETAILS(issueDetail.id));
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteLink = async (linkId: string) => {
|
||||
if (!workspaceSlug || !projectId || !issueDetail) return;
|
||||
|
||||
@ -220,14 +258,25 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
||||
const maxDate = targetDate ? new Date(targetDate) : null;
|
||||
maxDate?.setDate(maxDate.getDate());
|
||||
|
||||
const handleEditLink = (link: linkDetails) => {
|
||||
setSelectedLinkToUpdate(link);
|
||||
setLinkModal(true);
|
||||
};
|
||||
|
||||
const isNotAllowed = memberRole.isGuest || memberRole.isViewer;
|
||||
|
||||
return (
|
||||
<>
|
||||
<LinkModal
|
||||
isOpen={linkModal}
|
||||
handleClose={() => setLinkModal(false)}
|
||||
onFormSubmit={handleCreateLink}
|
||||
handleClose={() => {
|
||||
setLinkModal(false);
|
||||
setSelectedLinkToUpdate(null);
|
||||
}}
|
||||
data={selectedLinkToUpdate}
|
||||
status={selectedLinkToUpdate ? true : false}
|
||||
createIssueLink={handleCreateLink}
|
||||
updateIssueLink={handleUpdateLink}
|
||||
/>
|
||||
<DeleteIssueModal
|
||||
handleClose={() => setDeleteIssueModal(false)}
|
||||
@ -490,6 +539,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
||||
<LinksList
|
||||
links={issueDetail.issue_link}
|
||||
handleDeleteLink={handleDeleteLink}
|
||||
handleEditLink={handleEditLink}
|
||||
userAuth={memberRole}
|
||||
/>
|
||||
) : null}
|
||||
|
@ -37,7 +37,7 @@ import { LinkIcon } from "@heroicons/react/20/solid";
|
||||
import { renderDateFormat, renderShortDateWithYearFormat } from "helpers/date-time.helper";
|
||||
import { capitalizeFirstLetter, copyTextToClipboard } from "helpers/string.helper";
|
||||
// types
|
||||
import { ICurrentUserResponse, IIssue, IModule, ModuleLink } from "types";
|
||||
import { ICurrentUserResponse, IIssue, linkDetails, IModule, ModuleLink } from "types";
|
||||
// fetch-keys
|
||||
import { MODULE_DETAILS } from "constants/fetch-keys";
|
||||
// constant
|
||||
@ -61,6 +61,7 @@ type Props = {
|
||||
export const ModuleDetailsSidebar: React.FC<Props> = ({ module, isOpen, moduleIssues, user }) => {
|
||||
const [moduleDeleteModal, setModuleDeleteModal] = useState(false);
|
||||
const [moduleLinkModal, setModuleLinkModal] = useState(false);
|
||||
const [selectedLinkToUpdate, setSelectedLinkToUpdate] = useState<linkDetails | null>(null);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, moduleId } = router.query;
|
||||
@ -115,6 +116,37 @@ export const ModuleDetailsSidebar: React.FC<Props> = ({ module, isOpen, moduleIs
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateLink = async (formData: ModuleLink, linkId: string) => {
|
||||
if (!workspaceSlug || !projectId || !module) return;
|
||||
|
||||
const payload = { metadata: {}, ...formData };
|
||||
|
||||
const updatedLinks = module.link_module.map((l) =>
|
||||
l.id === linkId
|
||||
? {
|
||||
...l,
|
||||
title: formData.title,
|
||||
url: formData.url,
|
||||
}
|
||||
: l
|
||||
);
|
||||
|
||||
mutate<IModule>(
|
||||
MODULE_DETAILS(module.id),
|
||||
(prevData) => ({ ...(prevData as IModule), link_module: updatedLinks }),
|
||||
false
|
||||
);
|
||||
|
||||
await modulesService
|
||||
.updateModuleLink(workspaceSlug as string, projectId as string, module.id, linkId, payload)
|
||||
.then((res) => {
|
||||
mutate(MODULE_DETAILS(module.id));
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteLink = async (linkId: string) => {
|
||||
if (!workspaceSlug || !projectId || !module) return;
|
||||
|
||||
@ -170,12 +202,23 @@ export const ModuleDetailsSidebar: React.FC<Props> = ({ module, isOpen, moduleIs
|
||||
? Math.round((module.completed_issues / module.total_issues) * 100)
|
||||
: null;
|
||||
|
||||
const handleEditLink = (link: linkDetails) => {
|
||||
setSelectedLinkToUpdate(link);
|
||||
setModuleLinkModal(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<LinkModal
|
||||
isOpen={moduleLinkModal}
|
||||
handleClose={() => setModuleLinkModal(false)}
|
||||
onFormSubmit={handleCreateLink}
|
||||
handleClose={() => {
|
||||
setModuleLinkModal(false);
|
||||
setSelectedLinkToUpdate(null);
|
||||
}}
|
||||
data={selectedLinkToUpdate}
|
||||
status={selectedLinkToUpdate ? true : false}
|
||||
createIssueLink={handleCreateLink}
|
||||
updateIssueLink={handleUpdateLink}
|
||||
/>
|
||||
<DeleteModuleModal
|
||||
isOpen={moduleDeleteModal}
|
||||
@ -544,7 +587,7 @@ export const ModuleDetailsSidebar: React.FC<Props> = ({ module, isOpen, moduleIs
|
||||
</Disclosure>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col border-t border-custom-border-200 px-6 py-6 text-xs">
|
||||
<div className="flex w-full flex-col border-t border-custom-border-200 px-6 pt-6 pb-10 text-xs">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<h4 className="text-sm font-medium text-custom-text-200">Links</h4>
|
||||
<button
|
||||
@ -558,6 +601,7 @@ export const ModuleDetailsSidebar: React.FC<Props> = ({ module, isOpen, moduleIs
|
||||
{memberRole && module.link_module && module.link_module.length > 0 ? (
|
||||
<LinksList
|
||||
links={module.link_module}
|
||||
handleEditLink={handleEditLink}
|
||||
handleDeleteLink={handleDeleteLink}
|
||||
userAuth={memberRole}
|
||||
/>
|
||||
|
@ -459,6 +459,29 @@ class ProjectIssuesServices extends APIService {
|
||||
});
|
||||
}
|
||||
|
||||
async updateIssueLink(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
linkId: string,
|
||||
data: {
|
||||
metadata: any;
|
||||
title: string;
|
||||
url: string;
|
||||
},
|
||||
|
||||
): Promise<any> {
|
||||
return this.patch(
|
||||
`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/issue-links/${linkId}/`,
|
||||
data
|
||||
)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
async deleteIssueLink(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
|
@ -212,6 +212,28 @@ class ProjectIssuesServices extends APIService {
|
||||
});
|
||||
}
|
||||
|
||||
async updateModuleLink(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
moduleId: string,
|
||||
linkId: string,
|
||||
data: {
|
||||
metadata: any;
|
||||
title: string;
|
||||
url: string;
|
||||
},
|
||||
|
||||
): Promise<any> {
|
||||
return this.patch(
|
||||
`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/module-links/${linkId}/`,
|
||||
data
|
||||
)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response;
|
||||
});
|
||||
}
|
||||
|
||||
async deleteModuleLink(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
|
20
apps/app/types/issues.d.ts
vendored
20
apps/app/types/issues.d.ts
vendored
@ -56,6 +56,16 @@ export interface IIssueLink {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface linkDetails {
|
||||
created_at: Date;
|
||||
created_by: string;
|
||||
created_by_detail: IUserLite;
|
||||
id: string;
|
||||
metadata: any;
|
||||
title: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface IIssue {
|
||||
archived_at: string;
|
||||
assignees: string[];
|
||||
@ -80,15 +90,7 @@ export interface IIssue {
|
||||
estimate_point: number | null;
|
||||
id: string;
|
||||
issue_cycle: IIssueCycle | null;
|
||||
issue_link: {
|
||||
created_at: Date;
|
||||
created_by: string;
|
||||
created_by_detail: IUserLite;
|
||||
id: string;
|
||||
metadata: any;
|
||||
title: string;
|
||||
url: string;
|
||||
}[];
|
||||
issue_link: linkDetails[];
|
||||
issue_module: IIssueModule | null;
|
||||
labels: string[];
|
||||
label_details: any[];
|
||||
|
11
apps/app/types/modules.d.ts
vendored
11
apps/app/types/modules.d.ts
vendored
@ -7,6 +7,7 @@ import type {
|
||||
IWorkspaceLite,
|
||||
IProjectLite,
|
||||
IIssueFilterOptions,
|
||||
linkDetails,
|
||||
} from "types";
|
||||
|
||||
export interface IModule {
|
||||
@ -26,15 +27,7 @@ export interface IModule {
|
||||
id: string;
|
||||
lead: string | null;
|
||||
lead_detail: IUserLite | null;
|
||||
link_module: {
|
||||
created_at: Date;
|
||||
created_by: string;
|
||||
created_by_detail: IUserLite;
|
||||
id: string;
|
||||
metadata: any;
|
||||
title: string;
|
||||
url: string;
|
||||
}[];
|
||||
link_module: linkDetails[];
|
||||
links_list: ModuleLink[];
|
||||
members: string[];
|
||||
members_list: string[];
|
||||
|
Loading…
Reference in New Issue
Block a user