feat: auto-archive and auto-close (#1502)

* chore: issue archive services and types added

* chore: project type and constant updated

* feat: auto-close and auto-archive feature added

* feat: implement rendering of archived issues

* feat: implemented rendering of only list view for archived issues , chore: update types and services

* feat: implemented archive issue detail page and unarchive issue functionality , chore: refactor code

* feat: activity for issue archive and issue restore added

* fix: redirection and delete fix

* fix: merge conflict

* fix: restore issue redirection fix

* fix: disable modification of issue properties for archived issues, style: disable properties styling

* fix: hide empty group, switch to list view on redirct to archived issues

* fix: remove unnecessary header buttons for archived issue

* fix: auto-close dropdown fix
This commit is contained in:
Anmol Singh Bhatia 2023-07-13 11:34:37 +05:30 committed by GitHub
parent 275942a246
commit c9cbca5ec8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 1151 additions and 88 deletions

View File

@ -0,0 +1,96 @@
import React, { useState } from "react";
// component
import { CustomSelect, ToggleSwitch } from "components/ui";
import { SelectMonthModal } from "components/automation";
// icons
import { ChevronDownIcon } from "@heroicons/react/24/outline";
// constants
import { PROJECT_AUTOMATION_MONTHS } from "constants/project";
// types
import { IProject } from "types";
type Props = {
projectDetails: IProject | undefined;
handleChange: (formData: Partial<IProject>) => Promise<void>;
};
export const AutoArchiveAutomation: React.FC<Props> = ({ projectDetails, handleChange }) => {
const [monthModal, setmonthModal] = useState(false);
const initialValues: Partial<IProject> = { archive_in: 1 };
return (
<>
<SelectMonthModal
type="auto-archive"
initialValues={initialValues}
isOpen={monthModal}
handleClose={() => setmonthModal(false)}
handleChange={handleChange}
/>
<div className="flex flex-col gap-7 px-6 py-5 rounded-[10px] border border-brand-base bg-brand-base">
<div className="flex items-center justify-between gap-x-8 gap-y-2 ">
<div className="flex flex-col gap-2.5">
<h4 className="text-lg font-semibold">Auto-archive closed issues</h4>
<p className="text-sm text-brand-secondary">
Plane will automatically archive issues that have been completed or canceled for the
configured time period
</p>
</div>
<ToggleSwitch
value={projectDetails?.archive_in !== 0}
onChange={() => {
if (projectDetails?.archive_in === 0) {
handleChange({ archive_in: 1 });
} else {
handleChange({ archive_in: 0 });
}
}}
size="sm"
/>
</div>
{projectDetails?.archive_in !== 0 && (
<div className="flex items-center justify-between gap-2 w-full">
<div className="w-1/2 text-base font-medium">
Auto-archive issues that are closed for
</div>
<div className="w-1/2 ">
<CustomSelect
value={projectDetails?.archive_in}
customButton={
<button className="flex w-full items-center justify-between gap-1 rounded-md border border-brand-base shadow-sm duration-300 text-brand-secondary hover:text-brand-base hover:bg-brand-surface-2 focus:outline-none px-3 py-2 text-sm text-left">
{`${projectDetails?.archive_in} Months`}
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
</button>
}
onChange={(val: number) => {
handleChange({ archive_in: val });
}}
input
width="w-full"
>
<>
{PROJECT_AUTOMATION_MONTHS.map((month) => (
<CustomSelect.Option key={month.label} value={month.value}>
{month.label}
</CustomSelect.Option>
))}
<button
type="button"
className="flex w-full select-none items-center rounded py-2 px-1 hover:bg-custom-background-80"
onClick={() => setmonthModal(true)}
>
<span className="flex items-center justify-start gap-1 text-custom-text-200">
<span>Customize Time Range</span>
</span>
</button>
</>
</CustomSelect>
</div>
</div>
)}
</div>
</>
);
};

View File

@ -0,0 +1,193 @@
import React, { useState } from "react";
import useSWR from "swr";
import { useRouter } from "next/router";
// component
import { CustomSearchSelect, CustomSelect, ToggleSwitch } from "components/ui";
import { SelectMonthModal } from "components/automation";
// icons
import { ChevronDownIcon, Squares2X2Icon } from "@heroicons/react/24/outline";
import { getStateGroupIcon } from "components/icons";
// services
import stateService from "services/state.service";
// constants
import { PROJECT_AUTOMATION_MONTHS } from "constants/project";
import { STATES_LIST } from "constants/fetch-keys";
// types
import { IProject } from "types";
// helper
import { getStatesList } from "helpers/state.helper";
type Props = {
projectDetails: IProject | undefined;
handleChange: (formData: Partial<IProject>) => Promise<void>;
};
export const AutoCloseAutomation: React.FC<Props> = ({ projectDetails, handleChange }) => {
const [monthModal, setmonthModal] = 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 options = states
?.filter((state) => state.group === "cancelled")
.map((state) => ({
value: state.id,
query: state.name,
content: (
<div className="flex items-center gap-2">
{getStateGroupIcon(state.group, "16", "16", state.color)}
{state.name}
</div>
),
}));
const multipleOptions = options.length > 1;
const defaultState = stateGroups && stateGroups.cancelled ? stateGroups.cancelled[0].id : null;
const selectedOption = states?.find(
(s) => s.id === projectDetails?.default_state ?? defaultState
);
const currentDefaultState = states.find((s) => s.id === defaultState);
const initialValues: Partial<IProject> = {
close_in: 1,
default_state: defaultState,
};
return (
<>
<SelectMonthModal
type="auto-close"
initialValues={initialValues}
isOpen={monthModal}
handleClose={() => setmonthModal(false)}
handleChange={handleChange}
/>
<div className="flex flex-col gap-7 px-6 py-5 rounded-[10px] border border-brand-base bg-brand-base">
<div className="flex items-center justify-between gap-x-8 gap-y-2 ">
<div className="flex flex-col gap-2.5">
<h4 className="text-lg font-semibold">Auto-close inactive issues</h4>
<p className="text-sm text-brand-secondary">
Plane will automatically close the issues that have not been updated for the
configured time period.
</p>
</div>
<ToggleSwitch
value={projectDetails?.close_in !== 0}
onChange={() => {
if (projectDetails?.close_in === 0) {
handleChange({ close_in: 1, default_state: defaultState });
} else {
handleChange({ close_in: 0, default_state: null });
}
}}
size="sm"
/>
</div>
{projectDetails?.close_in !== 0 && (
<div className="flex flex-col gap-4 w-full">
<div className="flex items-center justify-between gap-2 w-full">
<div className="w-1/2 text-base font-medium">
Auto-close issues that are inactive for
</div>
<div className="w-1/2 ">
<CustomSelect
value={projectDetails?.close_in}
customButton={
<button className="flex w-full items-center justify-between gap-1 rounded-md border border-brand-base shadow-sm duration-300 text-brand-secondary hover:text-brand-base hover:bg-brand-surface-2 focus:outline-none px-3 py-2 text-sm text-left">
{`${projectDetails?.close_in} Months`}
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
</button>
}
onChange={(val: number) => {
handleChange({ close_in: val });
}}
input
width="w-full"
>
<>
{PROJECT_AUTOMATION_MONTHS.map((month) => (
<CustomSelect.Option key={month.label} value={month.value}>
{month.label}
</CustomSelect.Option>
))}
<button
type="button"
className="flex w-full select-none items-center rounded py-2 px-1 hover:bg-custom-background-80"
onClick={() => setmonthModal(true)}
>
<span className="flex items-center justify-start gap-1 text-custom-text-200">
<span>Customize Time Range</span>
</span>
</button>
</>
</CustomSelect>
</div>
</div>
<div className="flex items-center justify-between gap-2 w-full">
<div className="w-1/2 text-base font-medium">Auto-close Status</div>
<div className="w-1/2 ">
<CustomSearchSelect
value={
projectDetails?.default_state ? projectDetails?.default_state : defaultState
}
customButton={
<button
className={`flex w-full items-center justify-between gap-1 rounded-md border border-brand-base shadow-sm duration-300 text-brand-secondary hover:text-brand-base hover:bg-brand-surface-2 focus:outline-none px-3 py-2 text-sm text-left ${
!multipleOptions ? "opacity-60" : ""
}`}
>
<div className="flex items-center gap-2">
{selectedOption ? (
getStateGroupIcon(selectedOption.group, "16", "16", selectedOption.color)
) : currentDefaultState ? (
getStateGroupIcon(
currentDefaultState.group,
"16",
"16",
currentDefaultState.color
)
) : (
<Squares2X2Icon className="h-3.5 w-3.5 text-custom-text-200" />
)}
{selectedOption?.name
? selectedOption.name
: currentDefaultState?.name ?? (
<span className="text-custom-text-200">State</span>
)}
</div>
{multipleOptions && (
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
)}
</button>
}
onChange={(val: string) => {
handleChange({ default_state: val });
}}
options={options}
disabled={!multipleOptions}
dropdownWidth="w-full"
/>
</div>
</div>
</div>
)}
</div>
</>
);
};

View File

@ -0,0 +1,3 @@
export * from "./auto-close-automation";
export * from "./auto-archive-automation";
export * from "./select-month-modal";

View File

@ -0,0 +1,147 @@
import React from "react";
import { useRouter } from "next/router";
// react-hook-form
import { useForm } from "react-hook-form";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// ui
import { Input, PrimaryButton, SecondaryButton } from "components/ui";
// types
import type { IProject } from "types";
// types
type Props = {
isOpen: boolean;
type: "auto-close" | "auto-archive";
initialValues: Partial<IProject>;
handleClose: () => void;
handleChange: (formData: Partial<IProject>) => Promise<void>;
};
export const SelectMonthModal: React.FC<Props> = ({
type,
initialValues,
isOpen,
handleClose,
handleChange,
}) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const {
register,
formState: { errors, isSubmitting },
handleSubmit,
reset,
} = useForm<IProject>({
defaultValues: initialValues,
});
const onClose = () => {
handleClose();
reset(initialValues);
};
const onSubmit = (formData: Partial<IProject>) => {
if (!workspaceSlug && !projectId) return;
handleChange(formData);
onClose();
};
const inputSection = (name: string) => (
<div className="relative flex flex-col gap-1 justify-center w-full">
<Input
type="number"
id={name}
name={name}
placeholder="Enter Months"
autoComplete="off"
register={register}
width="full"
validations={{
required: "Select a month between 1 and 12.",
min: 1,
max: 12,
}}
style={{ appearance: "none" }}
/>
<span className="absolute text-sm text-custom-text-200 top-2.5 right-8">Months</span>
</div>
);
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-30" onClose={onClose}>
<Transition.Child
as={React.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-[#131313] bg-opacity-50 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<Transition.Child
as={React.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 rounded-lg bg-custom-background-90 px-4 pt-5 pb-4 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<Dialog.Title
as="h3"
className="text-lg font-medium leading-6 text-custom-text-100"
>
Customize Time Range
</Dialog.Title>
<div className="mt-8 flex items-center gap-2">
<div className="flex w-full flex-col gap-1 justify-center">
{type === "auto-close" ? (
<>
{inputSection("close_in")}
{errors.close_in && (
<span className="text-sm px-1 text-red-500">
Select a month between 1 and 12.
</span>
)}
</>
) : (
<>
{inputSection("archive_in")}
{errors.archive_in && (
<span className="text-sm px-1 text-red-500">
Select a month between 1 and 12.
</span>
)}
</>
)}
</div>
</div>
</div>
<div className="mt-5 flex justify-end gap-2">
<SecondaryButton onClick={onClose}>Cancel</SecondaryButton>
<PrimaryButton type="submit" loading={isSubmitting}>
{isSubmitting ? "Submitting..." : "Submit"}
</PrimaryButton>
</div>
</form>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};

View File

@ -23,6 +23,7 @@ import { renderShortDateWithYearFormat, timeAgo } from "helpers/date-time.helper
import { addSpaceIfCamelCase } from "helpers/string.helper";
// types
import RemirrorRichTextEditor from "components/rich-text-editor";
import { Icon } from "components/ui";
const activityDetails: {
[key: string]: {
@ -105,6 +106,10 @@ const activityDetails: {
message: "updated the attachment",
icon: <PaperClipIcon className="h-3 w-3 text-custom-text-200 " aria-hidden="true" />,
},
archived_at: {
message: "archived",
icon: <Icon iconName="archive" className="text-sm text-custom-text-200" aria-hidden="true" />,
},
};
export const Feeds: React.FC<any> = ({ activities }) => (
@ -144,6 +149,11 @@ export const Feeds: React.FC<any> = ({ activities }) => (
action = `${activity.verb} the`;
} else if (activity.field === "link") {
action = `${activity.verb} the`;
} else if (activity.field === "archived_at") {
action =
activity.new_value && activity.new_value === "restore"
? "restored the issue"
: "archived the issue";
}
// for values that are after the action clause
let value: any = activity.new_value ? activity.new_value : activity.old_value;
@ -205,7 +215,13 @@ export const Feeds: React.FC<any> = ({ activities }) => (
<div key={activity.id} className="mt-2">
<div className="relative flex items-start space-x-3">
<div className="relative px-1">
{activity.actor_detail.avatar && activity.actor_detail.avatar !== "" ? (
{activity.field ? (
activity.new_value === "restore" ? (
<Icon iconName="history" className="text-sm text-custom-text-200" />
) : (
activityDetails[activity.field as keyof typeof activityDetails]?.icon
)
) : activity.actor_detail.avatar && activity.actor_detail.avatar !== "" ? (
<img
src={activity.actor_detail.avatar}
alt={activity.actor_detail.first_name}
@ -296,14 +312,23 @@ export const Feeds: React.FC<any> = ({ activities }) => (
</div>
<div className="min-w-0 flex-1 py-3">
<div className="text-xs text-custom-text-200">
<span className="text-gray font-medium">
{activity.actor_detail.first_name}
{activity.actor_detail.is_bot
? " Bot"
: " " + activity.actor_detail.last_name}
</span>
{activity.field === "archived_at" && activity.new_value !== "restore" ? (
<span className="text-gray font-medium">Plane</span>
) : (
<span className="text-gray font-medium">
{activity.actor_detail.first_name}
{activity.actor_detail.is_bot
? " Bot"
: " " + activity.actor_detail.last_name}
</span>
)}
<span> {action} </span>
<span className="text-xs font-medium text-custom-text-100"> {value} </span>
{activity.field !== "archived_at" && (
<span className="text-xs font-medium text-custom-text-100">
{" "}
{value}{" "}
</span>
)}
<span className="whitespace-nowrap">{timeAgo(activity.created_at)}</span>
</div>
</div>

View File

@ -53,6 +53,7 @@ const issueViewOptions: { type: TIssueViewOptions; icon: any }[] = [
export const IssuesFilterView: React.FC = () => {
const router = useRouter();
const { workspaceSlug, projectId, viewId } = router.query;
const isArchivedIssues = router.pathname.includes("archived-issues");
const {
issueView,
@ -78,22 +79,24 @@ export const IssuesFilterView: React.FC = () => {
return (
<div className="flex items-center gap-2">
<div className="flex items-center gap-x-1">
{issueViewOptions.map((option) => (
<button
key={option.type}
type="button"
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none hover:bg-custom-sidebar-background-80 duration-300 ${
issueView === option.type
? "bg-custom-sidebar-background-80"
: "text-custom-sidebar-text-200"
}`}
onClick={() => setIssueView(option.type)}
>
{option.icon}
</button>
))}
</div>
{!isArchivedIssues && (
<div className="flex items-center gap-x-1">
{issueViewOptions.map((option) => (
<button
key={option.type}
type="button"
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none hover:bg-custom-sidebar-background-80 duration-300 ${
issueView === option.type
? "bg-custom-sidebar-background-80"
: "text-custom-sidebar-text-200"
}`}
onClick={() => setIssueView(option.type)}
>
{option.icon}
</button>
))}
</div>
)}
<SelectFilters
filters={filters}
onSelect={(option) => {

View File

@ -84,6 +84,7 @@ export const SingleListIssue: React.FC<Props> = ({
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query;
const isArchivedIssues = router.pathname.includes("archived-issues");
const { setToastAlert } = useToast();
@ -181,7 +182,11 @@ export const SingleListIssue: React.FC<Props> = ({
});
};
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || isCompleted;
const singleIssuePath = isArchivedIssues
? `/${workspaceSlug}/projects/${projectId}/archived-issues/${issue.id}`
: `/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`;
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || isCompleted || isArchivedIssues;
return (
<>
@ -207,11 +212,7 @@ export const SingleListIssue: React.FC<Props> = ({
<ContextMenu.Item Icon={LinkIcon} onClick={handleCopyText}>
Copy issue link
</ContextMenu.Item>
<a
href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`}
target="_blank"
rel="noreferrer noopener"
>
<a href={singleIssuePath} target="_blank" rel="noreferrer noopener">
<ContextMenu.Item Icon={ArrowTopRightOnSquareIcon}>
Open issue in new tab
</ContextMenu.Item>
@ -225,7 +226,7 @@ export const SingleListIssue: React.FC<Props> = ({
setContextMenuPosition({ x: e.pageX, y: e.pageY });
}}
>
<Link href={`/${workspaceSlug}/projects/${issue?.project_detail?.id}/issues/${issue.id}`}>
<Link href={singleIssuePath}>
<div className="flex-grow cursor-pointer">
<a className="group relative flex items-center gap-2">
{properties.key && (
@ -247,7 +248,11 @@ export const SingleListIssue: React.FC<Props> = ({
</div>
</Link>
<div className="flex w-full flex-shrink flex-wrap items-center gap-2 text-xs sm:w-auto">
<div
className={`flex w-full flex-shrink flex-wrap items-center gap-2 text-xs sm:w-auto ${
isArchivedIssues ? "opacity-60" : ""
}`}
>
{properties.priority && (
<ViewPrioritySelect
issue={issue}

View File

@ -67,6 +67,7 @@ export const SingleList: React.FC<Props> = ({
}) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const isArchivedIssues = router.pathname.includes("archived-issues");
const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string);
@ -159,7 +160,9 @@ export const SingleList: React.FC<Props> = ({
</span>
</div>
</Disclosure.Button>
{type === "issue" ? (
{isArchivedIssues ? (
""
) : type === "issue" ? (
<button
type="button"
className="p-1 text-custom-text-200 hover:bg-custom-background-80"

View File

@ -11,7 +11,7 @@ import useEstimateOption from "hooks/use-estimate-option";
// components
import { CommentCard } from "components/issues/comment";
// ui
import { Loader } from "components/ui";
import { Icon, Loader } from "components/ui";
// icons
import {
CalendarDaysIcon,
@ -110,6 +110,10 @@ const activityDetails: {
message: "updated the attachment",
icon: <PaperClipIcon className="h-3 w-3 text-custom-text-200" aria-hidden="true" />,
},
archived_at: {
message: "archived",
icon: <Icon iconName="archive" className="text-sm text-custom-text-200" aria-hidden="true" />,
},
};
type Props = {
@ -253,6 +257,11 @@ export const IssueActivitySection: React.FC<Props> = ({ issueId, user }) => {
activityItem.new_value && activityItem.new_value !== ""
? "set the module to"
: "removed the module";
} else if (activityItem.field === "archived_at") {
action =
activityItem.new_value && activityItem.new_value === "restore"
? "restored the issue"
: "archived the issue";
}
// for values that are after the action clause
let value: any = activityItem.new_value ? activityItem.new_value : activityItem.old_value;
@ -345,8 +354,16 @@ export const IssueActivitySection: React.FC<Props> = ({ issueId, user }) => {
<div className="mt-1.5">
<div className="ring-6 flex h-7 w-7 items-center justify-center rounded-full bg-custom-background-80 ring-white">
{activityItem.field ? (
activityDetails[activityItem.field as keyof typeof activityDetails]
?.icon
activityItem.new_value === "restore" ? (
<Icon
iconName="history"
className="text-sm text-custom-text-200"
/>
) : (
activityDetails[
activityItem.field as keyof typeof activityDetails
]?.icon
)
) : activityItem.actor_detail.avatar &&
activityItem.actor_detail.avatar !== "" ? (
<img
@ -369,17 +386,24 @@ export const IssueActivitySection: React.FC<Props> = ({ issueId, user }) => {
</div>
<div className="min-w-0 flex-1 py-3">
<div className="text-xs text-custom-text-200">
<span className="text-gray font-medium">
{activityItem.actor_detail.first_name}
{activityItem.actor_detail.is_bot
? " Bot"
: " " + activityItem.actor_detail.last_name}
</span>
{activityItem.field === "archived_at" &&
activityItem.new_value !== "restore" ? (
<span className="text-gray font-medium">Plane</span>
) : (
<span className="text-gray font-medium">
{activityItem.actor_detail.first_name}
{activityItem.actor_detail.is_bot
? " Bot"
: " " + activityItem.actor_detail.last_name}
</span>
)}
<span> {action} </span>
<span className="text-xs font-medium text-custom-text-100">
{" "}
{value}{" "}
</span>
{activityItem.field !== "archived_at" && (
<span className="text-xs font-medium text-custom-text-100">
{" "}
{value}{" "}
</span>
)}
<span className="whitespace-nowrap">
{timeAgo(activityItem.created_at)}
</span>

View File

@ -17,7 +17,11 @@ import { IIssueAttachment } from "types";
const maxFileSize = 5 * 1024 * 1024; // 5 MB
export const IssueAttachmentUpload = () => {
type Props = {
disabled?: boolean;
};
export const IssueAttachmentUpload: React.FC<Props> = ({ disabled = false }) => {
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
@ -74,7 +78,7 @@ export const IssueAttachmentUpload = () => {
onDrop,
maxSize: maxFileSize,
multiple: false,
disabled: isLoading,
disabled: isLoading || disabled,
});
const fileError =
@ -85,9 +89,9 @@ export const IssueAttachmentUpload = () => {
return (
<div
{...getRootProps()}
className={`flex items-center justify-center h-[60px] cursor-pointer border-2 border-dashed text-custom-primary bg-custom-primary/5 text-xs rounded-md px-4 ${
className={`flex items-center justify-center h-[60px] border-2 border-dashed text-custom-primary bg-custom-primary/5 text-xs rounded-md px-4 ${
isDragActive ? "bg-custom-primary/10 border-custom-primary" : "border-custom-border-100"
} ${isDragReject ? "bg-red-100" : ""}`}
} ${isDragReject ? "bg-red-100" : ""} ${disabled ? "cursor-not-allowed" : "cursor-pointer"}`}
>
<input {...getInputProps()} />
<span className="flex items-center gap-2">

View File

@ -43,9 +43,10 @@ const defaultValues: Partial<IIssueComment> = {
type Props = {
issueId: string;
user: ICurrentUserResponse | undefined;
disabled?: boolean;
};
export const AddComment: React.FC<Props> = ({ issueId, user }) => {
export const AddComment: React.FC<Props> = ({ issueId, user, disabled = false }) => {
const {
handleSubmit,
control,
@ -111,7 +112,7 @@ export const AddComment: React.FC<Props> = ({ issueId, user }) => {
)}
/>
<SecondaryButton type="submit" disabled={isSubmitting} className="mt-2">
<SecondaryButton type="submit" disabled={isSubmitting || disabled} className="mt-2">
{isSubmitting ? "Adding..." : "Comment"}
</SecondaryButton>
</div>

View File

@ -23,6 +23,7 @@ import type { IIssue, ICurrentUserResponse, ISubIssueResponse } from "types";
import {
CYCLE_ISSUES_WITH_PARAMS,
MODULE_ISSUES_WITH_PARAMS,
PROJECT_ARCHIVED_ISSUES_LIST_WITH_PARAMS,
PROJECT_ISSUES_LIST_WITH_PARAMS,
SUB_ISSUES,
VIEW_ISSUES,
@ -40,6 +41,7 @@ export const DeleteIssueModal: React.FC<Props> = ({ isOpen, handleClose, data, u
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query;
const isArchivedIssues = router.pathname.includes("archived-issues");
const { issueView, params } = useIssuesView();
const { params: calendarParams } = useCalendarIssuesView();
@ -126,6 +128,28 @@ export const DeleteIssueModal: React.FC<Props> = ({ isOpen, handleClose, data, u
});
};
const handleArchivedIssueDeletion = async () => {
setIsDeleteLoading(true);
if (!workspaceSlug || !projectId || !data) return;
await issueServices
.deleteArchivedIssue(workspaceSlug as string, projectId as string, data.id)
.then(() => {
mutate(PROJECT_ARCHIVED_ISSUES_LIST_WITH_PARAMS(projectId as string, params));
handleClose();
setToastAlert({
title: "Success",
type: "success",
message: "Issue deleted successfully",
});
router.push(`/${workspaceSlug}/projects/${projectId}/archived-issues/`);
})
.catch((error) => {
console.log(error);
setIsDeleteLoading(false);
});
};
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={onClose}>
@ -177,7 +201,13 @@ export const DeleteIssueModal: React.FC<Props> = ({ isOpen, handleClose, data, u
</span>
<div className="flex justify-end gap-2">
<SecondaryButton onClick={onClose}>Cancel</SecondaryButton>
<DangerButton onClick={handleDeletion} loading={isDeleteLoading}>
<DangerButton
onClick={() => {
if (isArchivedIssues) handleArchivedIssueDeletion();
else handleDeletion();
}}
loading={isDeleteLoading}
>
{isDeleteLoading ? "Deleting..." : "Delete Issue"}
</DangerButton>
</div>

View File

@ -28,11 +28,16 @@ import { SUB_ISSUES } from "constants/fetch-keys";
type Props = {
issueDetails: IIssue;
submitChanges: (formData: Partial<IIssue>) => Promise<void>;
nonEditable?: boolean;
};
export const IssueMainContent: React.FC<Props> = ({ issueDetails, submitChanges }) => {
export const IssueMainContent: React.FC<Props> = ({
issueDetails,
submitChanges,
nonEditable = false,
}) => {
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
const { workspaceSlug, projectId, issueId, archivedIssueId } = router.query;
const { user } = useUserAuth();
const { memberRole } = useProjectMyMembership();
@ -95,23 +100,30 @@ export const IssueMainContent: React.FC<Props> = ({ issueDetails, submitChanges
<IssueDescriptionForm
issue={issueDetails}
handleFormSubmit={submitChanges}
isAllowed={memberRole.isMember || memberRole.isOwner}
isAllowed={memberRole.isMember || memberRole.isOwner || !nonEditable}
/>
<div className="mt-2 space-y-2">
<SubIssuesList parentIssue={issueDetails} user={user} />
<SubIssuesList parentIssue={issueDetails} user={user} disabled={nonEditable} />
</div>
</div>
<div className="flex flex-col gap-3 py-3">
<h3 className="text-lg">Attachments</h3>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
<IssueAttachmentUpload />
<IssueAttachmentUpload disabled={nonEditable} />
<IssueAttachments />
</div>
</div>
<div className="space-y-5 pt-3">
<h3 className="text-lg text-custom-text-100">Comments/Activity</h3>
<IssueActivitySection issueId={issueId as string} user={user} />
<AddComment issueId={issueId as string} user={user} />
<IssueActivitySection
issueId={(archivedIssueId as string) ?? (issueId as string)}
user={user}
/>
<AddComment
issueId={(archivedIssueId as string) ?? (issueId as string)}
user={user}
disabled={nonEditable}
/>
</div>
</>
);

View File

@ -20,9 +20,15 @@ type Props = {
value: string[];
onChange: (val: string[]) => void;
userAuth: UserAuth;
disabled?: boolean;
};
export const SidebarAssigneeSelect: React.FC<Props> = ({ value, onChange, userAuth }) => {
export const SidebarAssigneeSelect: React.FC<Props> = ({
value,
onChange,
userAuth,
disabled = false,
}) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
@ -53,7 +59,7 @@ export const SidebarAssigneeSelect: React.FC<Props> = ({ value, onChange, userAu
),
}));
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disabled;
return (
<div className="flex flex-wrap items-center py-2">

View File

@ -21,6 +21,7 @@ type Props = {
submitChanges: (formData: Partial<IIssue>) => void;
watch: UseFormWatch<IIssue>;
userAuth: UserAuth;
disabled?: boolean;
};
export const SidebarBlockedSelect: React.FC<Props> = ({
@ -28,6 +29,7 @@ export const SidebarBlockedSelect: React.FC<Props> = ({
submitChanges,
watch,
userAuth,
disabled = false,
}) => {
const [isBlockedModalOpen, setIsBlockedModalOpen] = useState(false);
@ -69,7 +71,7 @@ export const SidebarBlockedSelect: React.FC<Props> = ({
handleClose();
};
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disabled;
return (
<>

View File

@ -21,6 +21,7 @@ type Props = {
submitChanges: (formData: Partial<IIssue>) => void;
watch: UseFormWatch<IIssue>;
userAuth: UserAuth;
disabled?: boolean;
};
export const SidebarBlockerSelect: React.FC<Props> = ({
@ -28,6 +29,7 @@ export const SidebarBlockerSelect: React.FC<Props> = ({
submitChanges,
watch,
userAuth,
disabled = false,
}) => {
const [isBlockerModalOpen, setIsBlockerModalOpen] = useState(false);
@ -69,7 +71,7 @@ export const SidebarBlockerSelect: React.FC<Props> = ({
handleClose();
};
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disabled;
return (
<>

View File

@ -22,12 +22,14 @@ type Props = {
issueDetail: IIssue | undefined;
handleCycleChange: (cycle: ICycle) => void;
userAuth: UserAuth;
disabled?: boolean;
};
export const SidebarCycleSelect: React.FC<Props> = ({
issueDetail,
handleCycleChange,
userAuth,
disabled = false,
}) => {
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
@ -61,7 +63,7 @@ export const SidebarCycleSelect: React.FC<Props> = ({
const issueCycle = issueDetail?.issue_cycle;
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disabled;
return (
<div className="flex flex-wrap items-center py-2">

View File

@ -13,10 +13,16 @@ type Props = {
value: number | null;
onChange: (val: number | null) => void;
userAuth: UserAuth;
disabled?: boolean;
};
export const SidebarEstimateSelect: React.FC<Props> = ({ value, onChange, userAuth }) => {
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
export const SidebarEstimateSelect: React.FC<Props> = ({
value,
onChange,
userAuth,
disabled = false,
}) => {
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disabled;
const { isEstimateActive, estimatePoints } = useEstimateOption();
@ -46,7 +52,7 @@ export const SidebarEstimateSelect: React.FC<Props> = ({ value, onChange, userAu
onChange={onChange}
position="right"
width="w-full"
disabled={isNotAllowed}
disabled={isNotAllowed || disabled}
>
<CustomSelect.Option value={null}>
<>

View File

@ -21,12 +21,14 @@ type Props = {
issueDetail: IIssue | undefined;
handleModuleChange: (module: IModule) => void;
userAuth: UserAuth;
disabled?: boolean;
};
export const SidebarModuleSelect: React.FC<Props> = ({
issueDetail,
handleModuleChange,
userAuth,
disabled = false,
}) => {
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
@ -55,7 +57,7 @@ export const SidebarModuleSelect: React.FC<Props> = ({
const issueModule = issueDetail?.issue_module;
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disabled;
return (
<div className="flex flex-wrap items-center py-2">

View File

@ -23,6 +23,7 @@ type Props = {
customDisplay: JSX.Element;
watch: UseFormWatch<IIssue>;
userAuth: UserAuth;
disabled?: boolean;
};
export const SidebarParentSelect: React.FC<Props> = ({
@ -31,6 +32,7 @@ export const SidebarParentSelect: React.FC<Props> = ({
customDisplay,
watch,
userAuth,
disabled = false,
}) => {
const [isParentModalOpen, setIsParentModalOpen] = useState(false);
@ -46,7 +48,7 @@ export const SidebarParentSelect: React.FC<Props> = ({
: null
);
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disabled;
return (
<div className="flex flex-wrap items-center py-2">

View File

@ -14,10 +14,16 @@ type Props = {
value: string | null;
onChange: (val: string) => void;
userAuth: UserAuth;
disabled?: boolean;
};
export const SidebarPrioritySelect: React.FC<Props> = ({ value, onChange, userAuth }) => {
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
export const SidebarPrioritySelect: React.FC<Props> = ({
value,
onChange,
userAuth,
disabled = false,
}) => {
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disabled;
return (
<div className="flex flex-wrap items-center py-2">

View File

@ -23,9 +23,15 @@ type Props = {
value: string;
onChange: (val: string) => void;
userAuth: UserAuth;
disabled?: boolean;
};
export const SidebarStateSelect: React.FC<Props> = ({ value, onChange, userAuth }) => {
export const SidebarStateSelect: React.FC<Props> = ({
value,
onChange,
userAuth,
disabled = false,
}) => {
const router = useRouter();
const { workspaceSlug, projectId, inboxIssueId } = router.query;
@ -39,7 +45,7 @@ export const SidebarStateSelect: React.FC<Props> = ({ value, onChange, userAuth
const selectedState = states?.find((s) => s.id === value);
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disabled;
return (
<div className="flex flex-wrap items-center py-2">

View File

@ -73,6 +73,7 @@ type Props = {
| "delete"
| "all"
)[];
nonEditable?: boolean;
};
const defaultValues: Partial<IIssueLabels> = {
@ -86,6 +87,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
issueDetail,
watch: watchIssue,
fieldsToShow = ["all"],
nonEditable = false,
}) => {
const [createLabelForm, setCreateLabelForm] = useState(false);
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
@ -307,7 +309,8 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
)}
</div>
</div>
<div className="divide-y-2 divide-custom-border-100">
<div className={`divide-y-2 divide-custom-border-100 ${nonEditable ? "opacity-60" : ""}`}>
{showFirstSection && (
<div className="py-1">
{(fieldsToShow.includes("all") || fieldsToShow.includes("state")) && (
@ -319,6 +322,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
value={value}
onChange={(val: string) => submitChanges({ state: val })}
userAuth={memberRole}
disabled={nonEditable}
/>
)}
/>
@ -332,6 +336,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
value={value}
onChange={(val: string[]) => submitChanges({ assignees_list: val })}
userAuth={memberRole}
disabled={nonEditable}
/>
)}
/>
@ -345,6 +350,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
value={value}
onChange={(val: string) => submitChanges({ priority: val })}
userAuth={memberRole}
disabled={nonEditable}
/>
)}
/>
@ -358,6 +364,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
value={value}
onChange={(val: number | null) => submitChanges({ estimate_point: val })}
userAuth={memberRole}
disabled={nonEditable}
/>
)}
/>
@ -389,6 +396,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
}
watch={watchIssue}
userAuth={memberRole}
disabled={nonEditable}
/>
)}
{(fieldsToShow.includes("all") || fieldsToShow.includes("blocker")) && (
@ -397,6 +405,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
submitChanges={submitChanges}
watch={watchIssue}
userAuth={memberRole}
disabled={nonEditable}
/>
)}
{(fieldsToShow.includes("all") || fieldsToShow.includes("blocked")) && (
@ -405,6 +414,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
submitChanges={submitChanges}
watch={watchIssue}
userAuth={memberRole}
disabled={nonEditable}
/>
)}
{(fieldsToShow.includes("all") || fieldsToShow.includes("dueDate")) && (
@ -427,7 +437,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
})
}
className="bg-custom-background-90"
disabled={isNotAllowed}
disabled={isNotAllowed || nonEditable}
/>
)}
/>
@ -443,6 +453,7 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
issueDetail={issueDetail}
handleCycleChange={handleCycleChange}
userAuth={memberRole}
disabled={nonEditable}
/>
)}
{(fieldsToShow.includes("all") || fieldsToShow.includes("module")) && (
@ -450,13 +461,14 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
issueDetail={issueDetail}
handleModuleChange={handleModuleChange}
userAuth={memberRole}
disabled={nonEditable}
/>
)}
</div>
)}
</div>
{(fieldsToShow.includes("all") || fieldsToShow.includes("label")) && (
<div className="space-y-3 py-3">
<div className={`space-y-3 py-3 ${nonEditable ? "opacity-60" : ""}`}>
<div className="flex items-start justify-between">
<div className="flex basis-1/2 items-center gap-x-2 text-sm text-custom-text-200">
<TagIcon className="h-4 w-4" />
@ -503,13 +515,13 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
onChange={(val: any) => submitChanges({ labels_list: val })}
className="flex-shrink-0"
multiple
disabled={isNotAllowed}
disabled={isNotAllowed || nonEditable}
>
{({ open }) => (
<div className="relative">
<Listbox.Button
className={`flex ${
isNotAllowed
isNotAllowed || nonEditable
? "cursor-not-allowed"
: "cursor-pointer hover:bg-custom-background-90"
} items-center gap-2 rounded-2xl border border-custom-border-100 px-2 py-0.5 text-xs text-custom-text-200`}
@ -614,11 +626,12 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
<button
type="button"
className={`flex ${
isNotAllowed
isNotAllowed || nonEditable
? "cursor-not-allowed"
: "cursor-pointer hover:bg-custom-background-90"
} items-center gap-1 rounded-2xl border border-custom-border-100 px-2 py-0.5 text-xs text-custom-text-200`}
onClick={() => setCreateLabelForm((prevData) => !prevData)}
disabled={nonEditable}
>
{createLabelForm ? (
<>
@ -709,14 +722,17 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
</div>
)}
{(fieldsToShow.includes("all") || fieldsToShow.includes("link")) && (
<div className="min-h-[116px] py-1 text-xs">
<div className={`min-h-[116px] py-1 text-xs ${nonEditable ? "opacity-60" : ""}`}>
<div className="flex items-center justify-between gap-2">
<h4>Links</h4>
{!isNotAllowed && (
<button
type="button"
className="grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-custom-background-90"
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-custom-background-90 ${
nonEditable ? "cursor-not-allowed" : "cursor-pointer"
}`}
onClick={() => setLinkModal(true)}
disabled={nonEditable}
>
<PlusIcon className="h-4 w-4" />
</button>

View File

@ -28,9 +28,10 @@ import { PROJECT_ISSUES_LIST, SUB_ISSUES } from "constants/fetch-keys";
type Props = {
parentIssue: IIssue;
user: ICurrentUserResponse | undefined;
disabled?: boolean;
};
export const SubIssuesList: FC<Props> = ({ parentIssue, user }) => {
export const SubIssuesList: FC<Props> = ({ parentIssue, user, disabled = false }) => {
// states
const [createIssueModal, setCreateIssueModal] = useState(false);
const [subIssuesListModal, setSubIssuesListModal] = useState(false);
@ -180,7 +181,7 @@ export const SubIssuesList: FC<Props> = ({ parentIssue, user }) => {
const completionPercentage = (completedSubIssues / totalSubIssues) * 100;
const isNotAllowed = memberRole.isGuest || memberRole.isViewer;
const isNotAllowed = memberRole.isGuest || memberRole.isViewer || disabled;
return (
<>

View File

@ -4,7 +4,7 @@ import { useRouter } from "next/router";
// headless ui
import { Disclosure, Transition } from "@headlessui/react";
// ui
import { CustomMenu } from "components/ui";
import { CustomMenu, Icon } from "components/ui";
// icons
import {
ChevronDownIcon,
@ -153,6 +153,18 @@ export const SingleSidebarProject: React.FC<Props> = ({
<span>Copy project link</span>
</span>
</CustomMenu.MenuItem>
{project.archive_in > 0 && (
<CustomMenu.MenuItem
onClick={() =>
router.push(`/${workspaceSlug}/projects/${project?.id}/archived-issues/`)
}
>
<div className="flex items-center justify-start gap-2">
<Icon iconName="archive" className="h-4 w-4" />
<span>Archived Issues</span>
</div>
</CustomMenu.MenuItem>
)}
</CustomMenu>
)}
</div>

View File

@ -70,6 +70,13 @@ export const PROJECT_ISSUES_LIST_WITH_PARAMS = (projectId: string, params?: any)
return `PROJECT_ISSUES_LIST_WITH_PARAMS_${projectId.toUpperCase()}_${paramsKey}`;
};
export const PROJECT_ARCHIVED_ISSUES_LIST_WITH_PARAMS = (projectId: string, params?: any) => {
if (!params) return `PROJECT_ARCHIVED_ISSUES_LIST_WITH_PARAMS${projectId.toUpperCase()}`;
const paramsKey = paramsToKey(params);
return `PROJECT_ARCHIVED_ISSUES_LIST_WITH_PARAMS${projectId.toUpperCase()}_${paramsKey}`;
};
export const PROJECT_ISSUES_DETAILS = (issueId: string) =>
`PROJECT_ISSUES_DETAILS_${issueId.toUpperCase()}`;
export const PROJECT_ISSUES_PROPERTIES = (projectId: string) =>
@ -155,6 +162,8 @@ export const INBOX_ISSUE_DETAILS = (inboxId: string, issueId: string) =>
export const ISSUE_DETAILS = (issueId: string) => `ISSUE_DETAILS_${issueId.toUpperCase()}`;
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) =>
`ARCHIVED_ISSUE_DETAILS_${issueId.toUpperCase()}`;
// integrations
export const APP_INTEGRATIONS = "APP_INTEGRATIONS";

View File

@ -1,4 +1,3 @@
export const NETWORK_CHOICES = { "0": "Secret", "2": "Public" };
export const GROUP_CHOICES = {
@ -27,3 +26,11 @@ export const MONTHS = [
];
export const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
export const PROJECT_AUTOMATION_MONTHS = [
{ label: "1 Months", value: 1 },
{ label: "3 Months", value: 3 },
{ label: "6 Months", value: 6 },
{ label: "9 Months", value: 9 },
{ label: "12 Months", value: 12 },
];

View File

@ -19,6 +19,7 @@ import type { IIssue } from "types";
import {
CYCLE_ISSUES_WITH_PARAMS,
MODULE_ISSUES_WITH_PARAMS,
PROJECT_ARCHIVED_ISSUES_LIST_WITH_PARAMS,
PROJECT_ISSUES_LIST_WITH_PARAMS,
STATES_LIST,
VIEW_ISSUES,
@ -44,6 +45,7 @@ const useIssuesView = () => {
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query;
const isArchivedIssues = router.pathname.includes("archived-issues");
const params: any = {
order_by: orderBy,
@ -73,6 +75,15 @@ const useIssuesView = () => {
: null
);
const { data: projectArchivedIssues } = useSWR(
workspaceSlug && projectId
? PROJECT_ARCHIVED_ISSUES_LIST_WITH_PARAMS(projectId as string, params)
: null,
workspaceSlug && projectId && params
? () => issuesService.getArchivedIssues(workspaceSlug as string, projectId as string, params)
: null
);
const { data: cycleIssues } = useSWR(
workspaceSlug && projectId && cycleId && params
? CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params)
@ -149,6 +160,8 @@ const useIssuesView = () => {
? moduleIssues
: viewId
? viewIssues
: isArchivedIssues
? projectArchivedIssues
: projectIssues;
if (Array.isArray(issuesToGroup)) return { allIssues: issuesToGroup };
@ -161,10 +174,12 @@ const useIssuesView = () => {
cycleIssues,
moduleIssues,
viewIssues,
projectArchivedIssues,
groupByProperty,
cycleId,
moduleId,
viewId,
isArchivedIssues,
emptyStatesObject,
]);
@ -174,12 +189,12 @@ const useIssuesView = () => {
return {
groupedByIssues,
issueView,
issueView: isArchivedIssues ? "list" : issueView,
groupByProperty,
setGroupByProperty,
orderBy,
setOrderBy,
showEmptyGroups,
showEmptyGroups: isArchivedIssues ? false : showEmptyGroups,
setShowEmptyGroups,
calendarDateRange,
setCalendarDateRange,

View File

@ -71,6 +71,10 @@ const SettingsNavbar: React.FC<Props> = ({ profilePage = false }) => {
label: "Estimates",
href: `/${workspaceSlug}/projects/${projectId}/settings/estimates`,
},
{
label: "Automations",
href: `/${workspaceSlug}/projects/${projectId}/settings/automations`,
},
];
const profileLinks: Array<{

View File

@ -0,0 +1,211 @@
import React, { useCallback, useEffect } from "react";
import { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
// react-hook-form
import { useForm } from "react-hook-form";
// services
import issuesService from "services/issues.service";
// hooks
import useUserAuth from "hooks/use-user-auth";
import useToast from "hooks/use-toast";
// layouts
import { ProjectAuthorizationWrapper } from "layouts/auth-layout";
// components
import { IssueDetailsSidebar, IssueMainContent } from "components/issues";
// ui
import { Icon, Loader } from "components/ui";
import { Breadcrumbs } from "components/breadcrumbs";
// types
import { IIssue } from "types";
import type { NextPage } from "next";
// fetch-keys
import { PROJECT_ISSUES_ACTIVITY, ISSUE_DETAILS } from "constants/fetch-keys";
const defaultValues = {
name: "",
description: "",
description_html: "",
estimate_point: null,
state: "",
assignees_list: [],
priority: "low",
target_date: new Date().toString(),
issue_cycle: null,
issue_module: null,
labels_list: [],
};
const ArchivedIssueDetailsPage: NextPage = () => {
const router = useRouter();
const { workspaceSlug, projectId, archivedIssueId } = router.query;
const { user } = useUserAuth();
const { setToastAlert } = useToast();
const { data: issueDetails, mutate: mutateIssueDetails } = useSWR<IIssue | undefined>(
workspaceSlug && projectId && archivedIssueId ? ISSUE_DETAILS(archivedIssueId as string) : null,
workspaceSlug && projectId && archivedIssueId
? () =>
issuesService.retrieveArchivedIssue(
workspaceSlug as string,
projectId as string,
archivedIssueId as string
)
: null
);
const { reset, control, watch } = useForm<IIssue>({
defaultValues,
});
const submitChanges = useCallback(
async (formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId || !archivedIssueId) return;
mutate<IIssue>(
ISSUE_DETAILS(archivedIssueId as string),
(prevData) => {
if (!prevData) return prevData;
return {
...prevData,
...formData,
};
},
false
);
const payload: Partial<IIssue> = {
...formData,
};
await issuesService
.patchIssue(
workspaceSlug as string,
projectId as string,
archivedIssueId as string,
payload,
user
)
.then(() => {
mutateIssueDetails();
mutate(PROJECT_ISSUES_ACTIVITY(archivedIssueId as string));
})
.catch((e) => {
console.error(e);
});
},
[workspaceSlug, archivedIssueId, projectId, mutateIssueDetails, user]
);
useEffect(() => {
if (!issueDetails) return;
mutate(PROJECT_ISSUES_ACTIVITY(archivedIssueId as string));
reset({
...issueDetails,
assignees_list:
issueDetails.assignees_list ?? issueDetails.assignee_details?.map((user) => user.id),
labels_list: issueDetails.labels_list ?? issueDetails.labels,
labels: issueDetails.labels_list ?? issueDetails.labels,
});
}, [issueDetails, reset, archivedIssueId]);
const handleUnArchive = async () => {
if (!workspaceSlug || !projectId || !archivedIssueId) return;
await issuesService
.unArchivedIssue(workspaceSlug as string, projectId as string, archivedIssueId as string)
.then(() => {
setToastAlert({
type: "success",
title: "Success!",
message: "Issue restored successfully.",
});
router.push(`/${workspaceSlug}/projects/${projectId}/issues/${archivedIssueId}`);
})
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Something went wrong. Please try again.",
});
});
};
return (
<ProjectAuthorizationWrapper
breadcrumbs={
<Breadcrumbs>
<Breadcrumbs.BreadcrumbItem
title={`${issueDetails?.project_detail.name ?? "Project"} Issues`}
link={`/${workspaceSlug}/projects/${projectId as string}/issues`}
/>
<Breadcrumbs.BreadcrumbItem
title={`Issue ${issueDetails?.project_detail.identifier ?? "Project"}-${
issueDetails?.sequence_id ?? "..."
} Details`}
/>
</Breadcrumbs>
}
>
{issueDetails && projectId ? (
<div className="flex h-full">
<div className="w-2/3 space-y-2 p-5">
{issueDetails.archived_at && (
<div className="flex items-center justify-between gap-2 px-2.5 py-2 text-sm border rounded-md text-custom-text-200 border-custom-border-200 bg-custom-background-90">
<div className="flex gap-2 items-center">
<Icon iconName="archive" className="" />
<p>This issue has been archived by Plane.</p>
</div>
<button
className="flex items-center gap-2 p-1.5 text-sm rounded-md border border-custom-border-200"
onClick={handleUnArchive}
>
<Icon iconName="history" />
<p>Restore Issue</p>
</button>
</div>
)}
<div className="space-y-5 divide-y-2 divide-custom-border-100 opacity-60">
<IssueMainContent
issueDetails={issueDetails}
submitChanges={submitChanges}
nonEditable
/>
</div>
</div>
<div className="w-1/3 space-y-5 border-l border-custom-border-100 p-5">
<IssueDetailsSidebar
control={control}
issueDetail={issueDetails}
submitChanges={submitChanges}
watch={watch}
nonEditable
/>
</div>
</div>
) : (
<Loader className="flex h-full gap-5 p-5">
<div className="basis-2/3 space-y-2">
<Loader.Item height="30px" width="40%" />
<Loader.Item height="15px" width="60%" />
<Loader.Item height="15px" width="60%" />
<Loader.Item height="15px" width="40%" />
</div>
<div className="basis-1/3 space-y-3">
<Loader.Item height="30px" />
<Loader.Item height="30px" />
<Loader.Item height="30px" />
<Loader.Item height="30px" />
</div>
</Loader>
)}
</ProjectAuthorizationWrapper>
);
};
export default ArchivedIssueDetailsPage;

View File

@ -0,0 +1,77 @@
import { useRouter } from "next/router";
import useSWR from "swr";
// services
import projectService from "services/project.service";
// layouts
import { ProjectAuthorizationWrapper } from "layouts/auth-layout";
// contexts
import { IssueViewContextProvider } from "contexts/issue-view.context";
// helper
import { truncateText } from "helpers/string.helper";
// components
import { IssuesFilterView, IssuesView } from "components/core";
// ui
import { Icon } from "components/ui";
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
// icons
import { XMarkIcon } from "@heroicons/react/24/outline";
// types
import type { NextPage } from "next";
// fetch-keys
import { PROJECT_DETAILS } from "constants/fetch-keys";
import useIssuesView from "hooks/use-issues-view";
import { useEffect } from "react";
const ProjectArchivedIssues: NextPage = () => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { showEmptyGroups, setShowEmptyGroups } = useIssuesView();
const { data: projectDetails } = useSWR(
workspaceSlug && projectId ? PROJECT_DETAILS(projectId as string) : null,
workspaceSlug && projectId
? () => projectService.getProject(workspaceSlug as string, projectId as string)
: null
);
return (
<IssueViewContextProvider>
<ProjectAuthorizationWrapper
breadcrumbs={
<Breadcrumbs>
<BreadcrumbItem title="Projects" link={`/${workspaceSlug}/projects`} />
<BreadcrumbItem
title={`${truncateText(projectDetails?.name ?? "Project", 12)} Archived Issues`}
/>
</Breadcrumbs>
}
right={
<div className="flex items-center gap-2">
<IssuesFilterView />
</div>
}
>
<div className="h-full w-full flex flex-col">
<div className="flex items-center ga-1 px-4 py-2.5 shadow-sm border-b border-custom-border-100">
<button
type="button"
onClick={() => router.push(`/${workspaceSlug}/projects/${projectId}/issues/`)}
className="flex items-center gap-1.5 rounded-full border border-custom-border-100 px-3 py-1.5 text-xs"
>
<Icon iconName="archive" className="text-base" />
<span>Archived Issues</span>
<XMarkIcon className="h-3 w-3" />
</button>
</div>
<IssuesView />
</div>
</ProjectAuthorizationWrapper>
</IssueViewContextProvider>
);
};
export default ProjectArchivedIssues;

View File

@ -0,0 +1,81 @@
import React from "react";
import { useRouter } from "next/router";
import { mutate } from "swr";
// services
import projectService from "services/project.service";
// layouts
import { ProjectAuthorizationWrapper } from "layouts/auth-layout";
// hooks
import useUserAuth from "hooks/use-user-auth";
import useProjectDetails from "hooks/use-project-details";
import useToast from "hooks/use-toast";
// components
import { SettingsHeader } from "components/project";
import { AutoArchiveAutomation, AutoCloseAutomation } from "components/automation";
// ui
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
// types
import type { NextPage } from "next";
import { IProject } from "types";
// constant
import { PROJECT_DETAILS } from "constants/fetch-keys";
const AutomationsSettings: NextPage = () => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { user } = useUserAuth();
const { setToastAlert } = useToast();
const { projectDetails } = useProjectDetails();
const handleChange = async (formData: Partial<IProject>) => {
if (!workspaceSlug || !projectId) return;
mutate<IProject>(
PROJECT_DETAILS(projectId as string),
(prevData) => ({ ...(prevData as IProject), ...formData }),
false
);
await projectService
.updateProject(workspaceSlug as string, projectId as string, formData, user)
.then(() => {
mutate(PROJECT_DETAILS(projectId as string));
})
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Something went wrong. Please try again.",
});
});
};
return (
<ProjectAuthorizationWrapper
breadcrumbs={
<Breadcrumbs>
<BreadcrumbItem
title={`${projectDetails?.name ?? "Project"}`}
link={`/${workspaceSlug}/projects/${projectDetails?.id}/issues`}
/>
<BreadcrumbItem title="Automations Settings" />
</Breadcrumbs>
}
>
<div className="p-8">
<SettingsHeader />
<section className="space-y-5">
<AutoCloseAutomation projectDetails={projectDetails} handleChange={handleChange} />
<AutoArchiveAutomation projectDetails={projectDetails} handleChange={handleChange} />
</section>
</div>
</ProjectAuthorizationWrapper>
);
};
export default AutomationsSettings;

View File

@ -525,6 +525,52 @@ class ProjectIssuesServices extends APIService {
throw error?.response?.data;
});
}
async getArchivedIssues(workspaceSlug: string, projectId: string, queries?: any): Promise<any> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/archived-issues/`, {
params: queries,
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async unArchivedIssue(workspaceSlug: string, projectId: string, issueId: string): Promise<any> {
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/unarchive/${issueId}/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async retrieveArchivedIssue(
workspaceSlug: string,
projectId: string,
issueId: string
): Promise<any> {
return this.get(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/archived-issues/${issueId}/`
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async deleteArchivedIssue(
workspaceSlug: string,
projectId: string,
issuesId: string
): Promise<any> {
return this.delete(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/archived-issues/${issuesId}/`
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}
export default new ProjectIssuesServices();

View File

@ -64,6 +64,7 @@ export interface IIssueLink {
}
export interface IIssue {
archived_at: string;
assignees: string[];
assignee_details: IUser[];
assignees_list: string[];

View File

@ -9,6 +9,8 @@ import type {
} from "./";
export interface IProject {
archive_in: number;
close_in: number;
created_at: Date;
created_by: string;
cover_image: string | null;
@ -18,6 +20,7 @@ export interface IProject {
page_view: boolean;
inbox_view: boolean;
default_assignee: IUser | string | null;
default_state: string | null;
description: string;
emoji: string | null;
emoji_and_icon: