mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
chore: refactored and resolved build issues on the issues and issue detail page (#3340)
* fix: handled undefined issue_id in list layout * dev: issue detail store and optimization * dev: issue filter and list operations * fix: typo on labels update * dev: Handled all issues in the list layout in project issues * dev: handled kanban and auick add issue in swimlanes * chore: fixed peekoverview in kanban * chore: fixed peekoverview in calendar * chore: fixed peekoverview in gantt * chore: updated quick add in the gantt chart * chore: handled issue detail properties and resolved build issues --------- Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
This commit is contained in:
parent
09603cf189
commit
73eed69aa6
@ -77,7 +77,7 @@ class DynamicBaseSerializer(BaseSerializer):
|
||||
"assignees": UserLiteSerializer,
|
||||
"labels": LabelSerializer,
|
||||
"issue_cycle": CycleIssueSerializer,
|
||||
"parent": IssueFlatSerializer,
|
||||
"parent": IssueSerializer,
|
||||
}
|
||||
|
||||
self.fields[field] = expansion[field](many=True if field in ["members", "assignees", "labels", "issue_cycle"] else False)
|
||||
@ -119,6 +119,7 @@ class DynamicBaseSerializer(BaseSerializer):
|
||||
"assignees": UserLiteSerializer,
|
||||
"labels": LabelSerializer,
|
||||
"issue_cycle": CycleIssueSerializer,
|
||||
"parent": IssueSerializer,
|
||||
}
|
||||
# Check if field in expansion then expand the field
|
||||
if expand in expansion:
|
||||
|
@ -175,7 +175,7 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
assignees = validated_data.pop("assignee_ids", None)
|
||||
labels = validated_data.pop("labels_ids", None)
|
||||
labels = validated_data.pop("label_ids", None)
|
||||
|
||||
# Related models
|
||||
project_id = instance.project_id
|
||||
|
2
packages/types/src/issues.d.ts
vendored
2
packages/types/src/issues.d.ts
vendored
@ -222,7 +222,7 @@ export type GroupByColumnTypes =
|
||||
export interface IGroupByColumn {
|
||||
id: string;
|
||||
name: string;
|
||||
Icon: ReactElement | undefined;
|
||||
icon: ReactElement | undefined;
|
||||
payload: Partial<TIssue>;
|
||||
}
|
||||
|
||||
|
@ -17,5 +17,5 @@ export type TIssueReactionMap = {
|
||||
};
|
||||
|
||||
export type TIssueReactionIdMap = {
|
||||
[issue_id: string]: string[];
|
||||
[issue_id: string]: { [reaction: string]: string[] };
|
||||
};
|
||||
|
13
packages/types/src/view-props.d.ts
vendored
13
packages/types/src/view-props.d.ts
vendored
@ -1,4 +1,9 @@
|
||||
export type TIssueLayouts = "list" | "kanban" | "calendar" | "spreadsheet" | "gantt_chart";
|
||||
export type TIssueLayouts =
|
||||
| "list"
|
||||
| "kanban"
|
||||
| "calendar"
|
||||
| "spreadsheet"
|
||||
| "gantt_chart";
|
||||
|
||||
export type TIssueGroupByOptions =
|
||||
| "state"
|
||||
@ -108,10 +113,16 @@ export interface IIssueDisplayProperties {
|
||||
updated_on?: boolean;
|
||||
}
|
||||
|
||||
export type TIssueKanbanFilters = {
|
||||
group_by: string[];
|
||||
sub_group_by: string[];
|
||||
};
|
||||
|
||||
export interface IIssueFilters {
|
||||
filters: IIssueFilterOptions | undefined;
|
||||
displayFilters: IIssueDisplayFilterOptions | undefined;
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
kanbanFilters: TIssueKanbanFilters | undefined;
|
||||
}
|
||||
|
||||
export interface IIssueFiltersResponse {
|
||||
|
6
packages/types/src/workspace-views.d.ts
vendored
6
packages/types/src/workspace-views.d.ts
vendored
@ -29,4 +29,8 @@ export interface IWorkspaceView {
|
||||
};
|
||||
}
|
||||
|
||||
export type TStaticViewTypes = "all-issues" | "assigned" | "created" | "subscribed";
|
||||
export type TStaticViewTypes =
|
||||
| "all-issues"
|
||||
| "assigned"
|
||||
| "created"
|
||||
| "subscribed";
|
||||
|
@ -3,5 +3,4 @@ export * from "./modals";
|
||||
export * from "./sidebar";
|
||||
export * from "./theme";
|
||||
export * from "./activity";
|
||||
export * from "./reaction-selector";
|
||||
export * from "./image-picker-popover";
|
||||
|
@ -126,7 +126,7 @@ export const SidebarProgressStats: React.FC<Props> = ({
|
||||
</Tab.List>
|
||||
<Tab.Panels className="flex w-full items-center justify-between text-custom-text-200">
|
||||
<Tab.Panel as="div" className="flex h-44 w-full flex-col gap-1.5 overflow-y-auto pt-3.5">
|
||||
{distribution.assignees.length > 0 ? (
|
||||
{distribution?.assignees.length > 0 ? (
|
||||
distribution.assignees.map((assignee, index) => {
|
||||
if (assignee.assignee_id)
|
||||
return (
|
||||
|
@ -7,7 +7,7 @@ import { useChart } from "components/gantt-chart/hooks";
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
// components
|
||||
import { GanttInlineCreateIssueForm, IssueGanttSidebarBlock } from "components/issues";
|
||||
import { GanttQuickAddIssueForm, IssueGanttSidebarBlock } from "components/issues";
|
||||
// helpers
|
||||
import { findTotalDaysInRange } from "helpers/date-time.helper";
|
||||
// types
|
||||
@ -169,7 +169,7 @@ export const IssueGanttSidebar: React.FC<Props> = (props) => {
|
||||
{droppableProvided.placeholder}
|
||||
</>
|
||||
{enableQuickIssueCreate && !disableIssueCreation && (
|
||||
<GanttInlineCreateIssueForm quickAddCallback={quickAddCallback} viewId={viewId} />
|
||||
<GanttQuickAddIssueForm quickAddCallback={quickAddCallback} viewId={viewId} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
@ -7,7 +7,13 @@ import { AlertTriangle, CheckCircle2, Clock, Copy, ExternalLink, Inbox, XCircle
|
||||
// hooks
|
||||
import { useProjectState, useUser, useInboxIssues } from "hooks/store";
|
||||
// components
|
||||
import { IssueDescriptionForm, IssueDetailsSidebar, IssueReaction, IssueUpdateStatus } from "components/issues";
|
||||
import {
|
||||
IssueDescriptionForm,
|
||||
// FIXME: have to replace this once the issue details page is ready --issue-detail--
|
||||
// IssueDetailsSidebar,
|
||||
// IssueReaction,
|
||||
IssueUpdateStatus,
|
||||
} from "components/issues";
|
||||
import { InboxIssueActivity } from "components/inbox";
|
||||
// ui
|
||||
import { Loader, StateGroupIcon } from "@plane/ui";
|
||||
@ -226,7 +232,9 @@ export const InboxMainContent: React.FC = observer(() => {
|
||||
)}
|
||||
<IssueUpdateStatus isSubmitting={isSubmitting} issueDetail={issueDetails} />
|
||||
</div>
|
||||
<div>
|
||||
|
||||
{/* FIXME: have to replace this once the issue details page is ready --issue-detail-- */}
|
||||
{/* <div>
|
||||
<IssueDescriptionForm
|
||||
setIsSubmitting={(value) => setIsSubmitting(value)}
|
||||
isSubmitting={isSubmitting}
|
||||
@ -239,26 +247,28 @@ export const InboxMainContent: React.FC = observer(() => {
|
||||
handleFormSubmit={submitChanges}
|
||||
isAllowed={isAllowed || currentUser?.id === issueDetails.created_by}
|
||||
/>
|
||||
</div>
|
||||
</div> */}
|
||||
|
||||
{workspaceSlug && projectId && (
|
||||
{/* FIXME: have to replace this once the issue details page is ready --issue-detail-- */}
|
||||
{/* {workspaceSlug && projectId && (
|
||||
<IssueReaction
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
issueId={issueDetails.id}
|
||||
/>
|
||||
)}
|
||||
)} */}
|
||||
<InboxIssueActivity issueDetails={issueDetails} />
|
||||
</div>
|
||||
|
||||
<div className="basis-1/3 space-y-5 border-custom-border-200 py-5">
|
||||
<IssueDetailsSidebar
|
||||
{/* FIXME: have to replace this once the issue details page is ready --issue-detail-- */}
|
||||
{/* <IssueDetailsSidebar
|
||||
control={control}
|
||||
issueDetail={issueDetails}
|
||||
submitChanges={submitChanges}
|
||||
watch={watch}
|
||||
fieldsToShow={["assignee", "priority", "estimate", "dueDate", "label", "state"]}
|
||||
/>
|
||||
/> */}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
@ -11,15 +11,15 @@ import { TAttachmentOperations } from "./root";
|
||||
type TAttachmentOperationsModal = Exclude<TAttachmentOperations, "remove">;
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
disabled?: boolean;
|
||||
handleAttachmentOperations: TAttachmentOperationsModal;
|
||||
};
|
||||
|
||||
export const IssueAttachmentUpload: React.FC<Props> = observer((props) => {
|
||||
const { disabled = false, handleAttachmentOperations } = props;
|
||||
const { workspaceSlug, disabled = false, handleAttachmentOperations } = props;
|
||||
// store hooks
|
||||
const {
|
||||
router: { workspaceSlug },
|
||||
config: { envConfig },
|
||||
} = useApplication();
|
||||
// states
|
||||
|
@ -1,13 +1,17 @@
|
||||
import { FC, useMemo } from "react";
|
||||
// hooks
|
||||
import { useApplication, useIssueDetail } from "hooks/store";
|
||||
import { useIssueDetail } from "hooks/store";
|
||||
import useToast from "hooks/use-toast";
|
||||
// components
|
||||
import { IssueAttachmentUpload } from "./attachment-upload";
|
||||
import { IssueAttachmentsList } from "./attachments-list";
|
||||
|
||||
export type TIssueAttachmentRoot = {
|
||||
isEditable: boolean;
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
is_archived: boolean;
|
||||
is_editable: boolean;
|
||||
};
|
||||
|
||||
export type TAttachmentOperations = {
|
||||
@ -17,20 +21,17 @@ export type TAttachmentOperations = {
|
||||
|
||||
export const IssueAttachmentRoot: FC<TIssueAttachmentRoot> = (props) => {
|
||||
// props
|
||||
const { isEditable } = props;
|
||||
const { workspaceSlug, projectId, issueId, is_archived, is_editable } = props;
|
||||
// hooks
|
||||
const {
|
||||
router: { workspaceSlug, projectId },
|
||||
} = useApplication();
|
||||
const { peekIssue, createAttachment, removeAttachment } = useIssueDetail();
|
||||
const { createAttachment, removeAttachment } = useIssueDetail();
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const handleAttachmentOperations: TAttachmentOperations = useMemo(
|
||||
() => ({
|
||||
create: async (data: FormData) => {
|
||||
try {
|
||||
if (!workspaceSlug || !projectId || !peekIssue?.issueId) throw new Error("Missing required fields");
|
||||
await createAttachment(workspaceSlug, projectId, peekIssue?.issueId, data);
|
||||
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields");
|
||||
await createAttachment(workspaceSlug, projectId, issueId, data);
|
||||
setToastAlert({
|
||||
message: "The attachment has been successfully uploaded",
|
||||
type: "success",
|
||||
@ -46,8 +47,8 @@ export const IssueAttachmentRoot: FC<TIssueAttachmentRoot> = (props) => {
|
||||
},
|
||||
remove: async (attachmentId: string) => {
|
||||
try {
|
||||
if (!workspaceSlug || !projectId || !peekIssue?.issueId) throw new Error("Missing required fields");
|
||||
await removeAttachment(workspaceSlug, projectId, peekIssue?.issueId, attachmentId);
|
||||
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields");
|
||||
await removeAttachment(workspaceSlug, projectId, issueId, attachmentId);
|
||||
setToastAlert({
|
||||
message: "The attachment has been successfully removed",
|
||||
type: "success",
|
||||
@ -62,14 +63,18 @@ export const IssueAttachmentRoot: FC<TIssueAttachmentRoot> = (props) => {
|
||||
}
|
||||
},
|
||||
}),
|
||||
[workspaceSlug, projectId, peekIssue, createAttachment, removeAttachment, setToastAlert]
|
||||
[workspaceSlug, projectId, issueId, createAttachment, removeAttachment, setToastAlert]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative py-3 space-y-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 disabled={isEditable} handleAttachmentOperations={handleAttachmentOperations} />
|
||||
<IssueAttachmentUpload
|
||||
workspaceSlug={workspaceSlug}
|
||||
disabled={is_editable}
|
||||
handleAttachmentOperations={handleAttachmentOperations}
|
||||
/>
|
||||
<IssueAttachmentsList handleAttachmentOperations={handleAttachmentOperations} />
|
||||
</div>
|
||||
</div>
|
||||
|
@ -5,7 +5,7 @@ import { observer } from "mobx-react-lite";
|
||||
import { useUser } from "hooks/store";
|
||||
import useCommentReaction from "hooks/use-comment-reaction";
|
||||
// ui
|
||||
import { ReactionSelector } from "components/core";
|
||||
// import { ReactionSelector } from "components/core";
|
||||
// helper
|
||||
import { renderEmoji } from "helpers/emoji.helper";
|
||||
// types
|
||||
@ -47,7 +47,8 @@ export const CommentReaction: FC<Props> = observer((props) => {
|
||||
|
||||
return (
|
||||
<div className="mt-2 flex items-center gap-1.5">
|
||||
{!readonly && (
|
||||
{/* FIXME: have to replace this once the issue details page is ready --issue-detail-- */}
|
||||
{/* {!readonly && (
|
||||
<ReactionSelector
|
||||
size="md"
|
||||
position="top"
|
||||
@ -58,7 +59,7 @@ export const CommentReaction: FC<Props> = observer((props) => {
|
||||
}
|
||||
onSelect={handleReactionClick}
|
||||
/>
|
||||
)}
|
||||
)} */}
|
||||
|
||||
{Object.keys(groupedReactions || {}).map(
|
||||
(reaction) =>
|
||||
|
@ -8,6 +8,7 @@ import { TextArea } from "@plane/ui";
|
||||
import { RichTextEditor } from "@plane/rich-text-editor";
|
||||
// types
|
||||
import { TIssue } from "@plane/types";
|
||||
import { TIssueOperations } from "./issue-detail";
|
||||
// services
|
||||
import { FileService } from "services/file.service";
|
||||
import { useMention } from "hooks/store";
|
||||
@ -18,14 +19,16 @@ export interface IssueDescriptionFormValues {
|
||||
}
|
||||
|
||||
export interface IssueDetailsProps {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
issue: {
|
||||
name: string;
|
||||
description_html: string;
|
||||
id: string;
|
||||
project_id?: string;
|
||||
};
|
||||
workspaceSlug: string;
|
||||
handleFormSubmit: (value: IssueDescriptionFormValues) => Promise<void>;
|
||||
issueOperations: TIssueOperations;
|
||||
isAllowed: boolean;
|
||||
isSubmitting: "submitting" | "submitted" | "saved";
|
||||
setIsSubmitting: (value: "submitting" | "submitted" | "saved") => void;
|
||||
@ -34,7 +37,7 @@ export interface IssueDetailsProps {
|
||||
const fileService = new FileService();
|
||||
|
||||
export const IssueDescriptionForm: FC<IssueDetailsProps> = (props) => {
|
||||
const { issue, handleFormSubmit, workspaceSlug, isAllowed, isSubmitting, setIsSubmitting } = props;
|
||||
const { workspaceSlug, projectId, issueId, issue, issueOperations, isAllowed, isSubmitting, setIsSubmitting } = props;
|
||||
// states
|
||||
const [characterLimit, setCharacterLimit] = useState(false);
|
||||
|
||||
@ -75,12 +78,12 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = (props) => {
|
||||
async (formData: Partial<TIssue>) => {
|
||||
if (!formData?.name || formData?.name.length === 0 || formData?.name.length > 255) return;
|
||||
|
||||
await handleFormSubmit({
|
||||
await issueOperations.update(workspaceSlug, projectId, issueId, {
|
||||
name: formData.name ?? "",
|
||||
description_html: formData.description_html ?? "<p></p>",
|
||||
});
|
||||
},
|
||||
[handleFormSubmit]
|
||||
[workspaceSlug, projectId, issueId, issueOperations]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -1,21 +1,22 @@
|
||||
export * from "./attachment";
|
||||
export * from "./comment";
|
||||
export * from "./issue-modal";
|
||||
export * from "./sidebar-select";
|
||||
export * from "./view-select";
|
||||
export * from "./activity";
|
||||
export * from "./delete-issue-modal";
|
||||
export * from "./description-form";
|
||||
export * from "./issue-layouts";
|
||||
export * from "./peek-overview";
|
||||
export * from "./main-content";
|
||||
|
||||
export * from "./parent-issues-list-modal";
|
||||
export * from "./sidebar";
|
||||
export * from "./label";
|
||||
export * from "./issue-reaction";
|
||||
export * from "./confirm-issue-discard";
|
||||
export * from "./issue-update-status";
|
||||
|
||||
// issue details
|
||||
export * from "./issue-detail";
|
||||
|
||||
export * from "./peek-overview";
|
||||
|
||||
// draft issue
|
||||
export * from "./draft-issue-form";
|
||||
export * from "./draft-issue-modal";
|
||||
@ -23,6 +24,3 @@ export * from "./delete-draft-issue-modal";
|
||||
|
||||
// archived issue
|
||||
export * from "./delete-archived-issue-modal";
|
||||
|
||||
// issue links
|
||||
export * from "./issue-links";
|
||||
|
103
web/components/issues/issue-detail/cycle-select.tsx
Normal file
103
web/components/issues/issue-detail/cycle-select.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
import React, { ReactNode, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import useSWR from "swr";
|
||||
// hooks
|
||||
import { useCycle, useIssueDetail } from "hooks/store";
|
||||
// ui
|
||||
import { ContrastIcon, CustomSearchSelect, Spinner, Tooltip } from "@plane/ui";
|
||||
// types
|
||||
import type { TIssueOperations } from "./root";
|
||||
|
||||
type TIssueCycleSelect = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
issueOperations: TIssueOperations;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const IssueCycleSelect: React.FC<TIssueCycleSelect> = observer((props) => {
|
||||
const { workspaceSlug, projectId, issueId, issueOperations, disabled = false } = props;
|
||||
// hooks
|
||||
const { getCycleById, currentProjectIncompleteCycleIds, fetchAllCycles } = useCycle();
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
// state
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
useSWR(workspaceSlug && projectId ? `PROJECT_${projectId}_ISSUE_${issueId}_CYCLES` : null, async () => {
|
||||
if (workspaceSlug && projectId) await fetchAllCycles(workspaceSlug, projectId);
|
||||
});
|
||||
|
||||
const issue = getIssueById(issueId);
|
||||
const projectCycleIds = currentProjectIncompleteCycleIds;
|
||||
const issueCycle = (issue && issue.cycle_id && getCycleById(issue.cycle_id)) || undefined;
|
||||
const disableSelect = disabled || isUpdating;
|
||||
|
||||
const handleIssueCycleChange = async (cycleId: string) => {
|
||||
if (!cycleId) return;
|
||||
setIsUpdating(true);
|
||||
if (issue && issue.cycle_id === cycleId)
|
||||
await issueOperations.removeIssueFromCycle(workspaceSlug, projectId, cycleId, issueId);
|
||||
else await issueOperations.addIssueToCycle(workspaceSlug, projectId, cycleId, [issueId]);
|
||||
setIsUpdating(false);
|
||||
};
|
||||
|
||||
type TDropdownOptions = { value: string; query: string; content: ReactNode }[];
|
||||
const options: TDropdownOptions | undefined = projectCycleIds
|
||||
? (projectCycleIds
|
||||
.map((cycleId) => {
|
||||
const cycle = getCycleById(cycleId) || undefined;
|
||||
if (!cycle) return undefined;
|
||||
return {
|
||||
value: cycle.id,
|
||||
query: cycle.name,
|
||||
content: (
|
||||
<div className="flex items-center gap-1.5 truncate">
|
||||
<span className="flex h-3.5 w-3.5 flex-shrink-0 items-center justify-center">
|
||||
<ContrastIcon />
|
||||
</span>
|
||||
<span className="flex-grow truncate">{cycle.name}</span>
|
||||
</div>
|
||||
) as ReactNode,
|
||||
};
|
||||
})
|
||||
.filter((cycle) => cycle !== undefined) as TDropdownOptions)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<CustomSearchSelect
|
||||
value={issue?.cycle_id}
|
||||
onChange={(value: any) => handleIssueCycleChange(value)}
|
||||
options={options}
|
||||
customButton={
|
||||
<div>
|
||||
<Tooltip position="left" tooltipContent={`${issueCycle ? issueCycle?.name : "No cycle"}`}>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex w-full items-center rounded bg-custom-background-80 px-2.5 py-0.5 text-xs ${
|
||||
disableSelect ? "cursor-not-allowed" : ""
|
||||
} max-w-[10rem]`}
|
||||
>
|
||||
<span
|
||||
className={`flex items-center gap-1.5 truncate ${
|
||||
issueCycle ? "text-custom-text-100" : "text-custom-text-200"
|
||||
}`}
|
||||
>
|
||||
<span className="flex-shrink-0">{issueCycle && <ContrastIcon className="h-3.5 w-3.5" />}</span>
|
||||
<span className="truncate">{issueCycle ? issueCycle?.name : "No cycle"}</span>
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
}
|
||||
width="max-w-[10rem]"
|
||||
noChevron
|
||||
disabled={disableSelect}
|
||||
/>
|
||||
{isUpdating && <Spinner className="h-4 w-4" />}
|
||||
</div>
|
||||
);
|
||||
});
|
14
web/components/issues/issue-detail/index.ts
Normal file
14
web/components/issues/issue-detail/index.ts
Normal file
@ -0,0 +1,14 @@
|
||||
export * from "./root";
|
||||
|
||||
export * from "./main-content";
|
||||
export * from "./sidebar";
|
||||
|
||||
// select
|
||||
export * from "./cycle-select";
|
||||
export * from "./module-select";
|
||||
export * from "./parent-select";
|
||||
export * from "./relation-select";
|
||||
export * from "./parent";
|
||||
export * from "./label";
|
||||
export * from "./subscription";
|
||||
export * from "./links";
|
163
web/components/issues/issue-detail/label/create-label.tsx
Normal file
163
web/components/issues/issue-detail/label/create-label.tsx
Normal file
@ -0,0 +1,163 @@
|
||||
import { FC, useState, Fragment, useEffect } from "react";
|
||||
import { Plus, X } from "lucide-react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { TwitterPicker } from "react-color";
|
||||
import { Popover, Transition } from "@headlessui/react";
|
||||
// hooks
|
||||
import { useIssueDetail } from "hooks/store";
|
||||
import useToast from "hooks/use-toast";
|
||||
// ui
|
||||
import { Input } from "@plane/ui";
|
||||
// types
|
||||
import { TLabelOperations } from "./root";
|
||||
import { IIssueLabel } from "@plane/types";
|
||||
|
||||
type ILabelCreate = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
labelOperations: TLabelOperations;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
const defaultValues: Partial<IIssueLabel> = {
|
||||
name: "",
|
||||
color: "#ff0000",
|
||||
};
|
||||
|
||||
export const LabelCreate: FC<ILabelCreate> = (props) => {
|
||||
const { workspaceSlug, projectId, issueId, labelOperations, disabled = false } = props;
|
||||
// hooks
|
||||
const { setToastAlert } = useToast();
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
// state
|
||||
const [isCreateToggle, setIsCreateToggle] = useState(false);
|
||||
const handleIsCreateToggle = () => setIsCreateToggle(!isCreateToggle);
|
||||
// react hook form
|
||||
const {
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
reset,
|
||||
control,
|
||||
setFocus,
|
||||
} = useForm<Partial<IIssueLabel>>({
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCreateToggle) return;
|
||||
|
||||
setFocus("name");
|
||||
reset();
|
||||
}, [isCreateToggle, reset, setFocus]);
|
||||
|
||||
const handleLabel = async (formData: Partial<IIssueLabel>) => {
|
||||
if (!workspaceSlug || !projectId || isSubmitting) return;
|
||||
|
||||
try {
|
||||
const issue = getIssueById(issueId);
|
||||
const labelResponse = await labelOperations.createLabel(workspaceSlug, projectId, formData);
|
||||
const currentLabels = [...(issue?.label_ids || []), labelResponse.id];
|
||||
await labelOperations.updateIssue(workspaceSlug, projectId, issueId, { label_ids: currentLabels });
|
||||
reset(defaultValues);
|
||||
} catch (error) {
|
||||
setToastAlert({
|
||||
title: "Label creation failed",
|
||||
type: "error",
|
||||
message: "Label creation failed. Please try again sometime later.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="flex-shrink-0 transition-all relative flex items-center gap-1 cursor-pointer border border-custom-border-100 rounded-full text-xs p-0.5 px-2 group hover:border-red-500/50 hover:bg-red-500/20"
|
||||
onClick={handleIsCreateToggle}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
{isCreateToggle ? (
|
||||
<X className="transition-all h-2.5 w-2.5 group-hover:text-red-500" />
|
||||
) : (
|
||||
<Plus className="transition-all h-2.5 w-2.5 group-hover:text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-shrink-0">{isCreateToggle ? "Cancel" : "New"}</div>
|
||||
</div>
|
||||
|
||||
{isCreateToggle && (
|
||||
<form className="flex items-center gap-x-2" onSubmit={handleSubmit(handleLabel)}>
|
||||
<div>
|
||||
<Controller
|
||||
name="color"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Popover className="relative">
|
||||
<>
|
||||
<Popover.Button className="grid place-items-center outline-none">
|
||||
{value && value?.trim() !== "" && (
|
||||
<span
|
||||
className="h-6 w-6 rounded"
|
||||
style={{
|
||||
backgroundColor: value ?? "black",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Popover.Button>
|
||||
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<Popover.Panel className="absolute z-10 mt-1.5 max-w-xs px-2 sm:px-0">
|
||||
<TwitterPicker color={value} onChange={(value) => onChange(value.hex)} />
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
</Popover>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
rules={{
|
||||
required: "This is required",
|
||||
}}
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
value={value ?? ""}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.name)}
|
||||
placeholder="Title"
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="grid place-items-center rounded bg-red-500 p-1.5"
|
||||
onClick={() => setIsCreateToggle(false)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<X className="h-4 w-4 text-white" />
|
||||
</button>
|
||||
<button type="submit" className="grid place-items-center rounded bg-green-500 p-1.5" disabled={isSubmitting}>
|
||||
<Plus className="h-4 w-4 text-white" />
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
5
web/components/issues/issue-detail/label/index.ts
Normal file
5
web/components/issues/issue-detail/label/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export * from "./root";
|
||||
|
||||
export * from "./label-list";
|
||||
export * from "./label-list-item";
|
||||
export * from "./create-label";
|
52
web/components/issues/issue-detail/label/label-list-item.tsx
Normal file
52
web/components/issues/issue-detail/label/label-list-item.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
import { FC } from "react";
|
||||
import { X } from "lucide-react";
|
||||
// types
|
||||
import { TLabelOperations } from "./root";
|
||||
import { useIssueDetail, useLabel } from "hooks/store";
|
||||
|
||||
type TLabelListItem = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
labelId: string;
|
||||
labelOperations: TLabelOperations;
|
||||
};
|
||||
|
||||
export const LabelListItem: FC<TLabelListItem> = (props) => {
|
||||
const { workspaceSlug, projectId, issueId, labelId, labelOperations } = props;
|
||||
// hooks
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
const { getLabelById } = useLabel();
|
||||
|
||||
const issue = getIssueById(issueId);
|
||||
const label = getLabelById(labelId);
|
||||
|
||||
const handleLabel = async () => {
|
||||
if (issue) {
|
||||
const currentLabels = issue.label_ids.filter((_labelId) => _labelId !== labelId);
|
||||
await labelOperations.updateIssue(workspaceSlug, projectId, issueId, { label_ids: currentLabels });
|
||||
}
|
||||
};
|
||||
|
||||
if (!label) return <></>;
|
||||
return (
|
||||
<div
|
||||
key={labelId}
|
||||
className="transition-all relative flex items-center gap-1 cursor-pointer border border-custom-border-100 rounded-full text-xs p-0.5 px-1 group hover:border-red-500/50 hover:bg-red-500/20"
|
||||
onClick={handleLabel}
|
||||
>
|
||||
<div
|
||||
className="rounded-full h-2 w-2 flex-shrink-0"
|
||||
style={{
|
||||
backgroundColor: label.color ?? "#000000",
|
||||
}}
|
||||
/>
|
||||
<div className="flex-shrink-0">{label.name}</div>
|
||||
<div className="flex-shrink-0">
|
||||
<X className="transition-all h-2.5 w-2.5 group-hover:text-red-500" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
40
web/components/issues/issue-detail/label/label-list.tsx
Normal file
40
web/components/issues/issue-detail/label/label-list.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import { FC } from "react";
|
||||
// components
|
||||
import { LabelListItem } from "./label-list-item";
|
||||
// hooks
|
||||
import { useIssueDetail } from "hooks/store";
|
||||
// types
|
||||
import { TLabelOperations } from "./root";
|
||||
|
||||
type TLabelList = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
labelOperations: TLabelOperations;
|
||||
};
|
||||
|
||||
export const LabelList: FC<TLabelList> = (props) => {
|
||||
const { workspaceSlug, projectId, issueId, labelOperations } = props;
|
||||
// hooks
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
|
||||
const issue = getIssueById(issueId);
|
||||
const issueLabels = issue?.label_ids || undefined;
|
||||
|
||||
if (!issue || !issueLabels) return <></>;
|
||||
return (
|
||||
<>
|
||||
{issueLabels.map((labelId) => (
|
||||
<LabelListItem
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
labelId={labelId}
|
||||
labelOperations={labelOperations}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
92
web/components/issues/issue-detail/label/root.tsx
Normal file
92
web/components/issues/issue-detail/label/root.tsx
Normal file
@ -0,0 +1,92 @@
|
||||
import { FC, useMemo } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// components
|
||||
import { LabelList, LabelCreate } from "./";
|
||||
|
||||
// hooks
|
||||
import { useIssueDetail, useLabel } from "hooks/store";
|
||||
// types
|
||||
import { IIssueLabel, TIssue } from "@plane/types";
|
||||
import useToast from "hooks/use-toast";
|
||||
|
||||
export type TIssueLabel = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
export type TLabelOperations = {
|
||||
updateIssue: (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>;
|
||||
createLabel: (workspaceSlug: string, projectId: string, data: Partial<IIssueLabel>) => Promise<any>;
|
||||
};
|
||||
|
||||
export const IssueLabel: FC<TIssueLabel> = observer((props) => {
|
||||
const { workspaceSlug, projectId, issueId, disabled = false } = props;
|
||||
// hooks
|
||||
const { updateIssue } = useIssueDetail();
|
||||
const {
|
||||
project: { createLabel },
|
||||
} = useLabel();
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const labelOperations: TLabelOperations = useMemo(
|
||||
() => ({
|
||||
updateIssue: async (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => {
|
||||
try {
|
||||
await updateIssue(workspaceSlug, projectId, issueId, data);
|
||||
setToastAlert({
|
||||
title: "Issue updated successfully",
|
||||
type: "success",
|
||||
message: "Issue updated successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
setToastAlert({
|
||||
title: "Issue update failed",
|
||||
type: "error",
|
||||
message: "Issue update failed",
|
||||
});
|
||||
}
|
||||
},
|
||||
createLabel: async (workspaceSlug: string, projectId: string, data: Partial<IIssueLabel>) => {
|
||||
try {
|
||||
const labelResponse = await createLabel(workspaceSlug, projectId, data);
|
||||
setToastAlert({
|
||||
title: "Label created successfully",
|
||||
type: "success",
|
||||
message: "Label created successfully",
|
||||
});
|
||||
return labelResponse;
|
||||
} catch (error) {
|
||||
setToastAlert({
|
||||
title: "Label creation failed",
|
||||
type: "error",
|
||||
message: "Label creation failed",
|
||||
});
|
||||
return error;
|
||||
}
|
||||
},
|
||||
}),
|
||||
[updateIssue, createLabel, setToastAlert]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-wrap items-center gap-1">
|
||||
<LabelList
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
labelOperations={labelOperations}
|
||||
/>
|
||||
|
||||
{/* <div>select existing labels</div> */}
|
||||
|
||||
<LabelCreate
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
labelOperations={labelOperations}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
@ -0,0 +1,9 @@
|
||||
import { FC } from "react";
|
||||
|
||||
type TLabelExistingSelect = {};
|
||||
|
||||
export const LabelExistingSelect: FC<TLabelExistingSelect> = (props) => {
|
||||
const {} = props;
|
||||
|
||||
return <></>;
|
||||
};
|
@ -42,7 +42,7 @@ export const IssueLinkCreateUpdateModal: FC<TIssueLinkCreateEditModal> = (props)
|
||||
const onClose = () => {
|
||||
handleModal(false);
|
||||
const timeout = setTimeout(() => {
|
||||
reset(defaultValues);
|
||||
reset(preloadedData ? preloadedData : defaultValues);
|
||||
clearTimeout(timeout);
|
||||
}, 500);
|
||||
};
|
4
web/components/issues/issue-detail/links/index.ts
Normal file
4
web/components/issues/issue-detail/links/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from "./root";
|
||||
|
||||
export * from "./links";
|
||||
export * from "./link-detail";
|
@ -23,13 +23,17 @@ export const IssueLinkDetail: FC<TIssueLinkDetail> = (props) => {
|
||||
const { linkId, linkOperations, isNotAllowed } = props;
|
||||
// hooks
|
||||
const {
|
||||
toggleIssueLinkModal: toggleIssueLinkModalStore,
|
||||
link: { getLinkById },
|
||||
} = useIssueDetail();
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
// state
|
||||
const [isIssueLinkModalOpen, setIsIssueLinkModalOpen] = useState(false);
|
||||
const toggleIssueLinkModal = (modalToggle: boolean) => setIsIssueLinkModalOpen(modalToggle);
|
||||
const toggleIssueLinkModal = (modalToggle: boolean) => {
|
||||
toggleIssueLinkModalStore(modalToggle);
|
||||
setIsIssueLinkModalOpen(modalToggle);
|
||||
};
|
||||
|
||||
const linkDetail = getLinkById(linkId);
|
||||
if (!linkDetail) return <></>;
|
||||
@ -74,7 +78,7 @@ export const IssueLinkDetail: FC<TIssueLinkDetail> = (props) => {
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsIssueLinkModalOpen(true);
|
||||
toggleIssueLinkModal(true);
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-3 w-3 stroke-[1.5] text-custom-text-200" />
|
@ -1,7 +1,7 @@
|
||||
import { FC, useMemo, useState } from "react";
|
||||
import { FC, useCallback, useMemo, useState } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
// hooks
|
||||
import { useApplication, useIssueDetail } from "hooks/store";
|
||||
import { useIssueDetail } from "hooks/store";
|
||||
import useToast from "hooks/use-toast";
|
||||
// components
|
||||
import { IssueLinkCreateUpdateModal } from "./create-update-link-modal";
|
||||
@ -16,21 +16,27 @@ export type TLinkOperations = {
|
||||
};
|
||||
|
||||
export type TIssueLinkRoot = {
|
||||
uneditable: boolean;
|
||||
isAllowed: boolean;
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
is_editable: boolean;
|
||||
is_archived: boolean;
|
||||
};
|
||||
|
||||
export const IssueLinkRoot: FC<TIssueLinkRoot> = (props) => {
|
||||
// props
|
||||
const { uneditable, isAllowed } = props;
|
||||
const { workspaceSlug, projectId, issueId, is_editable, is_archived } = props;
|
||||
// hooks
|
||||
const {
|
||||
router: { workspaceSlug, projectId },
|
||||
} = useApplication();
|
||||
const { peekIssue, createLink, updateLink, removeLink } = useIssueDetail();
|
||||
const { toggleIssueLinkModal: toggleIssueLinkModalStore, createLink, updateLink, removeLink } = useIssueDetail();
|
||||
// state
|
||||
const [isIssueLinkModalOpen, setIsIssueLinkModalOpen] = useState(false);
|
||||
const toggleIssueLinkModal = (modalToggle: boolean) => setIsIssueLinkModalOpen(modalToggle);
|
||||
const [isIssueLinkModal, setIsIssueLinkModal] = useState(false);
|
||||
const toggleIssueLinkModal = useCallback(
|
||||
(modalToggle: boolean) => {
|
||||
toggleIssueLinkModalStore(modalToggle);
|
||||
setIsIssueLinkModal(modalToggle);
|
||||
},
|
||||
[toggleIssueLinkModalStore]
|
||||
);
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
@ -38,8 +44,8 @@ export const IssueLinkRoot: FC<TIssueLinkRoot> = (props) => {
|
||||
() => ({
|
||||
create: async (data: Partial<TIssueLink>) => {
|
||||
try {
|
||||
if (!workspaceSlug || !projectId || !peekIssue?.issueId) throw new Error("Missing required fields");
|
||||
await createLink(workspaceSlug, projectId, peekIssue?.issueId, data);
|
||||
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields");
|
||||
await createLink(workspaceSlug, projectId, issueId, data);
|
||||
setToastAlert({
|
||||
message: "The link has been successfully created",
|
||||
type: "success",
|
||||
@ -56,8 +62,8 @@ export const IssueLinkRoot: FC<TIssueLinkRoot> = (props) => {
|
||||
},
|
||||
update: async (linkId: string, data: Partial<TIssueLink>) => {
|
||||
try {
|
||||
if (!workspaceSlug || !projectId || !peekIssue?.issueId) throw new Error("Missing required fields");
|
||||
await updateLink(workspaceSlug, projectId, peekIssue?.issueId, linkId, data);
|
||||
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields");
|
||||
await updateLink(workspaceSlug, projectId, issueId, linkId, data);
|
||||
setToastAlert({
|
||||
message: "The link has been successfully updated",
|
||||
type: "success",
|
||||
@ -74,8 +80,8 @@ export const IssueLinkRoot: FC<TIssueLinkRoot> = (props) => {
|
||||
},
|
||||
remove: async (linkId: string) => {
|
||||
try {
|
||||
if (!workspaceSlug || !projectId || !peekIssue?.issueId) throw new Error("Missing required fields");
|
||||
await removeLink(workspaceSlug, projectId, peekIssue?.issueId, linkId);
|
||||
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields");
|
||||
await removeLink(workspaceSlug, projectId, issueId, linkId);
|
||||
setToastAlert({
|
||||
message: "The link has been successfully removed",
|
||||
type: "success",
|
||||
@ -91,28 +97,28 @@ export const IssueLinkRoot: FC<TIssueLinkRoot> = (props) => {
|
||||
}
|
||||
},
|
||||
}),
|
||||
[workspaceSlug, projectId, peekIssue, createLink, updateLink, removeLink, setToastAlert]
|
||||
[workspaceSlug, projectId, issueId, createLink, updateLink, removeLink, setToastAlert, toggleIssueLinkModal]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IssueLinkCreateUpdateModal
|
||||
isModalOpen={isIssueLinkModalOpen}
|
||||
isModalOpen={isIssueLinkModal}
|
||||
handleModal={toggleIssueLinkModal}
|
||||
linkOperations={handleLinkOperations}
|
||||
/>
|
||||
|
||||
<div className={`py-1 text-xs ${uneditable ? "opacity-60" : ""}`}>
|
||||
<div className={`py-1 text-xs ${is_archived ? "opacity-60" : ""}`}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h4>Links</h4>
|
||||
{isAllowed && (
|
||||
{is_editable && (
|
||||
<button
|
||||
type="button"
|
||||
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-custom-background-90 ${
|
||||
uneditable ? "cursor-not-allowed" : "cursor-pointer"
|
||||
is_archived ? "cursor-not-allowed" : "cursor-pointer"
|
||||
}`}
|
||||
onClick={() => setIsIssueLinkModalOpen(true)}
|
||||
disabled={uneditable}
|
||||
onClick={() => toggleIssueLinkModal(true)}
|
||||
disabled={is_archived}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
130
web/components/issues/issue-detail/main-content.tsx
Normal file
130
web/components/issues/issue-detail/main-content.tsx
Normal file
@ -0,0 +1,130 @@
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// hooks
|
||||
import { useIssueDetail, useProject, useProjectState, useUser } from "hooks/store";
|
||||
// components
|
||||
import { IssueDescriptionForm, IssueAttachmentRoot, IssueUpdateStatus } from "components/issues";
|
||||
import { IssueParentDetail } from "./parent";
|
||||
import { IssueReaction } from "./reactions";
|
||||
import { SubIssuesRoot } from "../sub-issues";
|
||||
// ui
|
||||
import { StateGroupIcon } from "@plane/ui";
|
||||
// types
|
||||
import { TIssueOperations } from "./root";
|
||||
// constants
|
||||
import { EUserProjectRoles } from "constants/project";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
issueOperations: TIssueOperations;
|
||||
is_archived: boolean;
|
||||
is_editable: boolean;
|
||||
};
|
||||
|
||||
export const IssueMainContent: React.FC<Props> = observer((props) => {
|
||||
const { workspaceSlug, projectId, issueId, issueOperations, is_archived, is_editable } = props;
|
||||
// states
|
||||
const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved");
|
||||
// hooks
|
||||
const {
|
||||
currentUser,
|
||||
membership: { currentProjectRole },
|
||||
} = useUser();
|
||||
const { getProjectById } = useProject();
|
||||
const { projectStates } = useProjectState();
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
|
||||
const issue = getIssueById(issueId);
|
||||
if (!issue) return <></>;
|
||||
|
||||
const projectDetails = projectId ? getProjectById(projectId) : null;
|
||||
const currentIssueState = projectStates?.find((s) => s.id === issue.state_id);
|
||||
|
||||
const isAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-lg space-y-4">
|
||||
{issue.parent_id && (
|
||||
<IssueParentDetail
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
issue={issue}
|
||||
issueOperations={issueOperations}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mb-2.5 flex items-center">
|
||||
{currentIssueState && (
|
||||
<StateGroupIcon
|
||||
className="mr-3 h-4 w-4"
|
||||
stateGroup={currentIssueState.group}
|
||||
color={currentIssueState.color}
|
||||
/>
|
||||
)}
|
||||
<IssueUpdateStatus isSubmitting={isSubmitting} issueDetail={issue} />
|
||||
</div>
|
||||
|
||||
<IssueDescriptionForm
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
setIsSubmitting={(value) => setIsSubmitting(value)}
|
||||
isSubmitting={isSubmitting}
|
||||
issue={issue}
|
||||
issueOperations={issueOperations}
|
||||
isAllowed={isAllowed || !is_editable}
|
||||
/>
|
||||
|
||||
{currentUser && (
|
||||
<IssueReaction
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
currentUser={currentUser}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentUser && (
|
||||
<SubIssuesRoot
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
currentUser={currentUser}
|
||||
is_archived={is_archived}
|
||||
is_editable={is_editable}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* issue attachments */}
|
||||
<IssueAttachmentRoot
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
is_archived={is_archived}
|
||||
is_editable={is_editable}
|
||||
/>
|
||||
|
||||
{/* <div className="space-y-5 pt-3">
|
||||
<h3 className="text-lg text-custom-text-100">Comments/Activity</h3>
|
||||
<IssueActivitySection
|
||||
activity={issueActivity}
|
||||
handleCommentUpdate={handleCommentUpdate}
|
||||
handleCommentDelete={handleCommentDelete}
|
||||
showAccessSpecifier={Boolean(projectDetails && projectDetails.is_deployed)}
|
||||
/>
|
||||
<AddComment
|
||||
onSubmit={handleAddComment}
|
||||
disabled={is_editable}
|
||||
showAccessSpecifier={Boolean(projectDetails && projectDetails.is_deployed)}
|
||||
/>
|
||||
</div> */}
|
||||
</>
|
||||
);
|
||||
});
|
103
web/components/issues/issue-detail/module-select.tsx
Normal file
103
web/components/issues/issue-detail/module-select.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
import React, { ReactNode, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import useSWR from "swr";
|
||||
// hooks
|
||||
import { useModule, useIssueDetail } from "hooks/store";
|
||||
// ui
|
||||
import { CustomSearchSelect, DiceIcon, Spinner, Tooltip } from "@plane/ui";
|
||||
// types
|
||||
import type { TIssueOperations } from "./root";
|
||||
|
||||
type TIssueModuleSelect = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
issueOperations: TIssueOperations;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const IssueModuleSelect: React.FC<TIssueModuleSelect> = observer((props) => {
|
||||
const { workspaceSlug, projectId, issueId, issueOperations, disabled = false } = props;
|
||||
// hooks
|
||||
const { getModuleById, projectModuleIds, fetchModules } = useModule();
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
// state
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
useSWR(workspaceSlug && projectId ? `PROJECT_${projectId}_ISSUE_${issueId}_MODULES` : null, async () => {
|
||||
if (workspaceSlug && projectId) await fetchModules(workspaceSlug, projectId);
|
||||
});
|
||||
|
||||
const issue = getIssueById(issueId);
|
||||
const issueModule = (issue && issue.module_id && getModuleById(issue.module_id)) || undefined;
|
||||
const disableSelect = disabled || isUpdating;
|
||||
|
||||
const handleIssueModuleChange = async (moduleId: string) => {
|
||||
if (!moduleId) return;
|
||||
setIsUpdating(true);
|
||||
if (issue && issue.module_id === moduleId)
|
||||
await issueOperations.removeIssueFromModule(workspaceSlug, projectId, moduleId, issueId);
|
||||
else await issueOperations.addIssueToModule(workspaceSlug, projectId, moduleId, [issueId]);
|
||||
setIsUpdating(false);
|
||||
};
|
||||
|
||||
type TDropdownOptions = { value: string; query: string; content: ReactNode }[];
|
||||
const options: TDropdownOptions | undefined = projectModuleIds
|
||||
? (projectModuleIds
|
||||
.map((moduleId) => {
|
||||
const _module = getModuleById(moduleId);
|
||||
if (!_module) return undefined;
|
||||
|
||||
return {
|
||||
value: _module.id,
|
||||
query: _module.name,
|
||||
content: (
|
||||
<div className="flex items-center gap-1.5 truncate">
|
||||
<span className="flex h-3.5 w-3.5 flex-shrink-0 items-center justify-center">
|
||||
<DiceIcon />
|
||||
</span>
|
||||
<span className="flex-grow truncate">{_module.name}</span>
|
||||
</div>
|
||||
) as ReactNode,
|
||||
};
|
||||
})
|
||||
.filter((_module) => _module !== undefined) as TDropdownOptions)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<CustomSearchSelect
|
||||
value={issue?.module_id}
|
||||
onChange={(value: any) => handleIssueModuleChange(value)}
|
||||
options={options}
|
||||
customButton={
|
||||
<div>
|
||||
<Tooltip position="left" tooltipContent={`${issueModule?.name ?? "No module"}`}>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex w-full items-center rounded bg-custom-background-80 px-2.5 py-0.5 text-xs ${
|
||||
disableSelect ? "cursor-not-allowed" : ""
|
||||
} max-w-[10rem]`}
|
||||
>
|
||||
<span
|
||||
className={`flex items-center gap-1.5 truncate ${
|
||||
issueModule ? "text-custom-text-100" : "text-custom-text-200"
|
||||
}`}
|
||||
>
|
||||
<span className="flex-shrink-0">{issueModule && <DiceIcon className="h-3.5 w-3.5" />}</span>
|
||||
<span className="truncate">{issueModule?.name ?? "No module"}</span>
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
}
|
||||
width="max-w-[10rem]"
|
||||
noChevron
|
||||
disabled={disableSelect}
|
||||
/>
|
||||
{isUpdating && <Spinner className="h-4 w-4" />}
|
||||
</div>
|
||||
);
|
||||
});
|
82
web/components/issues/issue-detail/parent-select.tsx
Normal file
82
web/components/issues/issue-detail/parent-select.tsx
Normal file
@ -0,0 +1,82 @@
|
||||
import React, { useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { X } from "lucide-react";
|
||||
// hooks
|
||||
import { useIssueDetail, useProject } from "hooks/store";
|
||||
import { Spinner } from "@plane/ui";
|
||||
// components
|
||||
import { ParentIssuesListModal } from "components/issues";
|
||||
import { TIssueOperations } from "./root";
|
||||
|
||||
type TIssueParentSelect = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
issueOperations: TIssueOperations;
|
||||
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const IssueParentSelect: React.FC<TIssueParentSelect> = observer(
|
||||
({ workspaceSlug, projectId, issueId, issueOperations, disabled = false }) => {
|
||||
// hooks
|
||||
const { getProjectById } = useProject();
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
// state
|
||||
const { isParentIssueModalOpen, toggleParentIssueModal } = useIssueDetail();
|
||||
const [updating, setUpdating] = useState(false);
|
||||
|
||||
const issue = getIssueById(issueId);
|
||||
|
||||
const parentIssue = issue && issue.parent_id ? getIssueById(issue.parent_id) : undefined;
|
||||
const parentIssueProjectDetails =
|
||||
parentIssue && parentIssue.project_id ? getProjectById(parentIssue.project_id) : undefined;
|
||||
|
||||
const handleParentIssue = async (_issueId: string | null = null) => {
|
||||
setUpdating(true);
|
||||
await issueOperations.update(workspaceSlug, projectId, issueId, { parent_id: _issueId }).finally(() => {
|
||||
toggleParentIssueModal(false);
|
||||
setUpdating(false);
|
||||
});
|
||||
};
|
||||
|
||||
if (!issue) return <></>;
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center gap-2">
|
||||
<ParentIssuesListModal
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
isOpen={isParentIssueModalOpen}
|
||||
handleClose={() => toggleParentIssueModal(false)}
|
||||
onChange={(issue: any) => handleParentIssue(issue?.id)}
|
||||
/>
|
||||
|
||||
<button
|
||||
className={`flex items-center gap-2 rounded bg-custom-background-80 px-2.5 py-0.5 text-xs w-max max-w-max" ${
|
||||
disabled ? "cursor-not-allowed" : "cursor-pointer "
|
||||
}`}
|
||||
disabled={disabled}
|
||||
>
|
||||
<div onClick={() => toggleParentIssueModal(true)}>
|
||||
{parentIssue ? (
|
||||
`${parentIssueProjectDetails?.identifier}-${parentIssue.sequence_id}`
|
||||
) : (
|
||||
<span className="text-custom-text-200">Select issue</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{parentIssue && (
|
||||
<div onClick={() => handleParentIssue(null)}>
|
||||
<X className="h-2.5 w-2.5" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{updating && <Spinner className="h-4 w-4" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
4
web/components/issues/issue-detail/parent/index.ts
Normal file
4
web/components/issues/issue-detail/parent/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from "./root";
|
||||
|
||||
export * from "./siblings";
|
||||
export * from "./sibling-item";
|
72
web/components/issues/issue-detail/parent/root.tsx
Normal file
72
web/components/issues/issue-detail/parent/root.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
import { FC } from "react";
|
||||
import Link from "next/link";
|
||||
import { MinusCircle } from "lucide-react";
|
||||
// component
|
||||
import { IssueParentSiblings } from "./siblings";
|
||||
// ui
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
// hooks
|
||||
import { useIssueDetail, useIssues, useProject, useProjectState } from "hooks/store";
|
||||
// types
|
||||
import { TIssueOperations } from "../root";
|
||||
import { TIssue } from "@plane/types";
|
||||
|
||||
export type TIssueParentDetail = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
issue: TIssue;
|
||||
issueOperations: TIssueOperations;
|
||||
};
|
||||
|
||||
export const IssueParentDetail: FC<TIssueParentDetail> = (props) => {
|
||||
const { workspaceSlug, projectId, issueId, issue, issueOperations } = props;
|
||||
// hooks
|
||||
const { issueMap } = useIssues();
|
||||
const { peekIssue } = useIssueDetail();
|
||||
const { getProjectById } = useProject();
|
||||
const { getProjectStates } = useProjectState();
|
||||
|
||||
const parentIssue = issueMap?.[issue.parent_id || ""] || undefined;
|
||||
|
||||
const issueParentState = getProjectStates(parentIssue?.project_id)?.find(
|
||||
(state) => state?.id === parentIssue?.state_id
|
||||
);
|
||||
const stateColor = issueParentState?.color || undefined;
|
||||
|
||||
if (!parentIssue) return <></>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-5 flex w-min items-center gap-3 whitespace-nowrap rounded-md border border-custom-border-300 bg-custom-background-80 px-2.5 py-1 text-xs">
|
||||
<Link href={`/${peekIssue?.workspaceSlug}/projects/${parentIssue?.project_id}/issues/${parentIssue.id}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="block h-2 w-2 rounded-full" style={{ backgroundColor: stateColor }} />
|
||||
<span className="flex-shrink-0 text-custom-text-200">
|
||||
{getProjectById(parentIssue.project_id)?.identifier}-{parentIssue?.sequence_id}
|
||||
</span>
|
||||
</div>
|
||||
<span className="truncate text-custom-text-100">{(parentIssue?.name ?? "").substring(0, 50)}</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<CustomMenu ellipsis optionsClassName="p-1.5">
|
||||
<div className="border-b border-custom-border-300 text-xs font-medium text-custom-text-200">
|
||||
Sibling issues
|
||||
</div>
|
||||
|
||||
<IssueParentSiblings currentIssue={issue} parentIssue={parentIssue} />
|
||||
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => issueOperations.update(workspaceSlug, projectId, issueId, { parent_id: null })}
|
||||
className="flex items-center gap-2 py-2 text-red-500"
|
||||
>
|
||||
<MinusCircle className="h-4 w-4" />
|
||||
<span> Remove Parent Issue</span>
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
39
web/components/issues/issue-detail/parent/sibling-item.tsx
Normal file
39
web/components/issues/issue-detail/parent/sibling-item.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import { FC } from "react";
|
||||
import Link from "next/link";
|
||||
// ui
|
||||
import { CustomMenu, LayersIcon } from "@plane/ui";
|
||||
// hooks
|
||||
import { useIssueDetail, useProject } from "hooks/store";
|
||||
|
||||
type TIssueParentSiblingItem = {
|
||||
issueId: string;
|
||||
};
|
||||
|
||||
export const IssueParentSiblingItem: FC<TIssueParentSiblingItem> = (props) => {
|
||||
const { issueId } = props;
|
||||
// hooks
|
||||
const { getProjectById } = useProject();
|
||||
const {
|
||||
peekIssue,
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
|
||||
const issueDetail = (issueId && getIssueById(issueId)) || undefined;
|
||||
if (!issueDetail) return <></>;
|
||||
|
||||
const projectDetails = (issueDetail.project_id && getProjectById(issueDetail.project_id)) || undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<CustomMenu.MenuItem key={issueDetail.id}>
|
||||
<Link
|
||||
href={`/${peekIssue?.workspaceSlug}/projects/${issueDetail?.project_id as string}/issues/${issueDetail.id}`}
|
||||
className="flex items-center gap-2 py-2"
|
||||
>
|
||||
<LayersIcon className="h-4 w-4" />
|
||||
{projectDetails?.identifier}-{issueDetail.sequence_id}
|
||||
</Link>
|
||||
</CustomMenu.MenuItem>
|
||||
</>
|
||||
);
|
||||
};
|
51
web/components/issues/issue-detail/parent/siblings.tsx
Normal file
51
web/components/issues/issue-detail/parent/siblings.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import { FC } from "react";
|
||||
import useSWR from "swr";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// components
|
||||
import { IssueParentSiblingItem } from "./sibling-item";
|
||||
// hooks
|
||||
import { useIssueDetail } from "hooks/store";
|
||||
// types
|
||||
import { TIssue } from "@plane/types";
|
||||
|
||||
export type TIssueParentSiblings = {
|
||||
currentIssue: TIssue;
|
||||
parentIssue: TIssue;
|
||||
};
|
||||
|
||||
export const IssueParentSiblings: FC<TIssueParentSiblings> = (props) => {
|
||||
const { currentIssue, parentIssue } = props;
|
||||
// hooks
|
||||
const {
|
||||
peekIssue,
|
||||
fetchSubIssues,
|
||||
subIssues: { subIssuesByIssueId },
|
||||
} = useIssueDetail();
|
||||
|
||||
const { isLoading } = useSWR(
|
||||
peekIssue && parentIssue
|
||||
? `ISSUE_PARENT_CHILD_ISSUES_${peekIssue?.workspaceSlug}_${parentIssue.project_id}_${parentIssue.id}`
|
||||
: null,
|
||||
peekIssue && parentIssue
|
||||
? () => fetchSubIssues(peekIssue?.workspaceSlug, parentIssue.project_id, parentIssue.id)
|
||||
: null
|
||||
);
|
||||
|
||||
const subIssueIds = (parentIssue && subIssuesByIssueId(parentIssue.id)) || undefined;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2 whitespace-nowrap px-1 py-1 text-left text-xs text-custom-text-200">
|
||||
Loading
|
||||
</div>
|
||||
) : subIssueIds && subIssueIds.length > 0 ? (
|
||||
subIssueIds.map((issueId) => currentIssue.id != issueId && <IssueParentSiblingItem issueId={issueId} />)
|
||||
) : (
|
||||
<div className="flex items-center gap-2 whitespace-nowrap px-1 py-1 text-left text-xs text-custom-text-200">
|
||||
No sibling issues
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
4
web/components/issues/issue-detail/reactions/index.ts
Normal file
4
web/components/issues/issue-detail/reactions/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from "./reaction-selector";
|
||||
|
||||
export * from "./issue";
|
||||
// export * from "./issue-comment";
|
103
web/components/issues/issue-detail/reactions/issue.tsx
Normal file
103
web/components/issues/issue-detail/reactions/issue.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
import { FC, useMemo } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// components
|
||||
import { ReactionSelector } from "./reaction-selector";
|
||||
// hooks
|
||||
import { useIssueDetail } from "hooks/store";
|
||||
import useToast from "hooks/use-toast";
|
||||
// types
|
||||
import { IUser } from "@plane/types";
|
||||
import { renderEmoji } from "helpers/emoji.helper";
|
||||
|
||||
export type TIssueReaction = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
currentUser: IUser;
|
||||
};
|
||||
|
||||
export const IssueReaction: FC<TIssueReaction> = observer((props) => {
|
||||
const { workspaceSlug, projectId, issueId, currentUser } = props;
|
||||
// hooks
|
||||
const {
|
||||
reaction: { getReactionsByIssueId, reactionsByUser },
|
||||
createReaction,
|
||||
removeReaction,
|
||||
} = useIssueDetail();
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const reactionIds = getReactionsByIssueId(issueId);
|
||||
const userReactions = reactionsByUser(issueId, currentUser.id).map((r) => r.reaction);
|
||||
|
||||
const issueReactionOperations = useMemo(
|
||||
() => ({
|
||||
create: async (reaction: string) => {
|
||||
try {
|
||||
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing fields");
|
||||
await createReaction(workspaceSlug, projectId, issueId, reaction);
|
||||
setToastAlert({
|
||||
title: "Reaction created successfully",
|
||||
type: "success",
|
||||
message: "Reaction created successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
setToastAlert({
|
||||
title: "Reaction creation failed",
|
||||
type: "error",
|
||||
message: "Reaction creation failed",
|
||||
});
|
||||
}
|
||||
},
|
||||
remove: async (reaction: string) => {
|
||||
try {
|
||||
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing fields");
|
||||
await removeReaction(workspaceSlug, projectId, issueId, reaction, currentUser.id);
|
||||
setToastAlert({
|
||||
title: "Reaction removed successfully",
|
||||
type: "success",
|
||||
message: "Reaction removed successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
setToastAlert({
|
||||
title: "Reaction remove failed",
|
||||
type: "error",
|
||||
message: "Reaction remove failed",
|
||||
});
|
||||
}
|
||||
},
|
||||
react: async (reaction: string) => {
|
||||
if (userReactions.includes(reaction)) await issueReactionOperations.remove(reaction);
|
||||
else await issueReactionOperations.create(reaction);
|
||||
},
|
||||
}),
|
||||
[workspaceSlug, projectId, issueId, currentUser, createReaction, removeReaction, setToastAlert, userReactions]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mt-4 relative flex items-center gap-1.5">
|
||||
<ReactionSelector size="md" position="top" value={userReactions} onSelect={issueReactionOperations.react} />
|
||||
|
||||
{reactionIds &&
|
||||
Object.keys(reactionIds || {}).map(
|
||||
(reaction) =>
|
||||
reactionIds[reaction]?.length > 0 && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => issueReactionOperations.react(reaction)}
|
||||
key={reaction}
|
||||
className={`flex h-full items-center gap-1 rounded-md px-2 py-1 text-sm text-custom-text-100 ${
|
||||
userReactions.includes(reaction) ? "bg-custom-primary-100/10" : "bg-custom-background-80"
|
||||
}`}
|
||||
>
|
||||
<span>{renderEmoji(reaction)}</span>
|
||||
<span className={userReactions.includes(reaction) ? "text-custom-primary-100" : ""}>
|
||||
{(reactionIds || {})[reaction].length}{" "}
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
@ -1,5 +1,4 @@
|
||||
import React, { useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { X, CopyPlus } from "lucide-react";
|
||||
// hooks
|
||||
@ -37,17 +36,16 @@ const issueRelationObject: Record<TIssueRelationTypes, TRelationObject> = {
|
||||
},
|
||||
};
|
||||
|
||||
type Props = {
|
||||
type TIssueRelationSelect = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
relationKey: TIssueRelationTypes;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const SidebarIssueRelationSelect: React.FC<Props> = observer((props) => {
|
||||
const { issueId, relationKey, disabled = false } = props;
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
export const IssueRelationSelect: React.FC<TIssueRelationSelect> = observer((props) => {
|
||||
const { workspaceSlug, projectId, issueId, relationKey, disabled = false } = props;
|
||||
// hooks
|
||||
const { currentUser } = useUser();
|
||||
const { getProjectById } = useProject();
|
199
web/components/issues/issue-detail/root.tsx
Normal file
199
web/components/issues/issue-detail/root.tsx
Normal file
@ -0,0 +1,199 @@
|
||||
import { FC, useMemo } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
// components
|
||||
import { IssueMainContent } from "./main-content";
|
||||
import { IssueDetailsSidebar } from "./sidebar";
|
||||
// ui
|
||||
import { EmptyState } from "components/common";
|
||||
// images
|
||||
import emptyIssue from "public/empty-state/issue.svg";
|
||||
// hooks
|
||||
import { useIssueDetail } from "hooks/store";
|
||||
import useToast from "hooks/use-toast";
|
||||
// types
|
||||
import { TIssue } from "@plane/types";
|
||||
|
||||
export type TIssueOperations = {
|
||||
update: (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>;
|
||||
remove: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
|
||||
addIssueToCycle: (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => Promise<void>;
|
||||
removeIssueFromCycle: (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => Promise<void>;
|
||||
addIssueToModule: (workspaceSlug: string, projectId: string, moduleId: string, issueIds: string[]) => Promise<void>;
|
||||
removeIssueFromModule: (workspaceSlug: string, projectId: string, moduleId: string, issueId: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export type TIssueDetailRoot = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
is_archived?: boolean;
|
||||
is_editable?: boolean;
|
||||
};
|
||||
|
||||
export const IssueDetailRoot: FC<TIssueDetailRoot> = (props) => {
|
||||
const { workspaceSlug, projectId, issueId, is_archived = false, is_editable = true } = props;
|
||||
// router
|
||||
const router = useRouter();
|
||||
// hooks
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
updateIssue,
|
||||
removeIssue,
|
||||
addIssueToCycle,
|
||||
removeIssueFromCycle,
|
||||
addIssueToModule,
|
||||
removeIssueFromModule,
|
||||
} = useIssueDetail();
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const issueOperations: TIssueOperations = useMemo(
|
||||
() => ({
|
||||
update: async (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => {
|
||||
try {
|
||||
await updateIssue(workspaceSlug, projectId, issueId, data);
|
||||
setToastAlert({
|
||||
title: "Issue updated successfully",
|
||||
type: "success",
|
||||
message: "Issue updated successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
setToastAlert({
|
||||
title: "Issue update failed",
|
||||
type: "error",
|
||||
message: "Issue update failed",
|
||||
});
|
||||
}
|
||||
},
|
||||
remove: async (workspaceSlug: string, projectId: string, issueId: string) => {
|
||||
try {
|
||||
await removeIssue(workspaceSlug, projectId, issueId);
|
||||
setToastAlert({
|
||||
title: "Issue deleted successfully",
|
||||
type: "success",
|
||||
message: "Issue deleted successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
setToastAlert({
|
||||
title: "Issue delete failed",
|
||||
type: "error",
|
||||
message: "Issue delete failed",
|
||||
});
|
||||
}
|
||||
},
|
||||
addIssueToCycle: async (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => {
|
||||
try {
|
||||
await addIssueToCycle(workspaceSlug, projectId, cycleId, issueIds);
|
||||
setToastAlert({
|
||||
title: "Cycle added to issue successfully",
|
||||
type: "success",
|
||||
message: "Issue added to issue successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
setToastAlert({
|
||||
title: "Cycle add to issue failed",
|
||||
type: "error",
|
||||
message: "Cycle add to issue failed",
|
||||
});
|
||||
}
|
||||
},
|
||||
removeIssueFromCycle: async (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => {
|
||||
try {
|
||||
await removeIssueFromCycle(workspaceSlug, projectId, cycleId, issueId);
|
||||
setToastAlert({
|
||||
title: "Cycle removed from issue successfully",
|
||||
type: "success",
|
||||
message: "Cycle removed from issue successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
setToastAlert({
|
||||
title: "Cycle remove from issue failed",
|
||||
type: "error",
|
||||
message: "Cycle remove from issue failed",
|
||||
});
|
||||
}
|
||||
},
|
||||
addIssueToModule: async (workspaceSlug: string, projectId: string, moduleId: string, issueIds: string[]) => {
|
||||
try {
|
||||
await addIssueToModule(workspaceSlug, projectId, moduleId, issueIds);
|
||||
setToastAlert({
|
||||
title: "Module added to issue successfully",
|
||||
type: "success",
|
||||
message: "Module added to issue successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
setToastAlert({
|
||||
title: "Module add to issue failed",
|
||||
type: "error",
|
||||
message: "Module add to issue failed",
|
||||
});
|
||||
}
|
||||
},
|
||||
removeIssueFromModule: async (workspaceSlug: string, projectId: string, moduleId: string, issueId: string) => {
|
||||
try {
|
||||
await removeIssueFromModule(workspaceSlug, projectId, moduleId, issueId);
|
||||
setToastAlert({
|
||||
title: "Module removed from issue successfully",
|
||||
type: "success",
|
||||
message: "Module removed from issue successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
setToastAlert({
|
||||
title: "Module remove from issue failed",
|
||||
type: "error",
|
||||
message: "Module remove from issue failed",
|
||||
});
|
||||
}
|
||||
},
|
||||
}),
|
||||
[
|
||||
updateIssue,
|
||||
removeIssue,
|
||||
addIssueToCycle,
|
||||
removeIssueFromCycle,
|
||||
addIssueToModule,
|
||||
removeIssueFromModule,
|
||||
setToastAlert,
|
||||
]
|
||||
);
|
||||
|
||||
const issue = getIssueById(issueId);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!issue ? (
|
||||
<EmptyState
|
||||
image={emptyIssue}
|
||||
title="Issue does not exist"
|
||||
description="The issue you are looking for does not exist, has been archived, or has been deleted."
|
||||
primaryButton={{
|
||||
text: "View other issues",
|
||||
onClick: () => router.push(`/${workspaceSlug}/projects/${projectId}/issues`),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full overflow-hidden">
|
||||
<div className="h-full w-2/3 space-y-5 divide-y-2 divide-custom-border-300 overflow-y-auto p-5">
|
||||
<IssueMainContent
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
issueOperations={issueOperations}
|
||||
is_archived={is_archived}
|
||||
is_editable={is_editable}
|
||||
/>
|
||||
</div>
|
||||
<div className="h-full w-1/3 space-y-5 overflow-hidden border-l border-custom-border-300 py-5">
|
||||
<IssueDetailsSidebar
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
issueOperations={issueOperations}
|
||||
is_archived={is_archived}
|
||||
is_editable={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
@ -1,45 +1,40 @@
|
||||
import React, { useCallback, useState } from "react";
|
||||
import React, { useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { mutate } from "swr";
|
||||
import { Controller, UseFormWatch } from "react-hook-form";
|
||||
import { Bell, CalendarDays, LinkIcon, Signal, Tag, Trash2, Triangle, LayoutPanelTop } from "lucide-react";
|
||||
import { CalendarDays, LinkIcon, Signal, Tag, Trash2, Triangle, LayoutPanelTop } from "lucide-react";
|
||||
// hooks
|
||||
import { useEstimate, useIssues, useProject, useProjectState, useUser } from "hooks/store";
|
||||
import { useEstimate, useIssueDetail, useProject, useProjectState, useUser } from "hooks/store";
|
||||
import useToast from "hooks/use-toast";
|
||||
import useUserIssueNotificationSubscription from "hooks/use-issue-notification-subscription";
|
||||
// services
|
||||
import { IssueService } from "services/issue";
|
||||
import { ModuleService } from "services/module.service";
|
||||
// components
|
||||
import {
|
||||
DeleteIssueModal,
|
||||
SidebarIssueRelationSelect,
|
||||
SidebarCycleSelect,
|
||||
SidebarModuleSelect,
|
||||
SidebarParentSelect,
|
||||
SidebarLabelSelect,
|
||||
IssueLinkRoot,
|
||||
IssueRelationSelect,
|
||||
IssueCycleSelect,
|
||||
IssueModuleSelect,
|
||||
IssueParentSelect,
|
||||
IssueLabel,
|
||||
} from "components/issues";
|
||||
import { IssueSubscription } from "./subscription";
|
||||
import { EstimateDropdown, PriorityDropdown, ProjectMemberDropdown, StateDropdown } from "components/dropdowns";
|
||||
// ui
|
||||
import { CustomDatePicker } from "components/ui";
|
||||
// icons
|
||||
import { Button, ContrastIcon, DiceIcon, DoubleCircleIcon, StateGroupIcon, UserGroupIcon } from "@plane/ui";
|
||||
import { ContrastIcon, DiceIcon, DoubleCircleIcon, StateGroupIcon, UserGroupIcon } from "@plane/ui";
|
||||
// helpers
|
||||
import { copyTextToClipboard } from "helpers/string.helper";
|
||||
// types
|
||||
import type { TIssue } from "@plane/types";
|
||||
import type { TIssueOperations } from "./root";
|
||||
// fetch-keys
|
||||
import { ISSUE_DETAILS } from "constants/fetch-keys";
|
||||
import { EUserProjectRoles } from "constants/project";
|
||||
import { EIssuesStoreType } from "constants/issue";
|
||||
|
||||
type Props = {
|
||||
control: any;
|
||||
submitChanges: (formData: any) => void;
|
||||
issueDetail: TIssue | undefined;
|
||||
watch: UseFormWatch<TIssue>;
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
issueOperations: TIssueOperations;
|
||||
is_archived: boolean;
|
||||
is_editable: boolean;
|
||||
fieldsToShow?: (
|
||||
| "state"
|
||||
| "assignee"
|
||||
@ -60,74 +55,42 @@ type Props = {
|
||||
| "duplicate"
|
||||
| "relates_to"
|
||||
)[];
|
||||
uneditable?: boolean;
|
||||
};
|
||||
|
||||
const issueService = new IssueService();
|
||||
const moduleService = new ModuleService();
|
||||
|
||||
export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
const { control, submitChanges, issueDetail, watch: watchIssue, fieldsToShow = ["all"], uneditable = false } = props;
|
||||
// states
|
||||
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
|
||||
const {
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
issueId,
|
||||
issueOperations,
|
||||
is_archived,
|
||||
is_editable,
|
||||
fieldsToShow = ["all"],
|
||||
} = props;
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { inboxIssueId } = router.query;
|
||||
// store hooks
|
||||
const { getProjectById } = useProject();
|
||||
const {
|
||||
issues: { removeIssue },
|
||||
} = useIssues(EIssuesStoreType.PROJECT);
|
||||
const {
|
||||
currentUser,
|
||||
membership: { currentProjectRole },
|
||||
} = useUser();
|
||||
const { projectStates } = useProjectState();
|
||||
const { areEstimatesEnabledForCurrentProject } = useEstimate();
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, issueId, inboxIssueId } = router.query;
|
||||
|
||||
const { loading, handleSubscribe, handleUnsubscribe, subscribed } = useUserIssueNotificationSubscription(
|
||||
currentUser,
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
issueId
|
||||
);
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
// states
|
||||
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
|
||||
|
||||
const handleCycleChange = useCallback(
|
||||
(cycleId: string) => {
|
||||
if (!workspaceSlug || !projectId || !issueDetail || !currentUser) return;
|
||||
|
||||
issueService
|
||||
.addIssueToCycle(workspaceSlug as string, projectId as string, cycleId, {
|
||||
issues: [issueDetail.id],
|
||||
})
|
||||
.then(() => {
|
||||
mutate(ISSUE_DETAILS(issueId as string));
|
||||
});
|
||||
},
|
||||
[workspaceSlug, projectId, issueId, issueDetail, currentUser]
|
||||
);
|
||||
|
||||
const handleModuleChange = useCallback(
|
||||
(moduleId: string) => {
|
||||
if (!workspaceSlug || !projectId || !issueDetail || !currentUser) return;
|
||||
|
||||
moduleService
|
||||
.addIssuesToModule(workspaceSlug as string, projectId as string, moduleId, {
|
||||
issues: [issueDetail.id],
|
||||
})
|
||||
.then(() => {
|
||||
mutate(ISSUE_DETAILS(issueId as string));
|
||||
});
|
||||
},
|
||||
[workspaceSlug, projectId, issueId, issueDetail, currentUser]
|
||||
);
|
||||
const issue = getIssueById(issueId);
|
||||
if (!issue) return <></>;
|
||||
|
||||
const handleCopyText = () => {
|
||||
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
|
||||
|
||||
copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/issues/${issueDetail?.id}`).then(() => {
|
||||
copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`).then(() => {
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Link Copied!",
|
||||
@ -136,7 +99,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
});
|
||||
};
|
||||
|
||||
const projectDetails = issueDetail ? getProjectById(issueDetail?.project_id) : null;
|
||||
const projectDetails = issue ? getProjectById(issue.project_id) : null;
|
||||
|
||||
const showFirstSection =
|
||||
fieldsToShow.includes("all") ||
|
||||
@ -155,28 +118,25 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
const showThirdSection =
|
||||
fieldsToShow.includes("all") || fieldsToShow.includes("cycle") || fieldsToShow.includes("module");
|
||||
|
||||
const startDate = watchIssue("start_date");
|
||||
const targetDate = watchIssue("target_date");
|
||||
|
||||
const minDate = startDate ? new Date(startDate) : null;
|
||||
const minDate = issue.start_date ? new Date(issue.start_date) : null;
|
||||
minDate?.setDate(minDate.getDate());
|
||||
|
||||
const maxDate = targetDate ? new Date(targetDate) : null;
|
||||
const maxDate = issue.target_date ? new Date(issue.target_date) : null;
|
||||
maxDate?.setDate(maxDate.getDate());
|
||||
|
||||
const isAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
|
||||
|
||||
const currentIssueState = projectStates?.find((s) => s.id === issueDetail?.state_id);
|
||||
const currentIssueState = projectStates?.find((s) => s.id === issue.state_id);
|
||||
|
||||
return (
|
||||
<>
|
||||
{workspaceSlug && projectId && issueDetail && (
|
||||
{workspaceSlug && projectId && issue && (
|
||||
<DeleteIssueModal
|
||||
handleClose={() => setDeleteIssueModal(false)}
|
||||
isOpen={deleteIssueModal}
|
||||
data={issueDetail}
|
||||
data={issue}
|
||||
onSubmit={async () => {
|
||||
await removeIssue(workspaceSlug.toString(), projectId.toString(), issueDetail.id);
|
||||
await issueOperations.remove(workspaceSlug, projectId, issueId);
|
||||
router.push(`/${workspaceSlug}/projects/${projectId}/issues`);
|
||||
}}
|
||||
/>
|
||||
@ -195,28 +155,22 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
<StateGroupIcon className="h-4 w-4" stateGroup="backlog" color="#ff7700" />
|
||||
) : null}
|
||||
<h4 className="text-lg font-medium text-custom-text-300">
|
||||
{projectDetails?.identifier}-{issueDetail?.sequence_id}
|
||||
{projectDetails?.identifier}-{issue?.sequence_id}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{issueDetail?.created_by !== currentUser?.id &&
|
||||
!issueDetail?.assignee_ids.includes(currentUser?.id ?? "") &&
|
||||
!router.pathname.includes("[archivedIssueId]") &&
|
||||
(fieldsToShow.includes("all") || fieldsToShow.includes("subscribe")) && (
|
||||
<Button
|
||||
size="sm"
|
||||
prependIcon={<Bell className="h-3 w-3" />}
|
||||
variant="outline-primary"
|
||||
className="hover:!bg-custom-primary-100/20"
|
||||
onClick={() => {
|
||||
if (subscribed) handleUnsubscribe();
|
||||
else handleSubscribe();
|
||||
}}
|
||||
>
|
||||
{loading ? "Loading..." : subscribed ? "Unsubscribe" : "Subscribe"}
|
||||
</Button>
|
||||
)}
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("link")) && (
|
||||
{currentUser && !is_archived && (fieldsToShow.includes("all") || fieldsToShow.includes("subscribe")) && (
|
||||
<IssueSubscription
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
currentUserId={currentUser?.id}
|
||||
disabled={!isAllowed || !is_editable}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* {(fieldsToShow.includes("all") || fieldsToShow.includes("link")) && (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-custom-border-200 p-2 shadow-sm duration-300 hover:bg-custom-background-90 focus:border-custom-primary focus:outline-none focus:ring-1 focus:ring-custom-primary"
|
||||
@ -224,8 +178,9 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
>
|
||||
<LinkIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{isAllowed && (fieldsToShow.includes("all") || fieldsToShow.includes("delete")) && (
|
||||
)} */}
|
||||
|
||||
{/* {isAllowed && (fieldsToShow.includes("all") || fieldsToShow.includes("delete")) && (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-red-500 p-2 text-red-500 shadow-sm duration-300 hover:bg-red-500/20 focus:outline-none"
|
||||
@ -233,12 +188,12 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
)} */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-full w-full overflow-y-auto px-5">
|
||||
<div className={`divide-y-2 divide-custom-border-200 ${uneditable ? "opacity-60" : ""}`}>
|
||||
<div className={`divide-y-2 divide-custom-border-200 ${is_editable ? "opacity-60" : ""}`}>
|
||||
{showFirstSection && (
|
||||
<div className="py-1">
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("state")) && (
|
||||
@ -247,71 +202,63 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
<DoubleCircleIcon className="h-4 w-4 flex-shrink-0" />
|
||||
<p>State</p>
|
||||
</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="state"
|
||||
render={({ field: { value } }) => (
|
||||
<div className="h-5 sm:w-1/2">
|
||||
<StateDropdown
|
||||
value={value}
|
||||
onChange={(val) => submitChanges({ state: val })}
|
||||
projectId={projectId?.toString() ?? ""}
|
||||
disabled={!isAllowed || uneditable}
|
||||
buttonVariant="background-with-text"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="h-5 sm:w-1/2">
|
||||
<StateDropdown
|
||||
value={issue?.state_id ?? undefined}
|
||||
onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { state_id: val })}
|
||||
projectId={projectId?.toString() ?? ""}
|
||||
disabled={!isAllowed || !is_editable}
|
||||
buttonVariant="background-with-text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("assignee")) && (
|
||||
<div className="flex flex-wrap items-center py-2">
|
||||
<div className="flex items-center gap-x-2 text-sm text-custom-text-200 sm:w-1/2">
|
||||
<UserGroupIcon className="h-4 w-4 flex-shrink-0" />
|
||||
<p>Assignees</p>
|
||||
</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="assignees"
|
||||
render={({ field: { value } }) => (
|
||||
<div className="h-5 sm:w-1/2">
|
||||
<ProjectMemberDropdown
|
||||
value={value}
|
||||
onChange={(val) => submitChanges({ assignees: val })}
|
||||
disabled={!isAllowed || uneditable}
|
||||
projectId={projectId?.toString() ?? ""}
|
||||
placeholder="Assignees"
|
||||
multiple
|
||||
buttonVariant={value?.length > 0 ? "transparent-without-text" : "background-with-text"}
|
||||
buttonClassName={value?.length > 0 ? "hover:bg-transparent px-0" : ""}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="h-5 sm:w-1/2">
|
||||
<ProjectMemberDropdown
|
||||
value={issue?.assignee_ids ?? undefined}
|
||||
onChange={(val) =>
|
||||
issueOperations.update(workspaceSlug, projectId, issueId, { assignee_ids: val })
|
||||
}
|
||||
disabled={!isAllowed || !is_editable}
|
||||
projectId={projectId?.toString() ?? ""}
|
||||
placeholder="Assignees"
|
||||
multiple
|
||||
buttonVariant={
|
||||
issue?.assignee_ids?.length > 0 ? "transparent-without-text" : "background-with-text"
|
||||
}
|
||||
buttonClassName={issue?.assignee_ids?.length > 0 ? "hover:bg-transparent px-0" : ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("priority")) && (
|
||||
<div className="flex flex-wrap items-center py-2">
|
||||
<div className="flex items-center gap-x-2 text-sm text-custom-text-200 sm:w-1/2">
|
||||
<Signal className="h-4 w-4 flex-shrink-0" />
|
||||
<p>Priority</p>
|
||||
</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="priority"
|
||||
render={({ field: { value } }) => (
|
||||
<div className="h-5 sm:w-1/2">
|
||||
<PriorityDropdown
|
||||
value={value}
|
||||
onChange={(val) => submitChanges({ priority: val })}
|
||||
disabled={!isAllowed || uneditable}
|
||||
buttonVariant="background-with-text"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="h-5 sm:w-1/2">
|
||||
<PriorityDropdown
|
||||
value={issue?.priority || undefined}
|
||||
onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { priority: val })}
|
||||
disabled={!isAllowed || !is_editable}
|
||||
buttonVariant="background-with-text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("estimate")) &&
|
||||
areEstimatesEnabledForCurrentProject && (
|
||||
<div className="flex flex-wrap items-center py-2">
|
||||
@ -319,25 +266,23 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
<Triangle className="h-4 w-4 flex-shrink-0 " />
|
||||
<p>Estimate</p>
|
||||
</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="estimate_point"
|
||||
render={({ field: { value } }) => (
|
||||
<div className="h-5 sm:w-1/2">
|
||||
<EstimateDropdown
|
||||
value={value}
|
||||
onChange={(val) => submitChanges({ estimate_point: val })}
|
||||
projectId={projectId?.toString() ?? ""}
|
||||
disabled={!isAllowed || uneditable}
|
||||
buttonVariant="background-with-text"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="h-5 sm:w-1/2">
|
||||
<EstimateDropdown
|
||||
value={issue?.estimate_point || null}
|
||||
onChange={(val) =>
|
||||
issueOperations.update(workspaceSlug, projectId, issueId, { estimate_point: val })
|
||||
}
|
||||
projectId={projectId}
|
||||
disabled={!isAllowed || !is_editable}
|
||||
buttonVariant="background-with-text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showSecondSection && (
|
||||
<div className="py-1">
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && (
|
||||
@ -347,53 +292,54 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
<p>Parent</p>
|
||||
</div>
|
||||
<div className="sm:basis-1/2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="parent"
|
||||
render={({ field: { onChange } }) => (
|
||||
<SidebarParentSelect
|
||||
onChange={(val: string) => {
|
||||
submitChanges({ parent: val });
|
||||
onChange(val);
|
||||
}}
|
||||
issueDetails={issueDetail}
|
||||
disabled={!isAllowed || uneditable}
|
||||
/>
|
||||
)}
|
||||
<IssueParentSelect
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
issueOperations={issueOperations}
|
||||
disabled={!isAllowed || !is_editable}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("blocker")) && (
|
||||
<SidebarIssueRelationSelect
|
||||
issueId={issueId as string}
|
||||
<IssueRelationSelect
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
relationKey="blocking"
|
||||
disabled={!isAllowed || uneditable}
|
||||
disabled={!isAllowed || !is_editable}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("blocked")) && (
|
||||
<SidebarIssueRelationSelect
|
||||
issueId={issueId as string}
|
||||
<IssueRelationSelect
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
relationKey="blocked_by"
|
||||
disabled={!isAllowed || uneditable}
|
||||
disabled={!isAllowed || !is_editable}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("duplicate")) && (
|
||||
<SidebarIssueRelationSelect
|
||||
issueId={issueId as string}
|
||||
<IssueRelationSelect
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
relationKey="duplicate"
|
||||
disabled={!isAllowed || uneditable}
|
||||
disabled={!isAllowed || !is_editable}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("relates_to")) && (
|
||||
<SidebarIssueRelationSelect
|
||||
issueId={issueId as string}
|
||||
<IssueRelationSelect
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
relationKey="relates_to"
|
||||
disabled={!isAllowed || uneditable}
|
||||
disabled={!isAllowed || !is_editable}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -404,27 +350,20 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
<p>Start date</p>
|
||||
</div>
|
||||
<div className="sm:basis-1/2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="start_date"
|
||||
render={({ field: { value } }) => (
|
||||
<CustomDatePicker
|
||||
placeholder="Start date"
|
||||
value={value}
|
||||
onChange={(val) =>
|
||||
submitChanges({
|
||||
start_date: val,
|
||||
})
|
||||
}
|
||||
className="border-none bg-custom-background-80"
|
||||
maxDate={maxDate ?? undefined}
|
||||
disabled={!isAllowed || uneditable}
|
||||
/>
|
||||
)}
|
||||
<CustomDatePicker
|
||||
placeholder="Start date"
|
||||
value={issue.start_date || undefined}
|
||||
onChange={(val) =>
|
||||
issueOperations.update(workspaceSlug, projectId, issueId, { start_date: val })
|
||||
}
|
||||
className="border-none bg-custom-background-80"
|
||||
maxDate={maxDate ?? undefined}
|
||||
disabled={!isAllowed || !is_editable}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("dueDate")) && (
|
||||
<div className="flex flex-wrap items-center py-2">
|
||||
<div className="flex items-center gap-x-2 text-sm text-custom-text-200 sm:basis-1/2">
|
||||
@ -432,23 +371,15 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
<p>Due date</p>
|
||||
</div>
|
||||
<div className="sm:basis-1/2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="target_date"
|
||||
render={({ field: { value } }) => (
|
||||
<CustomDatePicker
|
||||
placeholder="Due date"
|
||||
value={value}
|
||||
onChange={(val) =>
|
||||
submitChanges({
|
||||
target_date: val,
|
||||
})
|
||||
}
|
||||
className="border-none bg-custom-background-80"
|
||||
minDate={minDate ?? undefined}
|
||||
disabled={!isAllowed || uneditable}
|
||||
/>
|
||||
)}
|
||||
<CustomDatePicker
|
||||
placeholder="Due date"
|
||||
value={issue.target_date || undefined}
|
||||
onChange={(val) =>
|
||||
issueOperations.update(workspaceSlug, projectId, issueId, { target_date: val })
|
||||
}
|
||||
className="border-none bg-custom-background-80"
|
||||
minDate={minDate ?? undefined}
|
||||
disabled={!isAllowed || !is_editable}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -465,14 +396,17 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
<p>Cycle</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<SidebarCycleSelect
|
||||
issueDetail={issueDetail}
|
||||
handleCycleChange={handleCycleChange}
|
||||
disabled={!isAllowed || uneditable}
|
||||
<IssueCycleSelect
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
issueOperations={issueOperations}
|
||||
disabled={!isAllowed || !is_editable}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("module")) && projectDetails?.module_view && (
|
||||
<div className="flex flex-wrap items-center py-2">
|
||||
<div className="flex items-center gap-x-2 text-sm text-custom-text-200 sm:w-1/2">
|
||||
@ -480,10 +414,12 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
<p>Module</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<SidebarModuleSelect
|
||||
issueDetail={issueDetail}
|
||||
handleModuleChange={handleModuleChange}
|
||||
disabled={!isAllowed || uneditable}
|
||||
<IssueModuleSelect
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
issueOperations={issueOperations}
|
||||
disabled={!isAllowed || !is_editable}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -499,19 +435,24 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
<p>Label</p>
|
||||
</div>
|
||||
<div className="space-y-1 sm:w-1/2">
|
||||
<SidebarLabelSelect
|
||||
issueDetails={issueDetail}
|
||||
labelList={issueDetail?.label_ids ?? []}
|
||||
submitChanges={submitChanges}
|
||||
isNotAllowed={!isAllowed}
|
||||
uneditable={uneditable || !isAllowed}
|
||||
<IssueLabel
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
disabled={!isAllowed || !is_editable}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("link")) && (
|
||||
<IssueLinkRoot uneditable={uneditable} isAllowed={isAllowed} />
|
||||
<IssueLinkRoot
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
is_editable={is_editable}
|
||||
is_archived={is_archived}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
54
web/components/issues/issue-detail/subscription.tsx
Normal file
54
web/components/issues/issue-detail/subscription.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
import { FC, useState } from "react";
|
||||
import { Bell } from "lucide-react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// UI
|
||||
import { Button } from "@plane/ui";
|
||||
// hooks
|
||||
import { useIssueDetail } from "hooks/store";
|
||||
|
||||
export type TIssueSubscription = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
currentUserId: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const IssueSubscription: FC<TIssueSubscription> = observer((props) => {
|
||||
const { workspaceSlug, projectId, issueId, currentUserId, disabled } = props;
|
||||
// hooks
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
subscription: { getSubscriptionByIssueId },
|
||||
createSubscription,
|
||||
removeSubscription,
|
||||
} = useIssueDetail();
|
||||
// state
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const issue = getIssueById(issueId);
|
||||
const subscription = getSubscriptionByIssueId(issueId);
|
||||
|
||||
const handleSubscription = () => {
|
||||
setLoading(true);
|
||||
if (subscription?.subscribed) removeSubscription(workspaceSlug, projectId, issueId);
|
||||
else createSubscription(workspaceSlug, projectId, issueId);
|
||||
};
|
||||
|
||||
if (issue?.created_by === currentUserId || issue?.assignee_ids.includes(currentUserId)) return <></>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button
|
||||
size="sm"
|
||||
prependIcon={<Bell className="h-3 w-3" />}
|
||||
variant="outline-primary"
|
||||
className="hover:!bg-custom-primary-100/20"
|
||||
onClick={handleSubscription}
|
||||
disabled={disabled}
|
||||
>
|
||||
{loading ? "Loading..." : subscription?.subscribed ? "Unsubscribe" : "Subscribe"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
});
|
@ -4,12 +4,13 @@ import { observer } from "mobx-react-lite";
|
||||
import { Draggable } from "@hello-pangea/dnd";
|
||||
import { MoreHorizontal } from "lucide-react";
|
||||
// components
|
||||
import { Tooltip } from "@plane/ui";
|
||||
import { Tooltip, ControlLink } from "@plane/ui";
|
||||
// hooks
|
||||
import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
||||
// ui
|
||||
// types
|
||||
import { TIssue, TIssueMap } from "@plane/types";
|
||||
import { useProject, useProjectState } from "hooks/store";
|
||||
import { useApplication, useIssueDetail, useProject, useProjectState } from "hooks/store";
|
||||
|
||||
type Props = {
|
||||
issues: TIssueMap | undefined;
|
||||
@ -23,21 +24,23 @@ export const CalendarIssueBlocks: React.FC<Props> = observer((props) => {
|
||||
// router
|
||||
const router = useRouter();
|
||||
// hooks
|
||||
const {
|
||||
router: { workspaceSlug, projectId },
|
||||
} = useApplication();
|
||||
const { getProjectById } = useProject();
|
||||
const { getProjectStates } = useProjectState();
|
||||
const { setPeekIssue } = useIssueDetail();
|
||||
// states
|
||||
const [isMenuActive, setIsMenuActive] = useState(false);
|
||||
|
||||
const menuActionRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const handleIssuePeekOverview = (issue: TIssue) => {
|
||||
const { query } = router;
|
||||
|
||||
router.push({
|
||||
pathname: router.pathname,
|
||||
query: { ...query, peekIssueId: issue?.id, peekProjectId: issue?.project_id },
|
||||
});
|
||||
};
|
||||
const handleIssuePeekOverview = (issue: TIssue) =>
|
||||
workspaceSlug &&
|
||||
issue &&
|
||||
issue.project_id &&
|
||||
issue.id &&
|
||||
setPeekIssue({ workspaceSlug, projectId: issue.project_id, issueId: issue.id });
|
||||
|
||||
useOutsideClickDetector(menuActionRef, () => setIsMenuActive(false));
|
||||
|
||||
@ -67,45 +70,53 @@ export const CalendarIssueBlocks: React.FC<Props> = observer((props) => {
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
ref={provided.innerRef}
|
||||
onClick={() => handleIssuePeekOverview(issue)}
|
||||
>
|
||||
{issue?.tempId !== undefined && (
|
||||
<div className="absolute left-0 top-0 z-[99999] h-full w-full animate-pulse bg-custom-background-100/20" />
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`group/calendar-block flex h-8 w-full items-center justify-between gap-1.5 rounded border-[0.5px] border-custom-border-100 px-1 py-1.5 shadow-custom-shadow-2xs ${
|
||||
snapshot.isDragging
|
||||
? "bg-custom-background-90 shadow-custom-shadow-rg"
|
||||
: "bg-custom-background-100 hover:bg-custom-background-90"
|
||||
}`}
|
||||
<ControlLink
|
||||
href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`}
|
||||
target="_blank"
|
||||
onClick={() => handleIssuePeekOverview(issue)}
|
||||
className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100"
|
||||
>
|
||||
<div className="flex h-full items-center gap-1.5">
|
||||
<span
|
||||
className="h-full w-0.5 flex-shrink-0 rounded"
|
||||
style={{
|
||||
backgroundColor: getProjectStates(issue?.project_id).find(
|
||||
(state) => state?.id == issue?.state_id
|
||||
)?.color,
|
||||
}}
|
||||
/>
|
||||
<div className="flex-shrink-0 text-xs text-custom-text-300">
|
||||
{getProjectById(issue?.project_id)?.identifier}-{issue.sequence_id}
|
||||
<>
|
||||
{issue?.tempId !== undefined && (
|
||||
<div className="absolute left-0 top-0 z-[99999] h-full w-full animate-pulse bg-custom-background-100/20" />
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`group/calendar-block flex h-8 w-full items-center justify-between gap-1.5 rounded border-[0.5px] border-custom-border-100 px-1 py-1.5 shadow-custom-shadow-2xs ${
|
||||
snapshot.isDragging
|
||||
? "bg-custom-background-90 shadow-custom-shadow-rg"
|
||||
: "bg-custom-background-100 hover:bg-custom-background-90"
|
||||
}`}
|
||||
>
|
||||
<div className="flex h-full items-center gap-1.5">
|
||||
<span
|
||||
className="h-full w-0.5 flex-shrink-0 rounded"
|
||||
style={{
|
||||
backgroundColor: getProjectStates(issue?.project_id).find(
|
||||
(state) => state?.id == issue?.state_id
|
||||
)?.color,
|
||||
}}
|
||||
/>
|
||||
<div className="flex-shrink-0 text-xs text-custom-text-300">
|
||||
{getProjectById(issue?.project_id)?.identifier}-{issue.sequence_id}
|
||||
</div>
|
||||
<Tooltip tooltipHeading="Title" tooltipContent={issue.name}>
|
||||
<div className="truncate text-xs">{issue.name}</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div
|
||||
className={`hidden h-5 w-5 group-hover/calendar-block:block ${isMenuActive ? "!block" : ""}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{quickActions(issue, customActionButton)}
|
||||
</div>
|
||||
</div>
|
||||
<Tooltip tooltipHeading="Title" tooltipContent={issue.name}>
|
||||
<div className="truncate text-xs">{issue.name}</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div
|
||||
className={`hidden h-5 w-5 group-hover/calendar-block:block ${isMenuActive ? "!block" : ""}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{quickActions(issue, customActionButton)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</ControlLink>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
|
@ -1,25 +1,26 @@
|
||||
import { useRouter } from "next/router";
|
||||
// ui
|
||||
import { Tooltip, StateGroupIcon } from "@plane/ui";
|
||||
import { Tooltip, StateGroupIcon, ControlLink } from "@plane/ui";
|
||||
// helpers
|
||||
import { renderFormattedDate } from "helpers/date-time.helper";
|
||||
// types
|
||||
import { TIssue } from "@plane/types";
|
||||
import { useProject, useProjectState } from "hooks/store";
|
||||
import { useApplication, useIssueDetail, useProject, useProjectState } from "hooks/store";
|
||||
|
||||
export const IssueGanttBlock = ({ data }: { data: TIssue }) => {
|
||||
const router = useRouter();
|
||||
// hooks
|
||||
const {
|
||||
router: { workspaceSlug },
|
||||
} = useApplication();
|
||||
const { getProjectStates } = useProjectState();
|
||||
const { setPeekIssue } = useIssueDetail();
|
||||
|
||||
const handleIssuePeekOverview = () => {
|
||||
const { query } = router;
|
||||
|
||||
router.push({
|
||||
pathname: router.pathname,
|
||||
query: { ...query, peekIssueId: data?.id, peekProjectId: data?.project_id },
|
||||
});
|
||||
};
|
||||
const handleIssuePeekOverview = () =>
|
||||
workspaceSlug &&
|
||||
data &&
|
||||
data.project_id &&
|
||||
data.id &&
|
||||
setPeekIssue({ workspaceSlug, projectId: data.project_id, issueId: data.id });
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -49,34 +50,42 @@ export const IssueGanttBlock = ({ data }: { data: TIssue }) => {
|
||||
|
||||
// rendering issues on gantt sidebar
|
||||
export const IssueGanttSidebarBlock = ({ data }: { data: TIssue }) => {
|
||||
const router = useRouter();
|
||||
// hooks
|
||||
const { getProjectStates } = useProjectState();
|
||||
const { getProjectById } = useProject();
|
||||
const {
|
||||
router: { workspaceSlug },
|
||||
} = useApplication();
|
||||
const { setPeekIssue } = useIssueDetail();
|
||||
|
||||
const handleIssuePeekOverview = () => {
|
||||
const { query } = router;
|
||||
|
||||
router.push({
|
||||
pathname: router.pathname,
|
||||
query: { ...query, peekIssueId: data?.id, peekProjectId: data?.project_id },
|
||||
});
|
||||
};
|
||||
const handleIssuePeekOverview = () =>
|
||||
workspaceSlug &&
|
||||
data &&
|
||||
data.project_id &&
|
||||
data.id &&
|
||||
setPeekIssue({ workspaceSlug, projectId: data.project_id, issueId: data.id });
|
||||
|
||||
const currentStateDetails =
|
||||
getProjectStates(data?.project_id)?.find((state) => state?.id == data?.state_id) || undefined;
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-full cursor-pointer items-center gap-2" onClick={handleIssuePeekOverview}>
|
||||
{currentStateDetails != undefined && (
|
||||
<StateGroupIcon stateGroup={currentStateDetails?.group} color={currentStateDetails?.color} />
|
||||
)}
|
||||
<div className="flex-shrink-0 text-xs text-custom-text-300">
|
||||
{getProjectById(data?.project_id)?.identifier} {data?.sequence_id}
|
||||
<ControlLink
|
||||
href={`/${workspaceSlug}/projects/${data.project_id}/issues/${data.id}`}
|
||||
target="_blank"
|
||||
onClick={handleIssuePeekOverview}
|
||||
className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100"
|
||||
>
|
||||
<div className="relative flex h-full w-full cursor-pointer items-center gap-2" onClick={handleIssuePeekOverview}>
|
||||
{currentStateDetails != undefined && (
|
||||
<StateGroupIcon stateGroup={currentStateDetails?.group} color={currentStateDetails?.color} />
|
||||
)}
|
||||
<div className="flex-shrink-0 text-xs text-custom-text-300">
|
||||
{getProjectById(data?.project_id)?.identifier} {data?.sequence_id}
|
||||
</div>
|
||||
<Tooltip tooltipHeading="Title" tooltipContent={data.name}>
|
||||
<span className="flex-grow truncate text-sm font-medium">{data?.name}</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Tooltip tooltipHeading="Title" tooltipContent={data.name}>
|
||||
<span className="flex-grow truncate text-sm font-medium">{data?.name}</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</ControlLink>
|
||||
);
|
||||
};
|
||||
|
@ -1,10 +1,10 @@
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { useEffect, useState, useRef, FC } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
// hooks
|
||||
import { useProject, useWorkspace } from "hooks/store";
|
||||
import { useProject } from "hooks/store";
|
||||
import useToast from "hooks/use-toast";
|
||||
import useKeypress from "hooks/use-keypress";
|
||||
import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
||||
@ -12,9 +12,38 @@ import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
||||
import { renderFormattedPayloadDate } from "helpers/date-time.helper";
|
||||
import { createIssuePayload } from "helpers/issue.helper";
|
||||
// types
|
||||
import { TIssue } from "@plane/types";
|
||||
import { IProject, TIssue } from "@plane/types";
|
||||
|
||||
type Props = {
|
||||
interface IInputProps {
|
||||
formKey: string;
|
||||
register: any;
|
||||
setFocus: any;
|
||||
projectDetail: IProject | null;
|
||||
}
|
||||
const Inputs: FC<IInputProps> = (props) => {
|
||||
const { formKey, register, setFocus, projectDetail } = props;
|
||||
|
||||
useEffect(() => {
|
||||
setFocus(formKey);
|
||||
}, [formKey, setFocus]);
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center gap-3">
|
||||
<div className="text-xs font-medium text-custom-text-400">{projectDetail?.identifier ?? "..."}</div>
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
placeholder="Issue Title"
|
||||
{...register(formKey, {
|
||||
required: "Issue title is required.",
|
||||
})}
|
||||
className="w-full rounded-md bg-transparent px-2 py-3 text-sm font-medium leading-5 text-custom-text-200 outline-none"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type IGanttQuickAddIssueForm = {
|
||||
prePopulatedData?: Partial<TIssue>;
|
||||
onSuccess?: (data: TIssue) => Promise<void> | void;
|
||||
quickAddCallback?: (
|
||||
@ -30,34 +59,25 @@ const defaultValues: Partial<TIssue> = {
|
||||
name: "",
|
||||
};
|
||||
|
||||
const Inputs = (props: any) => {
|
||||
const { register, setFocus } = props;
|
||||
|
||||
useEffect(() => {
|
||||
setFocus("name");
|
||||
}, [setFocus]);
|
||||
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
placeholder="Issue Title"
|
||||
{...register("name", {
|
||||
required: "Issue title is required.",
|
||||
})}
|
||||
className="w-full rounded-md bg-transparent px-2 text-sm font-medium leading-5 text-custom-text-200 outline-none"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const GanttInlineCreateIssueForm: React.FC<Props> = observer((props) => {
|
||||
export const GanttQuickAddIssueForm: React.FC<IGanttQuickAddIssueForm> = observer((props) => {
|
||||
const { prePopulatedData, quickAddCallback, viewId } = props;
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
// store hooks
|
||||
const { getWorkspaceBySlug } = useWorkspace();
|
||||
const { currentProjectDetails } = useProject();
|
||||
// hooks
|
||||
const { getProjectById } = useProject();
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const projectDetail = (projectId && getProjectById(projectId.toString())) || undefined;
|
||||
|
||||
const ref = useRef<HTMLFormElement>(null);
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const handleClose = () => setIsOpen(false);
|
||||
|
||||
useKeypress("Escape", handleClose);
|
||||
useOutsideClickDetector(ref, handleClose);
|
||||
|
||||
// form info
|
||||
const {
|
||||
reset,
|
||||
@ -67,103 +87,67 @@ export const GanttInlineCreateIssueForm: React.FC<Props> = observer((props) => {
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<TIssue>({ defaultValues });
|
||||
|
||||
// ref
|
||||
const ref = useRef<HTMLFormElement>(null);
|
||||
|
||||
// states
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const handleClose = () => setIsOpen(false);
|
||||
|
||||
// hooks
|
||||
useKeypress("Escape", handleClose);
|
||||
useOutsideClickDetector(ref, handleClose);
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
// derived values
|
||||
const workspaceDetail = getWorkspaceBySlug(workspaceSlug?.toString()!);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) reset({ ...defaultValues });
|
||||
}, [isOpen, reset]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!errors) return;
|
||||
|
||||
Object.keys(errors).forEach((key) => {
|
||||
const error = errors[key as keyof TIssue];
|
||||
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: error?.message?.toString() || "Some error occurred. Please try again.",
|
||||
});
|
||||
});
|
||||
}, [errors, setToastAlert]);
|
||||
|
||||
const onSubmitHandler = async (formData: TIssue) => {
|
||||
if (isSubmitting || !workspaceSlug || !projectId) return;
|
||||
|
||||
reset({ ...defaultValues });
|
||||
|
||||
const targetDate = new Date();
|
||||
targetDate.setDate(targetDate.getDate() + 1);
|
||||
|
||||
const payload = createIssuePayload(projectId.toString(), {
|
||||
...(prePopulatedData ?? {}),
|
||||
...formData,
|
||||
start_date: renderFormattedPayloadDate(new Date()),
|
||||
target_date: renderFormattedPayloadDate(targetDate),
|
||||
});
|
||||
|
||||
try {
|
||||
if (quickAddCallback) {
|
||||
await quickAddCallback(workspaceSlug.toString(), projectId.toString(), payload, viewId);
|
||||
}
|
||||
quickAddCallback &&
|
||||
(await quickAddCallback(workspaceSlug.toString(), projectId.toString(), { ...payload }, viewId));
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: "Issue created successfully.",
|
||||
});
|
||||
} catch (err: any) {
|
||||
Object.keys(err || {}).forEach((key) => {
|
||||
const error = err?.[key];
|
||||
const errorTitle = error ? (Array.isArray(error) ? error.join(", ") : error) : null;
|
||||
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: errorTitle || "Some error occurred. Please try again.",
|
||||
});
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: err?.message || "Some error occurred. Please try again.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{isOpen && (
|
||||
<form
|
||||
ref={ref}
|
||||
className="mr-2.5 flex items-center gap-x-2 rounded border-[0.5px] border-custom-border-100 bg-custom-background-100 px-2 py-3 shadow-custom-shadow-2xs"
|
||||
onSubmit={handleSubmit(onSubmitHandler)}
|
||||
>
|
||||
<div className="h-3 w-3 flex-shrink-0 rounded-full border border-custom-border-1000" />
|
||||
<h4 className="text-xs text-custom-text-400">{currentProjectDetails?.identifier ?? "..."}</h4>
|
||||
<Inputs register={register} setFocus={setFocus} />
|
||||
</form>
|
||||
)}
|
||||
|
||||
{isOpen && (
|
||||
<p className="ml-3 mt-3 text-xs italic text-custom-text-200">
|
||||
Press {"'"}Enter{"'"} to add another issue
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!isOpen && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-x-[6px] rounded-md px-2 py-1 text-custom-primary-100"
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<PlusIcon className="h-3.5 w-3.5 stroke-2" />
|
||||
<span className="text-sm font-medium text-custom-primary-100">New Issue</span>
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
className={`${errors && errors?.name && errors?.name?.message ? `border border-red-500/20 bg-red-500/10` : ``}`}
|
||||
>
|
||||
{isOpen ? (
|
||||
<div className="shadow-custom-shadow-sm">
|
||||
<form
|
||||
ref={ref}
|
||||
onSubmit={handleSubmit(onSubmitHandler)}
|
||||
className="flex w-full items-center gap-x-3 border-[0.5px] border-custom-border-100 bg-custom-background-100 px-3"
|
||||
>
|
||||
<Inputs formKey={"name"} register={register} setFocus={setFocus} projectDetail={projectDetail ?? null} />
|
||||
</form>
|
||||
<div className="px-3 py-2 text-xs italic text-custom-text-200">{`Press 'Enter' to add another issue`}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="flex w-full cursor-pointer items-center gap-2 p-3 py-3 text-custom-primary-100"
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<PlusIcon className="h-3.5 w-3.5 stroke-2" />
|
||||
<span className="text-sm font-medium text-custom-primary-100">New Issue</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
@ -15,17 +15,16 @@ import { IProjectIssues, IProjectIssuesFilter } from "store/issue/project";
|
||||
//components
|
||||
import { KanBan } from "./default";
|
||||
import { KanBanSwimLanes } from "./swimlanes";
|
||||
import { DeleteIssueModal, IssuePeekOverview } from "components/issues";
|
||||
import { DeleteIssueModal } from "components/issues";
|
||||
import { EUserProjectRoles } from "constants/project";
|
||||
import { useIssues } from "hooks/store/use-issues";
|
||||
import { handleDragDrop } from "./utils";
|
||||
import { IssueKanBanViewStore } from "store/issue/issue_kanban_view.store";
|
||||
import { ICycleIssues, ICycleIssuesFilter } from "store/issue/cycle";
|
||||
import { IDraftIssues, IDraftIssuesFilter } from "store/issue/draft";
|
||||
import { IProfileIssues, IProfileIssuesFilter } from "store/issue/profile";
|
||||
import { IModuleIssues, IModuleIssuesFilter } from "store/issue/module";
|
||||
import { IProjectViewIssues, IProjectViewIssuesFilter } from "store/issue/project-views";
|
||||
import { TCreateModalStoreTypes } from "constants/issue";
|
||||
import { EIssueFilterType, TCreateModalStoreTypes } from "constants/issue";
|
||||
|
||||
export interface IBaseKanBanLayout {
|
||||
issues: IProjectIssues | ICycleIssues | IDraftIssues | IModuleIssues | IProjectViewIssues | IProfileIssues;
|
||||
@ -69,7 +68,7 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
|
||||
} = props;
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, peekIssueId, peekProjectId } = router.query;
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
// store hooks
|
||||
const {
|
||||
membership: { currentProjectRole },
|
||||
@ -78,9 +77,6 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
|
||||
// toast alert
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
// FIXME get from filters
|
||||
const kanbanViewStore: IssueKanBanViewStore = {} as IssueKanBanViewStore;
|
||||
|
||||
const issueIds = issues?.groupedIssueIds || [];
|
||||
|
||||
const displayFilters = issuesFilter?.issueFilters?.displayFilters;
|
||||
@ -211,10 +207,19 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
|
||||
});
|
||||
};
|
||||
|
||||
const handleKanBanToggle = (toggle: "groupByHeaderMinMax" | "subgroupByIssuesVisibility", value: string) => {
|
||||
kanbanViewStore.handleKanBanToggle(toggle, value);
|
||||
const handleKanbanFilters = (toggle: "group_by" | "sub_group_by", value: string) => {
|
||||
if (workspaceSlug && projectId) {
|
||||
let _kanbanFilters = issuesFilter?.issueFilters?.kanbanFilters?.[toggle] || [];
|
||||
if (_kanbanFilters.includes(value)) _kanbanFilters = _kanbanFilters.filter((_value) => _value != value);
|
||||
else _kanbanFilters.push(value);
|
||||
issuesFilter.updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.KANBAN_FILTERS, {
|
||||
[toggle]: _kanbanFilters,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const kanbanFilters = issuesFilter?.issueFilters?.kanbanFilters || { group_by: [], sub_group_by: [] };
|
||||
|
||||
return (
|
||||
<>
|
||||
<DeleteIssueModal
|
||||
@ -230,8 +235,9 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`relative h-max min-h-full w-max min-w-full bg-custom-background-90 px-3`}>
|
||||
<div className="relative h-full w-max min-w-full bg-custom-background-90 px-2">
|
||||
<DragDropContext onDragStart={onDragStart} onDragEnd={onDragEnd}>
|
||||
{/* drag and delete component */}
|
||||
<div
|
||||
className={`fixed left-1/2 -translate-x-1/2 ${
|
||||
isDragStarted ? "z-40" : ""
|
||||
@ -262,8 +268,8 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
|
||||
group_by={group_by}
|
||||
handleIssues={handleIssues}
|
||||
quickActions={renderQuickActions}
|
||||
kanBanToggle={kanbanViewStore?.kanBanToggle}
|
||||
handleKanBanToggle={handleKanBanToggle}
|
||||
handleKanbanFilters={handleKanbanFilters}
|
||||
kanbanFilters={kanbanFilters}
|
||||
enableQuickIssueCreate={enableQuickAdd}
|
||||
showEmptyGroup={userDisplayFilters?.show_empty_groups || true}
|
||||
quickAddCallback={issues?.quickAddIssue}
|
||||
@ -275,15 +281,6 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
|
||||
/>
|
||||
</DragDropContext>
|
||||
</div>
|
||||
|
||||
{/* {workspaceSlug && peekIssueId && peekProjectId && (
|
||||
<IssuePeekOverview
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={peekProjectId.toString()}
|
||||
issueId={peekIssueId.toString()}
|
||||
handleIssue={async (issueToUpdate) => await handleIssues(issueToUpdate as TIssue, EIssueActions.UPDATE)}
|
||||
/>
|
||||
)} */}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
@ -5,12 +5,11 @@ import { observer } from "mobx-react-lite";
|
||||
import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC";
|
||||
import { IssueProperties } from "../properties/all-properties";
|
||||
// ui
|
||||
import { Tooltip } from "@plane/ui";
|
||||
import { Tooltip, ControlLink } from "@plane/ui";
|
||||
// types
|
||||
import { TIssue, IIssueDisplayProperties, IIssueMap } from "@plane/types";
|
||||
import { EIssueActions } from "../types";
|
||||
import { useRouter } from "next/router";
|
||||
import { useProject } from "hooks/store";
|
||||
import { useApplication, useIssueDetail, useProject } from "hooks/store";
|
||||
|
||||
interface IssueBlockProps {
|
||||
issueId: string;
|
||||
@ -34,24 +33,23 @@ interface IssueDetailsBlockProps {
|
||||
|
||||
const KanbanIssueDetailsBlock: React.FC<IssueDetailsBlockProps> = observer((props: IssueDetailsBlockProps) => {
|
||||
const { issue, handleIssues, quickActions, isReadOnly, displayProperties } = props;
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
// hooks
|
||||
const { getProjectById } = useProject();
|
||||
const {
|
||||
router: { workspaceSlug, projectId },
|
||||
} = useApplication();
|
||||
const { setPeekIssue } = useIssueDetail();
|
||||
|
||||
const updateIssue = (issueToUpdate: TIssue) => {
|
||||
if (issueToUpdate) handleIssues(issueToUpdate, EIssueActions.UPDATE);
|
||||
};
|
||||
|
||||
const handleIssuePeekOverview = () => {
|
||||
const { query } = router;
|
||||
|
||||
router.push({
|
||||
pathname: router.pathname,
|
||||
query: { ...query, peekIssueId: issue?.id, peekProjectId: issue?.project_id },
|
||||
});
|
||||
};
|
||||
const handleIssuePeekOverview = (issue: TIssue) =>
|
||||
workspaceSlug &&
|
||||
issue &&
|
||||
issue.project_id &&
|
||||
issue.id &&
|
||||
setPeekIssue({ workspaceSlug, projectId: issue.project_id, issueId: issue.id });
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -63,11 +61,18 @@ const KanbanIssueDetailsBlock: React.FC<IssueDetailsBlockProps> = observer((prop
|
||||
<div className="absolute -top-1 right-0 hidden group-hover/kanban-block:block">{quickActions(issue)}</div>
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
<Tooltip tooltipHeading="Title" tooltipContent={issue.name}>
|
||||
<div className="line-clamp-2 text-sm font-medium text-custom-text-100" onClick={handleIssuePeekOverview}>
|
||||
{issue.name}
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
<ControlLink
|
||||
href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`}
|
||||
target="_blank"
|
||||
onClick={() => handleIssuePeekOverview(issue)}
|
||||
className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100"
|
||||
>
|
||||
<Tooltip tooltipHeading="Title" tooltipContent={issue.name}>
|
||||
<span>{issue.name}</span>
|
||||
</Tooltip>
|
||||
</ControlLink>
|
||||
|
||||
<IssueProperties
|
||||
className="flex flex-wrap items-center gap-2 whitespace-nowrap"
|
||||
issue={issue}
|
||||
|
@ -14,6 +14,7 @@ import {
|
||||
IIssueMap,
|
||||
TSubGroupedIssues,
|
||||
TUnGroupedIssues,
|
||||
TIssueKanbanFilters,
|
||||
} from "@plane/types";
|
||||
// constants
|
||||
import { EIssueActions } from "../types";
|
||||
@ -30,8 +31,8 @@ export interface IGroupByKanBan {
|
||||
isDragDisabled: boolean;
|
||||
handleIssues: (issue: TIssue, action: EIssueActions) => void;
|
||||
quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode;
|
||||
kanBanToggle: any;
|
||||
handleKanBanToggle: any;
|
||||
kanbanFilters: TIssueKanbanFilters;
|
||||
handleKanbanFilters: any;
|
||||
enableQuickIssueCreate?: boolean;
|
||||
quickAddCallback?: (
|
||||
workspaceSlug: string,
|
||||
@ -57,8 +58,8 @@ const GroupByKanBan: React.FC<IGroupByKanBan> = observer((props) => {
|
||||
isDragDisabled,
|
||||
handleIssues,
|
||||
quickActions,
|
||||
kanBanToggle,
|
||||
handleKanBanToggle,
|
||||
kanbanFilters,
|
||||
handleKanbanFilters,
|
||||
enableQuickIssueCreate,
|
||||
quickAddCallback,
|
||||
viewId,
|
||||
@ -77,58 +78,63 @@ const GroupByKanBan: React.FC<IGroupByKanBan> = observer((props) => {
|
||||
|
||||
if (!list) return null;
|
||||
|
||||
const verticalAlignPosition = (_list: IGroupByColumn) => kanBanToggle?.groupByHeaderMinMax.includes(_list.id);
|
||||
const visibilityGroupBy = (_list: IGroupByColumn) =>
|
||||
sub_group_by ? false : kanbanFilters?.group_by.includes(_list.id) ? true : false;
|
||||
|
||||
const isGroupByCreatedBy = group_by === "created_by";
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-full gap-3">
|
||||
<div className={`relative w-full flex gap-3 overflow-hidden ${sub_group_by ? "h-full" : "h-full"}`}>
|
||||
{list &&
|
||||
list.length > 0 &&
|
||||
list.map((_list: IGroupByColumn) => {
|
||||
const verticalPosition = verticalAlignPosition(_list);
|
||||
const groupByVisibilityToggle = visibilityGroupBy(_list);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative flex flex-shrink-0 flex-col ${!verticalPosition ? `w-[340px]` : ``} group`}
|
||||
key={_list.id}
|
||||
className={`relative flex flex-shrink-0 flex-col h-full group ${
|
||||
groupByVisibilityToggle ? `` : `w-[340px]`
|
||||
}`}
|
||||
>
|
||||
{sub_group_by === null && (
|
||||
<div className="sticky top-0 z-[2] w-full flex-shrink-0 bg-custom-background-90 py-1">
|
||||
<div className="flex-shrink-0 sticky top-0 z-[2] w-full bg-custom-background-90 py-1">
|
||||
<HeaderGroupByCard
|
||||
sub_group_by={sub_group_by}
|
||||
group_by={group_by}
|
||||
column_id={_list.id}
|
||||
icon={_list.Icon}
|
||||
icon={_list.icon}
|
||||
title={_list.name}
|
||||
count={(issueIds as TGroupedIssues)?.[_list.id]?.length || 0}
|
||||
kanBanToggle={kanBanToggle}
|
||||
handleKanBanToggle={handleKanBanToggle}
|
||||
issuePayload={_list.payload}
|
||||
disableIssueCreation={disableIssueCreation || isGroupByCreatedBy}
|
||||
currentStore={currentStore}
|
||||
addIssuesToView={addIssuesToView}
|
||||
kanbanFilters={kanbanFilters}
|
||||
handleKanbanFilters={handleKanbanFilters}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<KanbanGroup
|
||||
groupId={_list.id}
|
||||
issuesMap={issuesMap}
|
||||
issueIds={issueIds}
|
||||
displayProperties={displayProperties}
|
||||
sub_group_by={sub_group_by}
|
||||
group_by={group_by}
|
||||
sub_group_id={sub_group_id}
|
||||
isDragDisabled={isDragDisabled}
|
||||
handleIssues={handleIssues}
|
||||
quickActions={quickActions}
|
||||
enableQuickIssueCreate={enableQuickIssueCreate}
|
||||
quickAddCallback={quickAddCallback}
|
||||
viewId={viewId}
|
||||
disableIssueCreation={disableIssueCreation}
|
||||
canEditProperties={canEditProperties}
|
||||
verticalPosition={verticalPosition}
|
||||
/>
|
||||
|
||||
{!groupByVisibilityToggle && (
|
||||
<KanbanGroup
|
||||
groupId={_list.id}
|
||||
issuesMap={issuesMap}
|
||||
issueIds={issueIds}
|
||||
displayProperties={displayProperties}
|
||||
sub_group_by={sub_group_by}
|
||||
group_by={group_by}
|
||||
sub_group_id={sub_group_id}
|
||||
isDragDisabled={isDragDisabled}
|
||||
handleIssues={handleIssues}
|
||||
quickActions={quickActions}
|
||||
enableQuickIssueCreate={enableQuickIssueCreate}
|
||||
quickAddCallback={quickAddCallback}
|
||||
viewId={viewId}
|
||||
disableIssueCreation={disableIssueCreation}
|
||||
canEditProperties={canEditProperties}
|
||||
groupByVisibilityToggle={groupByVisibilityToggle}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@ -145,8 +151,8 @@ export interface IKanBan {
|
||||
sub_group_id?: string;
|
||||
handleIssues: (issue: TIssue, action: EIssueActions) => void;
|
||||
quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode;
|
||||
kanBanToggle: any;
|
||||
handleKanBanToggle: any;
|
||||
kanbanFilters: TIssueKanbanFilters;
|
||||
handleKanbanFilters: (toggle: "group_by" | "sub_group_by", value: string) => void;
|
||||
showEmptyGroup: boolean;
|
||||
enableQuickIssueCreate?: boolean;
|
||||
quickAddCallback?: (
|
||||
@ -172,8 +178,8 @@ export const KanBan: React.FC<IKanBan> = observer((props) => {
|
||||
sub_group_id = "null",
|
||||
handleIssues,
|
||||
quickActions,
|
||||
kanBanToggle,
|
||||
handleKanBanToggle,
|
||||
kanbanFilters,
|
||||
handleKanbanFilters,
|
||||
enableQuickIssueCreate,
|
||||
quickAddCallback,
|
||||
viewId,
|
||||
@ -186,27 +192,25 @@ export const KanBan: React.FC<IKanBan> = observer((props) => {
|
||||
const issueKanBanView = useKanbanView();
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
<GroupByKanBan
|
||||
issuesMap={issuesMap}
|
||||
issueIds={issueIds}
|
||||
displayProperties={displayProperties}
|
||||
group_by={group_by}
|
||||
sub_group_by={sub_group_by}
|
||||
sub_group_id={sub_group_id}
|
||||
isDragDisabled={!issueKanBanView?.canUserDragDrop}
|
||||
handleIssues={handleIssues}
|
||||
quickActions={quickActions}
|
||||
kanBanToggle={kanBanToggle}
|
||||
handleKanBanToggle={handleKanBanToggle}
|
||||
enableQuickIssueCreate={enableQuickIssueCreate}
|
||||
quickAddCallback={quickAddCallback}
|
||||
viewId={viewId}
|
||||
disableIssueCreation={disableIssueCreation}
|
||||
currentStore={currentStore}
|
||||
addIssuesToView={addIssuesToView}
|
||||
canEditProperties={canEditProperties}
|
||||
/>
|
||||
</div>
|
||||
<GroupByKanBan
|
||||
issuesMap={issuesMap}
|
||||
issueIds={issueIds}
|
||||
displayProperties={displayProperties}
|
||||
group_by={group_by}
|
||||
sub_group_by={sub_group_by}
|
||||
sub_group_id={sub_group_id}
|
||||
isDragDisabled={!issueKanBanView?.canUserDragDrop}
|
||||
handleIssues={handleIssues}
|
||||
quickActions={quickActions}
|
||||
kanbanFilters={kanbanFilters}
|
||||
handleKanbanFilters={handleKanbanFilters}
|
||||
enableQuickIssueCreate={enableQuickIssueCreate}
|
||||
quickAddCallback={quickAddCallback}
|
||||
viewId={viewId}
|
||||
disableIssueCreation={disableIssueCreation}
|
||||
currentStore={currentStore}
|
||||
addIssuesToView={addIssuesToView}
|
||||
canEditProperties={canEditProperties}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
@ -11,7 +11,7 @@ import useToast from "hooks/use-toast";
|
||||
// mobx
|
||||
import { observer } from "mobx-react-lite";
|
||||
// types
|
||||
import { TIssue, ISearchIssueResponse } from "@plane/types";
|
||||
import { TIssue, ISearchIssueResponse, TIssueKanbanFilters } from "@plane/types";
|
||||
import { TCreateModalStoreTypes } from "constants/issue";
|
||||
|
||||
interface IHeaderGroupByCard {
|
||||
@ -21,8 +21,8 @@ interface IHeaderGroupByCard {
|
||||
icon?: React.ReactNode;
|
||||
title: string;
|
||||
count: number;
|
||||
kanBanToggle: any;
|
||||
handleKanBanToggle: any;
|
||||
kanbanFilters: TIssueKanbanFilters;
|
||||
handleKanbanFilters: any;
|
||||
issuePayload: Partial<TIssue>;
|
||||
disableIssueCreation?: boolean;
|
||||
currentStore?: TCreateModalStoreTypes;
|
||||
@ -36,14 +36,14 @@ export const HeaderGroupByCard: FC<IHeaderGroupByCard> = observer((props) => {
|
||||
icon,
|
||||
title,
|
||||
count,
|
||||
kanBanToggle,
|
||||
handleKanBanToggle,
|
||||
kanbanFilters,
|
||||
handleKanbanFilters,
|
||||
issuePayload,
|
||||
disableIssueCreation,
|
||||
currentStore,
|
||||
addIssuesToView,
|
||||
} = props;
|
||||
const verticalAlignPosition = kanBanToggle?.groupByHeaderMinMax.includes(column_id);
|
||||
const verticalAlignPosition = sub_group_by ? false : kanbanFilters?.group_by.includes(column_id);
|
||||
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const [openExistingIssueListModal, setOpenExistingIssueListModal] = React.useState(false);
|
||||
@ -117,7 +117,7 @@ export const HeaderGroupByCard: FC<IHeaderGroupByCard> = observer((props) => {
|
||||
{sub_group_by === null && (
|
||||
<div
|
||||
className="flex h-[20px] w-[20px] flex-shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-sm transition-all hover:bg-custom-background-80"
|
||||
onClick={() => handleKanBanToggle("groupByHeaderMinMax", column_id)}
|
||||
onClick={() => handleKanbanFilters("group_by", column_id)}
|
||||
>
|
||||
{verticalAlignPosition ? (
|
||||
<Maximize2 width={14} strokeWidth={2} />
|
||||
|
@ -1,26 +1,26 @@
|
||||
import React from "react";
|
||||
// lucide icons
|
||||
import { Circle, ChevronDown, ChevronUp } from "lucide-react";
|
||||
// mobx
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { TIssueKanbanFilters } from "@plane/types";
|
||||
|
||||
interface IHeaderSubGroupByCard {
|
||||
icon?: React.ReactNode;
|
||||
title: string;
|
||||
count: number;
|
||||
column_id: string;
|
||||
kanBanToggle: any;
|
||||
handleKanBanToggle: any;
|
||||
kanbanFilters: TIssueKanbanFilters;
|
||||
handleKanbanFilters: (toggle: "group_by" | "sub_group_by", value: string) => void;
|
||||
}
|
||||
|
||||
export const HeaderSubGroupByCard = observer(
|
||||
({ icon, title, count, column_id, kanBanToggle, handleKanBanToggle }: IHeaderSubGroupByCard) => (
|
||||
({ icon, title, count, column_id, kanbanFilters, handleKanbanFilters }: IHeaderSubGroupByCard) => (
|
||||
<div className={`relative flex w-full flex-shrink-0 flex-row items-center gap-2 rounded-sm p-1.5`}>
|
||||
<div
|
||||
className="flex h-[20px] w-[20px] flex-shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-sm transition-all hover:bg-custom-background-80"
|
||||
onClick={() => handleKanBanToggle("subgroupByIssuesVisibility", column_id)}
|
||||
onClick={() => handleKanbanFilters("sub_group_by", column_id)}
|
||||
>
|
||||
{kanBanToggle?.subgroupByIssuesVisibility.includes(column_id) ? (
|
||||
{kanbanFilters?.sub_group_by.includes(column_id) ? (
|
||||
<ChevronDown width={14} strokeWidth={2} />
|
||||
) : (
|
||||
<ChevronUp width={14} strokeWidth={2} />
|
||||
|
@ -1,4 +1,8 @@
|
||||
import { Droppable } from "@hello-pangea/dnd";
|
||||
// hooks
|
||||
import { useProjectState } from "hooks/store";
|
||||
//components
|
||||
import { KanbanIssueBlocksList, KanBanQuickAddIssueForm } from ".";
|
||||
//types
|
||||
import {
|
||||
TGroupedIssues,
|
||||
@ -9,10 +13,6 @@ import {
|
||||
TUnGroupedIssues,
|
||||
} from "@plane/types";
|
||||
import { EIssueActions } from "../types";
|
||||
// hooks
|
||||
import { useProjectState } from "hooks/store";
|
||||
//components
|
||||
import { KanBanQuickAddIssueForm, KanbanIssueBlocksList } from ".";
|
||||
|
||||
interface IKanbanGroup {
|
||||
groupId: string;
|
||||
@ -35,7 +35,7 @@ interface IKanbanGroup {
|
||||
viewId?: string;
|
||||
disableIssueCreation?: boolean;
|
||||
canEditProperties: (projectId: string | undefined) => boolean;
|
||||
verticalPosition: any;
|
||||
groupByVisibilityToggle: boolean;
|
||||
}
|
||||
|
||||
export const KanbanGroup = (props: IKanbanGroup) => {
|
||||
@ -46,7 +46,6 @@ export const KanbanGroup = (props: IKanbanGroup) => {
|
||||
sub_group_by,
|
||||
issuesMap,
|
||||
displayProperties,
|
||||
verticalPosition,
|
||||
issueIds,
|
||||
isDragDisabled,
|
||||
handleIssues,
|
||||
@ -56,80 +55,97 @@ export const KanbanGroup = (props: IKanbanGroup) => {
|
||||
disableIssueCreation,
|
||||
quickAddCallback,
|
||||
viewId,
|
||||
groupByVisibilityToggle,
|
||||
} = props;
|
||||
|
||||
// hooks
|
||||
const projectState = useProjectState();
|
||||
|
||||
const prePopulateQuickAddData = (groupByKey: string | null, value: string) => {
|
||||
const prePopulateQuickAddData = (
|
||||
groupByKey: string | null,
|
||||
subGroupByKey: string | null,
|
||||
groupValue: string,
|
||||
subGroupValue: string
|
||||
) => {
|
||||
const defaultState = projectState.projectStates?.find((state) => state.default);
|
||||
let preloadedData: object = { state_id: defaultState?.id };
|
||||
|
||||
if (groupByKey) {
|
||||
if (groupByKey === "state") {
|
||||
preloadedData = { ...preloadedData, state_id: value };
|
||||
preloadedData = { ...preloadedData, state_id: groupValue };
|
||||
} else if (groupByKey === "priority") {
|
||||
preloadedData = { ...preloadedData, priority: value };
|
||||
} else if (groupByKey === "labels" && value != "None") {
|
||||
preloadedData = { ...preloadedData, label_ids: [value] };
|
||||
} else if (groupByKey === "assignees" && value != "None") {
|
||||
preloadedData = { ...preloadedData, assignee_ids: [value] };
|
||||
preloadedData = { ...preloadedData, priority: groupValue };
|
||||
} else if (groupByKey === "labels" && groupValue != "None") {
|
||||
preloadedData = { ...preloadedData, label_ids: [groupValue] };
|
||||
} else if (groupByKey === "assignees" && groupValue != "None") {
|
||||
preloadedData = { ...preloadedData, assignee_ids: [groupValue] };
|
||||
} else if (groupByKey === "created_by") {
|
||||
preloadedData = { ...preloadedData };
|
||||
} else {
|
||||
preloadedData = { ...preloadedData, [groupByKey]: value };
|
||||
preloadedData = { ...preloadedData, [groupByKey]: groupValue };
|
||||
}
|
||||
}
|
||||
|
||||
if (subGroupByKey) {
|
||||
if (subGroupByKey === "state") {
|
||||
preloadedData = { ...preloadedData, state_id: subGroupValue };
|
||||
} else if (subGroupByKey === "priority") {
|
||||
preloadedData = { ...preloadedData, priority: subGroupValue };
|
||||
} else if (subGroupByKey === "labels" && subGroupValue != "None") {
|
||||
preloadedData = { ...preloadedData, label_ids: [subGroupValue] };
|
||||
} else if (subGroupByKey === "assignees" && subGroupValue != "None") {
|
||||
preloadedData = { ...preloadedData, assignee_ids: [subGroupValue] };
|
||||
} else if (subGroupByKey === "created_by") {
|
||||
preloadedData = { ...preloadedData };
|
||||
} else {
|
||||
preloadedData = { ...preloadedData, [subGroupByKey]: subGroupValue };
|
||||
}
|
||||
}
|
||||
|
||||
return preloadedData;
|
||||
};
|
||||
|
||||
const isGroupByCreatedBy = group_by === "created_by";
|
||||
|
||||
return (
|
||||
<div className={`${verticalPosition ? `min-h-[150px] w-[0px] overflow-hidden` : `w-full transition-all`}`}>
|
||||
<div className={`relative w-full h-full overflow-hidden transition-all`}>
|
||||
<Droppable droppableId={`${groupId}__${sub_group_id}`}>
|
||||
{(provided: any, snapshot: any) => (
|
||||
<div
|
||||
className={`relative h-full w-full transition-all ${
|
||||
snapshot.isDraggingOver ? `bg-custom-background-80` : ``
|
||||
}`}
|
||||
sub_group_by ? `` : `overflow-hidden overflow-y-auto`
|
||||
} ${snapshot.isDraggingOver ? `bg-custom-background-80` : ``}`}
|
||||
{...provided.droppableProps}
|
||||
ref={provided.innerRef}
|
||||
>
|
||||
{!verticalPosition ? (
|
||||
<KanbanIssueBlocksList
|
||||
sub_group_id={sub_group_id}
|
||||
columnId={groupId}
|
||||
issuesMap={issuesMap}
|
||||
issueIds={(issueIds as TGroupedIssues)?.[groupId] || []}
|
||||
displayProperties={displayProperties}
|
||||
isDragDisabled={isDragDisabled}
|
||||
handleIssues={handleIssues}
|
||||
quickActions={quickActions}
|
||||
canEditProperties={canEditProperties}
|
||||
/>
|
||||
) : null}
|
||||
<KanbanIssueBlocksList
|
||||
sub_group_id={sub_group_id}
|
||||
columnId={groupId}
|
||||
issuesMap={issuesMap}
|
||||
issueIds={(issueIds as TGroupedIssues)?.[groupId] || []}
|
||||
displayProperties={displayProperties}
|
||||
isDragDisabled={isDragDisabled}
|
||||
handleIssues={handleIssues}
|
||||
quickActions={quickActions}
|
||||
canEditProperties={canEditProperties}
|
||||
/>
|
||||
|
||||
{provided.placeholder}
|
||||
|
||||
{enableQuickIssueCreate && !disableIssueCreation && (
|
||||
<div className="w-full bg-custom-background-90 py-0.5 sticky bottom-0">
|
||||
<KanBanQuickAddIssueForm
|
||||
formKey="name"
|
||||
groupId={groupId}
|
||||
subGroupId={sub_group_id}
|
||||
prePopulatedData={{
|
||||
...(group_by && prePopulateQuickAddData(group_by, sub_group_by, groupId, sub_group_id)),
|
||||
}}
|
||||
quickAddCallback={quickAddCallback}
|
||||
viewId={viewId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
|
||||
<div className="sticky bottom-0 z-[0] w-full flex-shrink-0 bg-custom-background-90 py-1">
|
||||
{enableQuickIssueCreate && !disableIssueCreation && !isGroupByCreatedBy && (
|
||||
<KanBanQuickAddIssueForm
|
||||
formKey="name"
|
||||
groupId={groupId}
|
||||
subGroupId={sub_group_id}
|
||||
prePopulatedData={{
|
||||
...(group_by && prePopulateQuickAddData(group_by, groupId)),
|
||||
...(sub_group_by && sub_group_id !== "null" && { [sub_group_by]: sub_group_id }),
|
||||
}}
|
||||
quickAddCallback={quickAddCallback}
|
||||
viewId={viewId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -120,13 +120,13 @@ export const KanBanQuickAddIssueForm: React.FC<IKanBanQuickAddIssueForm> = obser
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<>
|
||||
{isOpen ? (
|
||||
<div className="shadow-custom-shadow-sm">
|
||||
<div className="shadow-custom-shadow-sm m-1.5 rounded overflow-hidden">
|
||||
<form
|
||||
ref={ref}
|
||||
onSubmit={handleSubmit(onSubmitHandler)}
|
||||
className="flex w-full items-center gap-x-3 border-[0.5px] border-t-0 border-custom-border-100 bg-custom-background-100 px-3"
|
||||
className="flex w-full items-center gap-x-3 bg-custom-background-100 p-3"
|
||||
>
|
||||
<Inputs formKey={formKey} register={register} setFocus={setFocus} projectDetail={projectDetail} />
|
||||
</form>
|
||||
@ -141,6 +141,6 @@ export const KanBanQuickAddIssueForm: React.FC<IKanBanQuickAddIssueForm> = obser
|
||||
<span className="text-sm font-medium text-custom-primary-100">New Issue</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
@ -13,6 +13,7 @@ import {
|
||||
IIssueMap,
|
||||
TSubGroupedIssues,
|
||||
TUnGroupedIssues,
|
||||
TIssueKanbanFilters,
|
||||
} from "@plane/types";
|
||||
// constants
|
||||
import { EIssueActions } from "../types";
|
||||
@ -25,16 +26,16 @@ interface ISubGroupSwimlaneHeader {
|
||||
sub_group_by: string | null;
|
||||
group_by: string | null;
|
||||
list: IGroupByColumn[];
|
||||
kanBanToggle: any;
|
||||
handleKanBanToggle: any;
|
||||
kanbanFilters: TIssueKanbanFilters;
|
||||
handleKanbanFilters: (toggle: "group_by" | "sub_group_by", value: string) => void;
|
||||
}
|
||||
const SubGroupSwimlaneHeader: React.FC<ISubGroupSwimlaneHeader> = ({
|
||||
issueIds,
|
||||
sub_group_by,
|
||||
group_by,
|
||||
list,
|
||||
kanBanToggle,
|
||||
handleKanBanToggle,
|
||||
kanbanFilters,
|
||||
handleKanbanFilters,
|
||||
}) => (
|
||||
<div className="relative flex h-max min-h-full w-full items-center">
|
||||
{list &&
|
||||
@ -45,11 +46,11 @@ const SubGroupSwimlaneHeader: React.FC<ISubGroupSwimlaneHeader> = ({
|
||||
sub_group_by={sub_group_by}
|
||||
group_by={group_by}
|
||||
column_id={_list.id}
|
||||
icon={_list.Icon}
|
||||
icon={_list.icon}
|
||||
title={_list.name}
|
||||
count={(issueIds as TGroupedIssues)?.[_list.id]?.length || 0}
|
||||
kanBanToggle={kanBanToggle}
|
||||
handleKanBanToggle={handleKanBanToggle}
|
||||
kanbanFilters={kanbanFilters}
|
||||
handleKanbanFilters={handleKanbanFilters}
|
||||
issuePayload={_list.payload}
|
||||
/>
|
||||
</div>
|
||||
@ -64,8 +65,8 @@ interface ISubGroupSwimlane extends ISubGroupSwimlaneHeader {
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
handleIssues: (issue: TIssue, action: EIssueActions) => void;
|
||||
quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode;
|
||||
kanBanToggle: any;
|
||||
handleKanBanToggle: any;
|
||||
kanbanFilters: TIssueKanbanFilters;
|
||||
handleKanbanFilters: (toggle: "group_by" | "sub_group_by", value: string) => void;
|
||||
isDragStarted?: boolean;
|
||||
disableIssueCreation?: boolean;
|
||||
currentStore?: TCreateModalStoreTypes;
|
||||
@ -90,8 +91,8 @@ const SubGroupSwimlane: React.FC<ISubGroupSwimlane> = observer((props) => {
|
||||
handleIssues,
|
||||
quickActions,
|
||||
displayProperties,
|
||||
kanBanToggle,
|
||||
handleKanBanToggle,
|
||||
kanbanFilters,
|
||||
handleKanbanFilters,
|
||||
showEmptyGroup,
|
||||
enableQuickIssueCreate,
|
||||
canEditProperties,
|
||||
@ -123,13 +124,14 @@ const SubGroupSwimlane: React.FC<ISubGroupSwimlane> = observer((props) => {
|
||||
icon={_list.Icon}
|
||||
title={_list.name || ""}
|
||||
count={calculateIssueCount(_list.id)}
|
||||
kanBanToggle={kanBanToggle}
|
||||
handleKanBanToggle={handleKanBanToggle}
|
||||
kanbanFilters={kanbanFilters}
|
||||
handleKanbanFilters={handleKanbanFilters}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full border-b border-dashed border-custom-border-400" />
|
||||
</div>
|
||||
{!kanBanToggle?.subgroupByIssuesVisibility.includes(_list.id) && (
|
||||
|
||||
{!kanbanFilters?.sub_group_by.includes(_list.id) && (
|
||||
<div className="relative">
|
||||
<KanBan
|
||||
issuesMap={issuesMap}
|
||||
@ -140,8 +142,8 @@ const SubGroupSwimlane: React.FC<ISubGroupSwimlane> = observer((props) => {
|
||||
sub_group_id={_list.id}
|
||||
handleIssues={handleIssues}
|
||||
quickActions={quickActions}
|
||||
kanBanToggle={kanBanToggle}
|
||||
handleKanBanToggle={handleKanBanToggle}
|
||||
kanbanFilters={kanbanFilters}
|
||||
handleKanbanFilters={handleKanbanFilters}
|
||||
showEmptyGroup={showEmptyGroup}
|
||||
enableQuickIssueCreate={enableQuickIssueCreate}
|
||||
canEditProperties={canEditProperties}
|
||||
@ -165,8 +167,8 @@ export interface IKanBanSwimLanes {
|
||||
group_by: string | null;
|
||||
handleIssues: (issue: TIssue, action: EIssueActions) => void;
|
||||
quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode;
|
||||
kanBanToggle: any;
|
||||
handleKanBanToggle: any;
|
||||
kanbanFilters: TIssueKanbanFilters;
|
||||
handleKanbanFilters: (toggle: "group_by" | "sub_group_by", value: string) => void;
|
||||
showEmptyGroup: boolean;
|
||||
isDragStarted?: boolean;
|
||||
disableIssueCreation?: boolean;
|
||||
@ -192,8 +194,8 @@ export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
|
||||
group_by,
|
||||
handleIssues,
|
||||
quickActions,
|
||||
kanBanToggle,
|
||||
handleKanBanToggle,
|
||||
kanbanFilters,
|
||||
handleKanbanFilters,
|
||||
showEmptyGroup,
|
||||
isDragStarted,
|
||||
disableIssueCreation,
|
||||
@ -227,8 +229,8 @@ export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
|
||||
issueIds={issueIds}
|
||||
group_by={group_by}
|
||||
sub_group_by={sub_group_by}
|
||||
kanBanToggle={kanBanToggle}
|
||||
handleKanBanToggle={handleKanBanToggle}
|
||||
kanbanFilters={kanbanFilters}
|
||||
handleKanbanFilters={handleKanbanFilters}
|
||||
list={groupByList}
|
||||
/>
|
||||
</div>
|
||||
@ -243,8 +245,8 @@ export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
|
||||
sub_group_by={sub_group_by}
|
||||
handleIssues={handleIssues}
|
||||
quickActions={quickActions}
|
||||
kanBanToggle={kanBanToggle}
|
||||
handleKanBanToggle={handleKanBanToggle}
|
||||
kanbanFilters={kanbanFilters}
|
||||
handleKanbanFilters={handleKanbanFilters}
|
||||
showEmptyGroup={showEmptyGroup}
|
||||
isDragStarted={isDragStarted}
|
||||
disableIssueCreation={disableIssueCreation}
|
||||
|
@ -8,6 +8,41 @@ import { IProjectViewIssues } from "store/issue/project-views";
|
||||
import { IWorkspaceIssues } from "store/issue/workspace";
|
||||
import { TGroupedIssues, IIssueMap, TSubGroupedIssues, TUnGroupedIssues } from "@plane/types";
|
||||
|
||||
const handleSortOrder = (destinationIssues: string[], destinationIndex: number, issueMap: IIssueMap) => {
|
||||
const sortOrderDefaultValue = 65535;
|
||||
let currentIssueState = {};
|
||||
|
||||
if (destinationIssues && destinationIssues.length > 0) {
|
||||
if (destinationIndex === 0) {
|
||||
const destinationIssueId = destinationIssues[destinationIndex];
|
||||
currentIssueState = {
|
||||
...currentIssueState,
|
||||
sort_order: issueMap[destinationIssueId].sort_order - sortOrderDefaultValue,
|
||||
};
|
||||
} else if (destinationIndex === destinationIssues.length) {
|
||||
const destinationIssueId = destinationIssues[destinationIndex - 1];
|
||||
currentIssueState = {
|
||||
...currentIssueState,
|
||||
sort_order: issueMap[destinationIssueId].sort_order + sortOrderDefaultValue,
|
||||
};
|
||||
} else {
|
||||
const destinationTopIssueId = destinationIssues[destinationIndex - 1];
|
||||
const destinationBottomIssueId = destinationIssues[destinationIndex];
|
||||
currentIssueState = {
|
||||
...currentIssueState,
|
||||
sort_order: (issueMap[destinationTopIssueId].sort_order + issueMap[destinationBottomIssueId].sort_order) / 2,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
currentIssueState = {
|
||||
...currentIssueState,
|
||||
sort_order: sortOrderDefaultValue,
|
||||
};
|
||||
}
|
||||
|
||||
return currentIssueState;
|
||||
};
|
||||
|
||||
export const handleDragDrop = async (
|
||||
source: DraggableLocation | null | undefined,
|
||||
destination: DraggableLocation | null | undefined,
|
||||
@ -50,7 +85,7 @@ export const handleDragDrop = async (
|
||||
!sourceGroupByColumnId ||
|
||||
!destinationGroupByColumnId ||
|
||||
!sourceSubGroupByColumnId ||
|
||||
!sourceGroupByColumnId
|
||||
!destinationSubGroupByColumnId
|
||||
)
|
||||
return;
|
||||
|
||||
@ -76,92 +111,49 @@ export const handleDragDrop = async (
|
||||
const [removed] = sourceIssues.splice(source.index, 1);
|
||||
const removedIssueDetail = issueMap[removed];
|
||||
|
||||
updateIssue = {
|
||||
id: removedIssueDetail?.id,
|
||||
project_id: removedIssueDetail?.project_id,
|
||||
};
|
||||
|
||||
// for both horizontal and vertical dnd
|
||||
updateIssue = {
|
||||
...updateIssue,
|
||||
...handleSortOrder(destinationIssues, destination.index, issueMap),
|
||||
};
|
||||
|
||||
if (subGroupBy && sourceSubGroupByColumnId && destinationSubGroupByColumnId) {
|
||||
updateIssue = {
|
||||
id: removedIssueDetail?.id,
|
||||
};
|
||||
|
||||
// for both horizontal and vertical dnd
|
||||
updateIssue = {
|
||||
...updateIssue,
|
||||
...handleSortOrder(destinationIssues, destination.index, issueMap),
|
||||
};
|
||||
|
||||
if (sourceSubGroupByColumnId === destinationSubGroupByColumnId) {
|
||||
if (sourceGroupByColumnId != destinationGroupByColumnId) {
|
||||
if (groupBy === "state") updateIssue = { ...updateIssue, state: destinationGroupByColumnId };
|
||||
if (groupBy === "state") updateIssue = { ...updateIssue, state_id: destinationGroupByColumnId };
|
||||
if (groupBy === "priority") updateIssue = { ...updateIssue, priority: destinationGroupByColumnId };
|
||||
}
|
||||
} else {
|
||||
if (subGroupBy === "state")
|
||||
updateIssue = {
|
||||
...updateIssue,
|
||||
state: destinationSubGroupByColumnId,
|
||||
state_id: destinationSubGroupByColumnId,
|
||||
priority: destinationGroupByColumnId,
|
||||
};
|
||||
if (subGroupBy === "priority")
|
||||
updateIssue = {
|
||||
...updateIssue,
|
||||
state: destinationGroupByColumnId,
|
||||
state_id: destinationGroupByColumnId,
|
||||
priority: destinationSubGroupByColumnId,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
updateIssue = {
|
||||
id: removedIssueDetail?.id,
|
||||
};
|
||||
|
||||
// for both horizontal and vertical dnd
|
||||
updateIssue = {
|
||||
...updateIssue,
|
||||
...handleSortOrder(destinationIssues, destination.index, issueMap),
|
||||
};
|
||||
|
||||
// for horizontal dnd
|
||||
if (sourceColumnId != destinationColumnId) {
|
||||
if (groupBy === "state") updateIssue = { ...updateIssue, state: destinationGroupByColumnId };
|
||||
if (groupBy === "state") updateIssue = { ...updateIssue, state_id: destinationGroupByColumnId };
|
||||
if (groupBy === "priority") updateIssue = { ...updateIssue, priority: destinationGroupByColumnId };
|
||||
}
|
||||
}
|
||||
|
||||
if (updateIssue && updateIssue?.id) {
|
||||
if (viewId) return await store?.updateIssue(workspaceSlug, projectId, updateIssue.id, updateIssue); //, viewId);
|
||||
else return await store?.updateIssue(workspaceSlug, projectId, updateIssue.id, updateIssue);
|
||||
if (viewId)
|
||||
return await store?.updateIssue(workspaceSlug, updateIssue.project_id, updateIssue.id, updateIssue, viewId);
|
||||
else return await store?.updateIssue(workspaceSlug, updateIssue.project_id, updateIssue.id, updateIssue);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSortOrder = (destinationIssues: string[], destinationIndex: number, issueMap: IIssueMap) => {
|
||||
const sortOrderDefaultValue = 65535;
|
||||
let currentIssueState = {};
|
||||
|
||||
if (destinationIssues && destinationIssues.length > 0) {
|
||||
if (destinationIndex === 0) {
|
||||
const destinationIssueId = destinationIssues[destinationIndex];
|
||||
currentIssueState = {
|
||||
...currentIssueState,
|
||||
sort_order: issueMap[destinationIssueId].sort_order - sortOrderDefaultValue,
|
||||
};
|
||||
} else if (destinationIndex === destinationIssues.length) {
|
||||
const destinationIssueId = destinationIssues[destinationIndex - 1];
|
||||
currentIssueState = {
|
||||
...currentIssueState,
|
||||
sort_order: issueMap[destinationIssueId].sort_order + sortOrderDefaultValue,
|
||||
};
|
||||
} else {
|
||||
const destinationTopIssueId = destinationIssues[destinationIndex - 1];
|
||||
const destinationBottomIssueId = destinationIssues[destinationIndex];
|
||||
currentIssueState = {
|
||||
...currentIssueState,
|
||||
sort_order: (issueMap[destinationTopIssueId].sort_order + issueMap[destinationBottomIssueId].sort_order) / 2,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
currentIssueState = {
|
||||
...currentIssueState,
|
||||
sort_order: sortOrderDefaultValue,
|
||||
};
|
||||
}
|
||||
|
||||
return currentIssueState;
|
||||
};
|
||||
|
@ -138,15 +138,6 @@ export const BaseListRoot = observer((props: IBaseListRoot) => {
|
||||
addIssuesToView={addIssuesToView}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* {workspaceSlug && peekIssueId && peekProjectId && (
|
||||
<IssuePeekOverview
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={peekProjectId.toString()}
|
||||
issueId={peekIssueId.toString()}
|
||||
handleIssue={async (issueToUpdate) => await handleIssues(issueToUpdate as TIssue, EIssueActions.UPDATE)}
|
||||
/>
|
||||
)} */}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
@ -62,7 +62,7 @@ export const IssueBlock: React.FC<IssueBlockProps> = observer((props: IssueBlock
|
||||
href={`/${workspaceSlug}/projects/${projectId}/issues/${issueId}`}
|
||||
target="_blank"
|
||||
onClick={() => handleIssuePeekOverview(issue)}
|
||||
className="w-full line-clamp-1 cursor-pointer text-sm font-medium text-custom-text-100"
|
||||
className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100"
|
||||
>
|
||||
<Tooltip tooltipHeading="Title" tooltipContent={issue.name}>
|
||||
<span>{issue.name}</span>
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { CalendarCheck2, CalendarClock, Layers, Link, Paperclip } from "lucide-react";
|
||||
// hooks
|
||||
import { useLabel } from "hooks/store";
|
||||
import { useEstimate, useLabel } from "hooks/store";
|
||||
// components
|
||||
import { IssuePropertyLabels } from "../properties/labels";
|
||||
import { Tooltip } from "@plane/ui";
|
||||
@ -29,6 +29,7 @@ export interface IIssueProperties {
|
||||
export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
||||
const { issue, handleIssues, displayProperties, isReadOnly, className } = props;
|
||||
const { labelMap } = useLabel();
|
||||
const { areEstimatesEnabledForCurrentProject } = useEstimate();
|
||||
|
||||
const handleState = (stateId: string) => {
|
||||
handleIssues({ ...issue, state_id: stateId });
|
||||
@ -92,7 +93,6 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
||||
</WithDisplayPropertiesHOC>
|
||||
|
||||
{/* label */}
|
||||
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="labels">
|
||||
<IssuePropertyLabels
|
||||
projectId={issue?.project_id || null}
|
||||
@ -122,6 +122,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="due_date">
|
||||
<div className="h-5">
|
||||
<DateDropdown
|
||||
minDate={issue.start_date ? new Date(issue.start_date) : undefined}
|
||||
value={issue?.target_date ?? null}
|
||||
onChange={handleTargetDate}
|
||||
icon={<CalendarCheck2 className="h-3 w-3 flex-shrink-0" />}
|
||||
@ -148,17 +149,19 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
||||
</WithDisplayPropertiesHOC>
|
||||
|
||||
{/* estimates */}
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="estimate">
|
||||
<div className="h-5">
|
||||
<EstimateDropdown
|
||||
value={issue.estimate_point}
|
||||
onChange={handleEstimate}
|
||||
projectId={issue.project_id}
|
||||
disabled={isReadOnly}
|
||||
buttonVariant="border-with-text"
|
||||
/>
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
{areEstimatesEnabledForCurrentProject && (
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="estimate">
|
||||
<div className="h-5">
|
||||
<EstimateDropdown
|
||||
value={issue.estimate_point}
|
||||
onChange={handleEstimate}
|
||||
projectId={issue.project_id}
|
||||
disabled={isReadOnly}
|
||||
buttonVariant="border-with-text"
|
||||
/>
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
)}
|
||||
|
||||
{/* extra render properties */}
|
||||
{/* sub-issues */}
|
||||
|
@ -5,31 +5,53 @@ import useSWR from "swr";
|
||||
// mobx store
|
||||
import { useIssues } from "hooks/store";
|
||||
// components
|
||||
import { ArchivedIssueListLayout, ArchivedIssueAppliedFiltersRoot } from "components/issues";
|
||||
import { ArchivedIssueListLayout, ArchivedIssueAppliedFiltersRoot, ProjectEmptyState } from "components/issues";
|
||||
import { EIssuesStoreType } from "constants/issue";
|
||||
// ui
|
||||
import { Spinner } from "@plane/ui";
|
||||
|
||||
export const ArchivedIssueLayoutRoot: React.FC = observer(() => {
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query as { workspaceSlug: string; projectId: string };
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
// hooks
|
||||
const { issues, issuesFilter } = useIssues(EIssuesStoreType.ARCHIVED);
|
||||
|
||||
const {
|
||||
issues: { groupedIssueIds, fetchIssues },
|
||||
issuesFilter: { fetchFilters },
|
||||
} = useIssues(EIssuesStoreType.ARCHIVED);
|
||||
|
||||
useSWR(workspaceSlug && projectId ? `ARCHIVED_FILTERS_AND_ISSUES_${projectId.toString()}` : null, async () => {
|
||||
if (workspaceSlug && projectId) {
|
||||
await fetchFilters(workspaceSlug, projectId);
|
||||
await fetchIssues(workspaceSlug, projectId, groupedIssueIds ? "mutation" : "init-loader");
|
||||
useSWR(
|
||||
workspaceSlug && projectId ? `ARCHIVED_ISSUES_${workspaceSlug.toString()}_${projectId.toString()}` : null,
|
||||
async () => {
|
||||
if (workspaceSlug && projectId) {
|
||||
await issuesFilter?.fetchFilters(workspaceSlug.toString(), projectId.toString());
|
||||
await issues?.fetchIssues(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
issues?.groupedIssueIds ? "mutation" : "init-loader"
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
if (!workspaceSlug || !projectId) return <></>;
|
||||
return (
|
||||
<div className="relative flex h-full w-full flex-col overflow-hidden">
|
||||
<ArchivedIssueAppliedFiltersRoot />
|
||||
<div className="h-full w-full overflow-auto">
|
||||
<ArchivedIssueListLayout />
|
||||
</div>
|
||||
|
||||
{issues?.loader === "init-loader" ? (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{!issues?.groupedIssueIds ? (
|
||||
// TODO: Replace this with project view empty state
|
||||
<ProjectEmptyState />
|
||||
) : (
|
||||
<div className="relative h-full w-full overflow-auto">
|
||||
<ArchivedIssueListLayout />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
@ -21,36 +21,37 @@ import { Spinner } from "@plane/ui";
|
||||
import { EIssuesStoreType } from "constants/issue";
|
||||
|
||||
export const CycleLayoutRoot: React.FC = observer(() => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, cycleId } = router.query;
|
||||
// store hooks
|
||||
const { issues, issuesFilter } = useIssues(EIssuesStoreType.CYCLE);
|
||||
const { getCycleById } = useCycle();
|
||||
// state
|
||||
const [transferIssuesModal, setTransferIssuesModal] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, cycleId } = router.query as {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
cycleId: string;
|
||||
};
|
||||
// store hooks
|
||||
const {
|
||||
issues: { loader, groupedIssueIds, fetchIssues },
|
||||
issuesFilter: { issueFilters, fetchFilters },
|
||||
} = useIssues(EIssuesStoreType.CYCLE);
|
||||
const { getCycleById } = useCycle();
|
||||
|
||||
useSWR(
|
||||
workspaceSlug && projectId && cycleId ? `CYCLE_ISSUES_V3_${workspaceSlug}_${projectId}_${cycleId}` : null,
|
||||
workspaceSlug && projectId && cycleId
|
||||
? `CYCLE_ISSUES_${workspaceSlug.toString()}_${projectId.toString()}_${cycleId.toString()}`
|
||||
: null,
|
||||
async () => {
|
||||
if (workspaceSlug && projectId && cycleId) {
|
||||
await fetchFilters(workspaceSlug, projectId, cycleId);
|
||||
await fetchIssues(workspaceSlug, projectId, groupedIssueIds ? "mutation" : "init-loader", cycleId);
|
||||
await issuesFilter?.fetchFilters(workspaceSlug.toString(), projectId.toString(), cycleId.toString());
|
||||
await issues?.fetchIssues(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
issues?.groupedIssueIds ? "mutation" : "init-loader",
|
||||
cycleId.toString()
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const activeLayout = issueFilters?.displayFilters?.layout;
|
||||
const activeLayout = issuesFilter?.issueFilters?.displayFilters?.layout;
|
||||
|
||||
const cycleDetails = cycleId ? getCycleById(cycleId) : undefined;
|
||||
const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : undefined;
|
||||
const cycleStatus = cycleDetails?.status.toLocaleLowerCase() ?? "draft";
|
||||
|
||||
if (!workspaceSlug || !projectId || !cycleId) return <></>;
|
||||
return (
|
||||
<>
|
||||
<TransferIssuesModal handleClose={() => setTransferIssuesModal(false)} isOpen={transferIssuesModal} />
|
||||
@ -59,14 +60,18 @@ export const CycleLayoutRoot: React.FC = observer(() => {
|
||||
{cycleStatus === "completed" && <TransferIssues handleClick={() => setTransferIssuesModal(true)} />}
|
||||
<CycleAppliedFiltersRoot />
|
||||
|
||||
{loader === "init-loader" || !groupedIssueIds ? (
|
||||
{issues?.loader === "init-loader" ? (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{Object.keys(groupedIssueIds ?? {}).length == 0 ? (
|
||||
<CycleEmptyState workspaceSlug={workspaceSlug} projectId={projectId} cycleId={cycleId} />
|
||||
{!issues?.groupedIssueIds ? (
|
||||
<CycleEmptyState
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
cycleId={cycleId.toString()}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-full overflow-auto">
|
||||
{activeLayout === "list" ? (
|
||||
|
@ -2,49 +2,64 @@ import React from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import useSWR from "swr";
|
||||
// mobx store
|
||||
// hooks
|
||||
import { useIssues } from "hooks/store";
|
||||
// components
|
||||
import { DraftIssueAppliedFiltersRoot } from "../filters/applied-filters/roots/draft-issue";
|
||||
import { DraftIssueListLayout } from "../list/roots/draft-issue-root";
|
||||
import { ProjectEmptyState } from "../empty-states";
|
||||
// ui
|
||||
import { Spinner } from "@plane/ui";
|
||||
import { DraftKanBanLayout } from "../kanban/roots/draft-issue-root";
|
||||
// constants
|
||||
import { EIssuesStoreType } from "constants/issue";
|
||||
|
||||
export const DraftIssueLayoutRoot: React.FC = observer(() => {
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query as { workspaceSlug: string; projectId: string };
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
// hooks
|
||||
const { issues, issuesFilter } = useIssues(EIssuesStoreType.DRAFT);
|
||||
|
||||
const {
|
||||
issues: { loader, groupedIssueIds, fetchIssues },
|
||||
issuesFilter: { issueFilters, fetchFilters },
|
||||
} = useIssues(EIssuesStoreType.DRAFT);
|
||||
|
||||
useSWR(workspaceSlug && projectId ? `DRAFT_FILTERS_AND_ISSUES_${projectId.toString()}` : null, async () => {
|
||||
if (workspaceSlug && projectId) {
|
||||
await fetchFilters(workspaceSlug, projectId);
|
||||
await fetchIssues(workspaceSlug, projectId, groupedIssueIds ? "mutation" : "init-loader");
|
||||
useSWR(
|
||||
workspaceSlug && projectId ? `DRAFT_ISSUES_${workspaceSlug.toString()}_${projectId.toString()}` : null,
|
||||
async () => {
|
||||
if (workspaceSlug && projectId) {
|
||||
await issuesFilter?.fetchFilters(workspaceSlug.toString(), projectId.toString());
|
||||
await issues?.fetchIssues(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
issues?.groupedIssueIds ? "mutation" : "init-loader"
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
const activeLayout = issueFilters?.displayFilters?.layout || undefined;
|
||||
const activeLayout = issuesFilter?.issueFilters?.displayFilters?.layout || undefined;
|
||||
|
||||
if (!workspaceSlug || !projectId) return <></>;
|
||||
return (
|
||||
<div className="relative flex h-full w-full flex-col overflow-hidden">
|
||||
<DraftIssueAppliedFiltersRoot />
|
||||
|
||||
{loader === "init-loader" ? (
|
||||
{issues?.loader === "init-loader" ? (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="relative h-full w-full overflow-auto">
|
||||
{activeLayout === "list" ? (
|
||||
<DraftIssueListLayout />
|
||||
) : activeLayout === "kanban" ? (
|
||||
<DraftKanBanLayout />
|
||||
) : null}
|
||||
</div>
|
||||
{!issues?.groupedIssueIds ? (
|
||||
// TODO: Replace this with project view empty state
|
||||
<ProjectEmptyState />
|
||||
) : (
|
||||
<div className="relative h-full w-full overflow-auto">
|
||||
{activeLayout === "list" ? (
|
||||
<DraftIssueListLayout />
|
||||
) : activeLayout === "kanban" ? (
|
||||
<DraftKanBanLayout />
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
@ -1,6 +1,7 @@
|
||||
export * from "./cycle-layout-root";
|
||||
export * from "./all-issue-layout-root";
|
||||
export * from "./module-layout-root";
|
||||
export * from "./project-layout-root";
|
||||
export * from "./module-layout-root";
|
||||
export * from "./cycle-layout-root";
|
||||
export * from "./project-view-layout-root";
|
||||
export * from "./archived-issue-layout-root";
|
||||
export * from "./draft-issue-layout-root";
|
||||
export * from "./all-issue-layout-root";
|
||||
|
@ -2,7 +2,6 @@ import React from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import useSWR from "swr";
|
||||
|
||||
// mobx store
|
||||
import { useIssues } from "hooks/store";
|
||||
// components
|
||||
@ -20,42 +19,48 @@ import { Spinner } from "@plane/ui";
|
||||
import { EIssuesStoreType } from "constants/issue";
|
||||
|
||||
export const ModuleLayoutRoot: React.FC = observer(() => {
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, moduleId } = router.query as {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
moduleId: string;
|
||||
};
|
||||
|
||||
const {
|
||||
issues: { loader, groupedIssueIds, fetchIssues },
|
||||
issuesFilter: { issueFilters, fetchFilters },
|
||||
} = useIssues(EIssuesStoreType.MODULE);
|
||||
const { workspaceSlug, projectId, moduleId } = router.query;
|
||||
// hooks
|
||||
const { issues, issuesFilter } = useIssues(EIssuesStoreType.MODULE);
|
||||
|
||||
useSWR(
|
||||
workspaceSlug && projectId && moduleId ? `MODULE_ISSUES_V3_${workspaceSlug}_${projectId}_${moduleId}` : null,
|
||||
workspaceSlug && projectId && moduleId
|
||||
? `MODULE_ISSUES_${workspaceSlug.toString()}_${projectId.toString()}_${moduleId.toString()}`
|
||||
: null,
|
||||
async () => {
|
||||
if (workspaceSlug && projectId && moduleId) {
|
||||
await fetchFilters(workspaceSlug, projectId, moduleId);
|
||||
await fetchIssues(workspaceSlug, projectId, groupedIssueIds ? "mutation" : "init-loader", moduleId);
|
||||
await issuesFilter?.fetchFilters(workspaceSlug.toString(), projectId.toString(), moduleId.toString());
|
||||
await issues?.fetchIssues(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
issues?.groupedIssueIds ? "mutation" : "init-loader",
|
||||
moduleId.toString()
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const activeLayout = issueFilters?.displayFilters?.layout || undefined;
|
||||
const activeLayout = issuesFilter?.issueFilters?.displayFilters?.layout || undefined;
|
||||
|
||||
if (!workspaceSlug || !projectId || !moduleId) return <></>;
|
||||
return (
|
||||
<div className="relative flex h-full w-full flex-col overflow-hidden">
|
||||
<ModuleAppliedFiltersRoot />
|
||||
|
||||
{loader === "init-loader" || !groupedIssueIds ? (
|
||||
{issues?.loader === "init-loader" ? (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{Object.keys(groupedIssueIds ?? {}).length == 0 ? (
|
||||
<ModuleEmptyState workspaceSlug={workspaceSlug} projectId={projectId} moduleId={moduleId} />
|
||||
{!issues?.groupedIssueIds ? (
|
||||
<ModuleEmptyState
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
moduleId={moduleId.toString()}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-full overflow-auto">
|
||||
{activeLayout === "list" ? (
|
||||
@ -71,7 +76,6 @@ export const ModuleLayoutRoot: React.FC = observer(() => {
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{/* <ModuleEmptyState workspaceSlug={workspaceSlug} projectId={projectId} moduleId={moduleId} /> */}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { FC } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import useSWR from "swr";
|
||||
// components
|
||||
@ -15,23 +16,27 @@ import {
|
||||
// ui
|
||||
import { Spinner } from "@plane/ui";
|
||||
// hooks
|
||||
import { useApplication, useIssues } from "hooks/store";
|
||||
import { useIssues } from "hooks/store";
|
||||
// constants
|
||||
import { EIssuesStoreType } from "constants/issue";
|
||||
|
||||
export const ProjectLayoutRoot: FC = observer(() => {
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
// hooks
|
||||
const {
|
||||
router: { workspaceSlug, projectId },
|
||||
} = useApplication();
|
||||
const { issues, issuesFilter } = useIssues(EIssuesStoreType.PROJECT);
|
||||
|
||||
useSWR(
|
||||
const {} = useSWR(
|
||||
workspaceSlug && projectId ? `PROJECT_ISSUES_${workspaceSlug}_${projectId}` : null,
|
||||
async () => {
|
||||
if (workspaceSlug && projectId) {
|
||||
await issuesFilter?.fetchFilters(workspaceSlug, projectId);
|
||||
await issues?.fetchIssues(workspaceSlug, projectId, issues?.groupedIssueIds ? "mutation" : "init-loader");
|
||||
await issuesFilter?.fetchFilters(workspaceSlug.toString(), projectId.toString());
|
||||
await issues?.fetchIssues(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
issues?.groupedIssueIds ? "mutation" : "init-loader"
|
||||
);
|
||||
}
|
||||
},
|
||||
{ revalidateOnFocus: false, refreshInterval: 600000, revalidateOnMount: true }
|
||||
@ -39,6 +44,7 @@ export const ProjectLayoutRoot: FC = observer(() => {
|
||||
|
||||
const activeLayout = issuesFilter?.issueFilters?.displayFilters?.layout;
|
||||
|
||||
if (!workspaceSlug || !projectId) return <></>;
|
||||
return (
|
||||
<div className="relative flex h-full w-full flex-col overflow-hidden">
|
||||
<ProjectAppliedFiltersRoot />
|
||||
@ -56,6 +62,13 @@ export const ProjectLayoutRoot: FC = observer(() => {
|
||||
) : (
|
||||
<>
|
||||
<div className="relative h-full w-full overflow-auto bg-custom-background-90">
|
||||
{/* mutation loader */}
|
||||
{issues?.loader === "mutation" && (
|
||||
<div className="fixed w-[40px] h-[40px] z-50 right-[20px] top-[70px] flex justify-center items-center bg-custom-background-80 shadow-sm rounded">
|
||||
<Spinner className="w-4 h-4" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeLayout === "list" ? (
|
||||
<ListLayout />
|
||||
) : activeLayout === "kanban" ? (
|
||||
|
@ -6,6 +6,7 @@ import useSWR from "swr";
|
||||
import { useIssues } from "hooks/store";
|
||||
// components
|
||||
import {
|
||||
ProjectEmptyState,
|
||||
ProjectViewAppliedFiltersRoot,
|
||||
ProjectViewCalendarLayout,
|
||||
ProjectViewGanttLayout,
|
||||
@ -17,53 +18,58 @@ import { Spinner } from "@plane/ui";
|
||||
import { EIssuesStoreType } from "constants/issue";
|
||||
|
||||
export const ProjectViewLayoutRoot: React.FC = observer(() => {
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, viewId } = router.query as {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
viewId?: string;
|
||||
};
|
||||
|
||||
const {
|
||||
issues: { loader, groupedIssueIds, fetchIssues },
|
||||
issuesFilter: { issueFilters, fetchFilters },
|
||||
} = useIssues(EIssuesStoreType.PROJECT_VIEW);
|
||||
const { workspaceSlug, projectId, viewId } = router.query;
|
||||
// hooks
|
||||
const { issues, issuesFilter } = useIssues(EIssuesStoreType.PROJECT_VIEW);
|
||||
|
||||
useSWR(
|
||||
workspaceSlug && projectId && viewId ? `PROJECT_VIEW_ISSUES_${workspaceSlug}_${projectId}` : null,
|
||||
async () => {
|
||||
if (workspaceSlug && projectId && viewId) {
|
||||
await fetchFilters(workspaceSlug, projectId, viewId);
|
||||
await fetchIssues(workspaceSlug, projectId, groupedIssueIds ? "mutation" : "init-loader");
|
||||
await issuesFilter?.fetchFilters(workspaceSlug.toString(), projectId.toString(), viewId.toString());
|
||||
await issues?.fetchIssues(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
issues?.groupedIssueIds ? "mutation" : "init-loader",
|
||||
viewId.toString()
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const activeLayout = issueFilters?.displayFilters?.layout;
|
||||
const activeLayout = issuesFilter?.issueFilters?.displayFilters?.layout;
|
||||
|
||||
if (!workspaceSlug || !projectId || !viewId) return <></>;
|
||||
return (
|
||||
<div className="relative flex h-full w-full flex-col overflow-hidden">
|
||||
<ProjectViewAppliedFiltersRoot />
|
||||
|
||||
{loader === "init-loader" ? (
|
||||
{issues?.loader === "init-loader" ? (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="relative h-full w-full overflow-auto">
|
||||
{activeLayout === "list" ? (
|
||||
<ProjectViewListLayout />
|
||||
) : activeLayout === "kanban" ? (
|
||||
<ProjectViewKanBanLayout />
|
||||
) : activeLayout === "calendar" ? (
|
||||
<ProjectViewCalendarLayout />
|
||||
) : activeLayout === "gantt_chart" ? (
|
||||
<ProjectViewGanttLayout />
|
||||
) : activeLayout === "spreadsheet" ? (
|
||||
<ProjectViewSpreadsheetLayout />
|
||||
) : null}
|
||||
</div>
|
||||
{!issues?.groupedIssueIds ? (
|
||||
// TODO: Replace this with project view empty state
|
||||
<ProjectEmptyState />
|
||||
) : (
|
||||
<div className="relative h-full w-full overflow-auto">
|
||||
{activeLayout === "list" ? (
|
||||
<ProjectViewListLayout />
|
||||
) : activeLayout === "kanban" ? (
|
||||
<ProjectViewKanBanLayout />
|
||||
) : activeLayout === "calendar" ? (
|
||||
<ProjectViewCalendarLayout />
|
||||
) : activeLayout === "gantt_chart" ? (
|
||||
<ProjectViewGanttLayout />
|
||||
) : activeLayout === "spreadsheet" ? (
|
||||
<ProjectViewSpreadsheetLayout />
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
@ -31,7 +31,7 @@ export const getGroupByColumns = (
|
||||
case "created_by":
|
||||
return getCreatedByColumns(member) as any;
|
||||
default:
|
||||
if (includeNone) return [{ id: `null`, name: `All Issues`, payload: {}, Icon: undefined }];
|
||||
if (includeNone) return [{ id: `null`, name: `All Issues`, payload: {}, icon: undefined }];
|
||||
}
|
||||
};
|
||||
|
||||
@ -48,7 +48,7 @@ const getProjectColumns = (project: IProjectStore): IGroupByColumn[] | undefined
|
||||
return {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
Icon: <div className="w-6 h-6">{renderEmoji(project.emoji || "")}</div>,
|
||||
icon: <div className="w-6 h-6">{renderEmoji(project.emoji || "")}</div>,
|
||||
payload: { project_id: project.id },
|
||||
};
|
||||
}) as any;
|
||||
@ -61,7 +61,7 @@ const getStateColumns = (projectState: IStateStore): IGroupByColumn[] | undefine
|
||||
return projectStates.map((state) => ({
|
||||
id: state.id,
|
||||
name: state.name,
|
||||
Icon: (
|
||||
icon: (
|
||||
<div className="w-3.5 h-3.5 rounded-full">
|
||||
<StateGroupIcon stateGroup={state.group} color={state.color} width="14" height="14" />
|
||||
</div>
|
||||
@ -76,7 +76,7 @@ const getStateGroupColumns = () => {
|
||||
return stateGroups.map((stateGroup) => ({
|
||||
id: stateGroup.key,
|
||||
name: stateGroup.title,
|
||||
Icon: (
|
||||
icon: (
|
||||
<div className="w-3.5 h-3.5 rounded-full">
|
||||
<StateGroupIcon stateGroup={stateGroup.key} width="14" height="14" />
|
||||
</div>
|
||||
@ -91,7 +91,7 @@ const getPriorityColumns = () => {
|
||||
return priorities.map((priority) => ({
|
||||
id: priority.key,
|
||||
name: priority.title,
|
||||
Icon: <PriorityIcon priority={priority?.key} />,
|
||||
icon: <PriorityIcon priority={priority?.key} />,
|
||||
payload: { priority: priority.key },
|
||||
}));
|
||||
};
|
||||
@ -108,7 +108,7 @@ const getLabelsColumns = (projectLabel: ILabelRootStore) => {
|
||||
return labels.map((label) => ({
|
||||
id: label.id,
|
||||
name: label.name,
|
||||
Icon: (
|
||||
icon: (
|
||||
<div className="w-[12px] h-[12px] rounded-full" style={{ backgroundColor: label.color ? label.color : "#666" }} />
|
||||
),
|
||||
payload: label?.id === "None" ? {} : { label_ids: [label.id] },
|
||||
@ -128,12 +128,12 @@ const getAssigneeColumns = (member: IMemberRootStore) => {
|
||||
return {
|
||||
id: memberId,
|
||||
name: member?.display_name || "",
|
||||
Icon: <Avatar name={member?.display_name} src={member?.avatar} size="md" />,
|
||||
icon: <Avatar name={member?.display_name} src={member?.avatar} size="md" />,
|
||||
payload: { assignee_ids: [memberId] },
|
||||
};
|
||||
});
|
||||
|
||||
assigneeColumns.push({ id: "None", name: "None", Icon: <Avatar size="md" />, payload: {} });
|
||||
assigneeColumns.push({ id: "None", name: "None", icon: <Avatar size="md" />, payload: {} });
|
||||
|
||||
return assigneeColumns;
|
||||
};
|
||||
@ -151,7 +151,7 @@ const getCreatedByColumns = (member: IMemberRootStore) => {
|
||||
return {
|
||||
id: memberId,
|
||||
name: member?.display_name || "",
|
||||
Icon: <Avatar name={member?.display_name} src={member?.avatar} size="md" />,
|
||||
icon: <Avatar name={member?.display_name} src={member?.avatar} size="md" />,
|
||||
payload: {},
|
||||
};
|
||||
});
|
||||
|
@ -1 +0,0 @@
|
||||
export * from "./root";
|
@ -1,80 +0,0 @@
|
||||
import { observer } from "mobx-react-lite";
|
||||
// hooks
|
||||
import { useUser } from "hooks/store";
|
||||
import useIssueReaction from "hooks/use-issue-reaction";
|
||||
// components
|
||||
import { ReactionSelector } from "components/core";
|
||||
// string helpers
|
||||
import { renderEmoji } from "helpers/emoji.helper";
|
||||
|
||||
// types
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
};
|
||||
|
||||
export const IssueReaction: React.FC<Props> = observer((props) => {
|
||||
const { workspaceSlug, projectId, issueId } = props;
|
||||
|
||||
const { currentUser } = useUser();
|
||||
|
||||
const { reactions, groupedReactions, handleReactionCreate, handleReactionDelete } = useIssueReaction(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
issueId
|
||||
);
|
||||
|
||||
const handleReactionClick = (reaction: string) => {
|
||||
if (!workspaceSlug || !projectId || !issueId) return;
|
||||
|
||||
const isSelected = reactions?.some((r) => r.actor === currentUser?.id && r.reaction === reaction);
|
||||
|
||||
if (isSelected) {
|
||||
handleReactionDelete(reaction);
|
||||
} else {
|
||||
handleReactionCreate(reaction);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-4 flex items-center gap-1.5">
|
||||
<ReactionSelector
|
||||
size="md"
|
||||
position="top"
|
||||
value={reactions?.filter((reaction) => reaction.actor === currentUser?.id).map((r) => r.reaction) || []}
|
||||
onSelect={handleReactionClick}
|
||||
/>
|
||||
|
||||
{Object.keys(groupedReactions || {}).map(
|
||||
(reaction) =>
|
||||
groupedReactions?.[reaction]?.length &&
|
||||
groupedReactions[reaction].length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
handleReactionClick(reaction);
|
||||
}}
|
||||
key={reaction}
|
||||
className={`flex h-full items-center gap-1 rounded-md px-2 py-1 text-sm text-custom-text-100 ${
|
||||
reactions?.some((r) => r.actor === currentUser?.id && r.reaction === reaction)
|
||||
? "bg-custom-primary-100/10"
|
||||
: "bg-custom-background-80"
|
||||
}`}
|
||||
>
|
||||
<span>{renderEmoji(reaction)}</span>
|
||||
<span
|
||||
className={
|
||||
reactions?.some((r) => r.actor === currentUser?.id && r.reaction === reaction)
|
||||
? "text-custom-primary-100"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{groupedReactions?.[reaction].length}{" "}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
@ -1,271 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import useSWR, { mutate } from "swr";
|
||||
import { MinusCircle } from "lucide-react";
|
||||
// hooks
|
||||
import { useApplication, useIssues, useProject, useProjectState, useUser, useWorkspace } from "hooks/store";
|
||||
import useToast from "hooks/use-toast";
|
||||
// services
|
||||
import { IssueService, IssueCommentService } from "services/issue";
|
||||
// components
|
||||
import {
|
||||
IssueAttachmentRoot,
|
||||
AddComment,
|
||||
IssueActivitySection,
|
||||
IssueDescriptionForm,
|
||||
IssueReaction,
|
||||
IssueUpdateStatus,
|
||||
} from "components/issues";
|
||||
import { useState } from "react";
|
||||
import { SubIssuesRoot } from "./sub-issues";
|
||||
// ui
|
||||
import { CustomMenu, LayersIcon, StateGroupIcon } from "@plane/ui";
|
||||
// types
|
||||
import { TIssue, IIssueActivity } from "@plane/types";
|
||||
// fetch-keys
|
||||
import { PROJECT_ISSUES_ACTIVITY, SUB_ISSUES } from "constants/fetch-keys";
|
||||
// constants
|
||||
import { EUserProjectRoles } from "constants/project";
|
||||
|
||||
type Props = {
|
||||
issueDetails: TIssue;
|
||||
submitChanges: (formData: Partial<TIssue>) => Promise<void>;
|
||||
uneditable?: boolean;
|
||||
};
|
||||
|
||||
// services
|
||||
const issueService = new IssueService();
|
||||
const issueCommentService = new IssueCommentService();
|
||||
|
||||
export const IssueMainContent: React.FC<Props> = observer((props) => {
|
||||
const { issueDetails, submitChanges, uneditable = false } = props;
|
||||
// states
|
||||
const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved");
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, issueId } = router.query;
|
||||
// toast alert
|
||||
const { setToastAlert } = useToast();
|
||||
const {
|
||||
eventTracker: { postHogEventTracker },
|
||||
} = useApplication();
|
||||
const {
|
||||
currentUser,
|
||||
membership: { currentProjectRole },
|
||||
} = useUser();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { getProjectById } = useProject();
|
||||
const { projectStates, getProjectStates } = useProjectState();
|
||||
const { issueMap } = useIssues();
|
||||
|
||||
const projectDetails = projectId ? getProjectById(projectId.toString()) : null;
|
||||
const currentIssueState = projectStates?.find((s) => s.id === issueDetails.state_id);
|
||||
|
||||
const { data: siblingIssues } = useSWR(
|
||||
workspaceSlug && projectId && issueDetails?.parent_id ? SUB_ISSUES(issueDetails.parent_id) : null,
|
||||
workspaceSlug && projectId && issueDetails?.parent_id
|
||||
? () => issueService.subIssues(workspaceSlug.toString(), projectId.toString(), issueDetails.parent_id ?? "")
|
||||
: null
|
||||
);
|
||||
const siblingIssuesList = siblingIssues?.sub_issues.filter((i) => i.id !== issueDetails.id);
|
||||
|
||||
const { data: issueActivity, mutate: mutateIssueActivity } = useSWR(
|
||||
workspaceSlug && projectId && issueId ? PROJECT_ISSUES_ACTIVITY(issueId.toString()) : null,
|
||||
workspaceSlug && projectId && issueId
|
||||
? () => issueService.getIssueActivities(workspaceSlug.toString(), projectId.toString(), issueId.toString())
|
||||
: null
|
||||
);
|
||||
|
||||
const handleCommentUpdate = async (commentId: string, data: Partial<IIssueActivity>) => {
|
||||
if (!workspaceSlug || !projectId || !issueId) return;
|
||||
|
||||
await issueCommentService
|
||||
.patchIssueComment(workspaceSlug as string, projectId as string, issueId as string, commentId, data)
|
||||
.then((res) => {
|
||||
mutateIssueActivity();
|
||||
postHogEventTracker(
|
||||
"COMMENT_UPDATED",
|
||||
{
|
||||
...res,
|
||||
state: "SUCCESS",
|
||||
},
|
||||
{
|
||||
isGrouping: true,
|
||||
groupType: "Workspace_metrics",
|
||||
groupId: currentWorkspace?.id!,
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const handleCommentDelete = async (commentId: string) => {
|
||||
if (!workspaceSlug || !projectId || !issueId || !currentUser) return;
|
||||
|
||||
mutateIssueActivity((prevData: any) => prevData?.filter((p: any) => p.id !== commentId), false);
|
||||
|
||||
await issueCommentService
|
||||
.deleteIssueComment(workspaceSlug as string, projectId as string, issueId as string, commentId)
|
||||
.then(() => {
|
||||
mutateIssueActivity();
|
||||
postHogEventTracker(
|
||||
"COMMENT_DELETED",
|
||||
{
|
||||
state: "SUCCESS",
|
||||
},
|
||||
{
|
||||
isGrouping: true,
|
||||
groupType: "Workspace_metrics",
|
||||
groupId: currentWorkspace?.id!,
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddComment = async (formData: IIssueActivity) => {
|
||||
if (!workspaceSlug || !issueDetails || !currentUser) return;
|
||||
|
||||
await issueCommentService
|
||||
.createIssueComment(workspaceSlug.toString(), issueDetails.project_id, issueDetails.id, formData)
|
||||
.then((res) => {
|
||||
mutate(PROJECT_ISSUES_ACTIVITY(issueDetails.id));
|
||||
postHogEventTracker(
|
||||
"COMMENT_ADDED",
|
||||
{
|
||||
...res,
|
||||
state: "SUCCESS",
|
||||
},
|
||||
{
|
||||
isGrouping: true,
|
||||
groupType: "Workspace_metrics",
|
||||
groupId: currentWorkspace?.id!,
|
||||
}
|
||||
);
|
||||
})
|
||||
.catch(() =>
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Comment could not be posted. Please try again.",
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const isAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
|
||||
|
||||
const parentDetail = issueMap?.[issueDetails.parent_id || ""] || undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-lg">
|
||||
{issueDetails?.parent_id && parentDetail ? (
|
||||
<div className="mb-5 flex w-min items-center gap-3 whitespace-nowrap rounded-md border border-custom-border-300 bg-custom-background-80 px-2.5 py-1 text-xs">
|
||||
<Link href={`/${workspaceSlug}/projects/${parentDetail?.project_id}/issues/${parentDetail.parent_id}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span
|
||||
className="block h-2 w-2 rounded-full"
|
||||
style={{
|
||||
backgroundColor: getProjectStates(parentDetail?.project_id)?.find(
|
||||
(state) => state?.id === parentDetail?.state_id
|
||||
)?.color,
|
||||
}}
|
||||
/>
|
||||
<span className="flex-shrink-0 text-custom-text-200">
|
||||
{getProjectById(parentDetail?.project_id)?.identifier}-{parentDetail?.sequence_id}
|
||||
</span>
|
||||
</div>
|
||||
<span className="truncate text-custom-text-100">{(parentDetail?.name ?? "").substring(0, 50)}</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<CustomMenu ellipsis optionsClassName="px-1.5">
|
||||
{siblingIssuesList ? (
|
||||
siblingIssuesList.length > 0 ? (
|
||||
<>
|
||||
<h2 className="mb-1 border-b border-custom-border-300 px-2 pb-1 text-xs font-medium text-custom-text-200">
|
||||
Sibling issues
|
||||
</h2>
|
||||
{siblingIssuesList.map((issue) => (
|
||||
<CustomMenu.MenuItem
|
||||
key={issue.id}
|
||||
onClick={() =>
|
||||
router.push(`/${workspaceSlug}/projects/${projectId as string}/issues/${issue.id}`)
|
||||
}
|
||||
className="flex items-center gap-2 py-2"
|
||||
>
|
||||
<LayersIcon className="h-4 w-4" />
|
||||
{getProjectById(issueDetails?.project_id)?.identifier}-{issue.sequence_id}
|
||||
</CustomMenu.MenuItem>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<p className="flex items-center gap-2 whitespace-nowrap px-1 py-1 text-left text-xs text-custom-text-200">
|
||||
No sibling issues
|
||||
</p>
|
||||
)
|
||||
) : null}
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => submitChanges({ parent_id: null })}
|
||||
className="flex items-center gap-2 py-2 text-red-500"
|
||||
>
|
||||
<MinusCircle className="h-4 w-4" />
|
||||
<span> Remove Parent Issue</span>
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mb-2.5 flex items-center">
|
||||
{currentIssueState && (
|
||||
<StateGroupIcon
|
||||
className="mr-3 h-4 w-4"
|
||||
stateGroup={currentIssueState.group}
|
||||
color={currentIssueState.color}
|
||||
/>
|
||||
)}
|
||||
<IssueUpdateStatus isSubmitting={isSubmitting} issueDetail={issueDetails} />
|
||||
</div>
|
||||
|
||||
<IssueDescriptionForm
|
||||
setIsSubmitting={(value) => setIsSubmitting(value)}
|
||||
isSubmitting={isSubmitting}
|
||||
workspaceSlug={workspaceSlug as string}
|
||||
issue={issueDetails}
|
||||
handleFormSubmit={submitChanges}
|
||||
isAllowed={isAllowed || !uneditable}
|
||||
/>
|
||||
|
||||
{workspaceSlug && projectId && (
|
||||
<IssueReaction
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
issueId={issueDetails.id}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mt-2 space-y-2">
|
||||
<SubIssuesRoot parentIssue={issueDetails} user={currentUser ?? undefined} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* issue attachments */}
|
||||
<IssueAttachmentRoot isEditable={uneditable} />
|
||||
|
||||
<div className="space-y-5 pt-3">
|
||||
<h3 className="text-lg text-custom-text-100">Comments/Activity</h3>
|
||||
<IssueActivitySection
|
||||
activity={issueActivity}
|
||||
handleCommentUpdate={handleCommentUpdate}
|
||||
handleCommentDelete={handleCommentDelete}
|
||||
showAccessSpecifier={Boolean(projectDetails && projectDetails.is_deployed)}
|
||||
/>
|
||||
<AddComment
|
||||
onSubmit={handleAddComment}
|
||||
disabled={uneditable}
|
||||
showAccessSpecifier={Boolean(projectDetails && projectDetails.is_deployed)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
@ -1,45 +1,35 @@
|
||||
import { FC, useState } from "react";
|
||||
import { FC, useMemo } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { CalendarDays, Link2, Plus, Signal, Tag, Triangle, LayoutPanelTop } from "lucide-react";
|
||||
import { CalendarDays, Signal, Tag, Triangle, LayoutPanelTop } from "lucide-react";
|
||||
// hooks
|
||||
import { useIssueDetail, useProject, useUser } from "hooks/store";
|
||||
// ui icons
|
||||
import { DiceIcon, DoubleCircleIcon, UserGroupIcon, ContrastIcon } from "@plane/ui";
|
||||
import {
|
||||
IssueLinkRoot,
|
||||
SidebarCycleSelect,
|
||||
SidebarLabelSelect,
|
||||
SidebarModuleSelect,
|
||||
SidebarParentSelect,
|
||||
} from "components/issues";
|
||||
import { IssueLinkRoot, IssueCycleSelect, IssueModuleSelect, IssueParentSelect, IssueLabel } from "components/issues";
|
||||
import { EstimateDropdown, PriorityDropdown, ProjectMemberDropdown, StateDropdown } from "components/dropdowns";
|
||||
// components
|
||||
import { CustomDatePicker } from "components/ui";
|
||||
import { LinkModal } from "components/core";
|
||||
// types
|
||||
import { TIssue, TIssuePriorities, ILinkDetails, IIssueLink } from "@plane/types";
|
||||
import { TIssue, TIssuePriorities } from "@plane/types";
|
||||
// constants
|
||||
import { EUserProjectRoles } from "constants/project";
|
||||
|
||||
interface IPeekOverviewProperties {
|
||||
issue: TIssue;
|
||||
issueUpdate: (issue: Partial<TIssue>) => void;
|
||||
issueLinkCreate: (data: IIssueLink) => Promise<ILinkDetails>;
|
||||
issueLinkUpdate: (data: IIssueLink, linkId: string) => Promise<ILinkDetails>;
|
||||
issueLinkDelete: (linkId: string) => Promise<void>;
|
||||
disableUserActions: boolean;
|
||||
issueOperations: any;
|
||||
}
|
||||
|
||||
export const PeekOverviewProperties: FC<IPeekOverviewProperties> = observer((props) => {
|
||||
const { issue, issueUpdate, issueLinkCreate, issueLinkUpdate, issueLinkDelete, disableUserActions } = props;
|
||||
// states
|
||||
const [selectedLinkToUpdate, setSelectedLinkToUpdate] = useState<ILinkDetails | null>(null);
|
||||
const { issue, issueUpdate, disableUserActions, issueOperations } = props;
|
||||
|
||||
// store hooks
|
||||
const {
|
||||
membership: { currentProjectRole },
|
||||
} = useUser();
|
||||
const { fetchIssue, isIssueLinkModalOpen, toggleIssueLinkModal } = useIssueDetail();
|
||||
const { currentUser } = useUser();
|
||||
const { getProjectById } = useProject();
|
||||
// router
|
||||
const router = useRouter();
|
||||
@ -66,23 +56,6 @@ export const PeekOverviewProperties: FC<IPeekOverviewProperties> = observer((pro
|
||||
const handleTargetDate = (_targetDate: string | null) => {
|
||||
issueUpdate({ ...issue, target_date: _targetDate || undefined });
|
||||
};
|
||||
const handleParent = (_parent: string) => {
|
||||
issueUpdate({ ...issue, parent_id: _parent });
|
||||
};
|
||||
const handleLabels = (formData: Partial<TIssue>) => {
|
||||
issueUpdate({ ...issue, ...formData });
|
||||
};
|
||||
|
||||
const handleCycleOrModuleChange = async () => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
await fetchIssue(workspaceSlug.toString(), projectId.toString(), issue.id);
|
||||
};
|
||||
|
||||
const handleEditLink = (link: ILinkDetails) => {
|
||||
setSelectedLinkToUpdate(link);
|
||||
toggleIssueLinkModal(true);
|
||||
};
|
||||
|
||||
const projectDetails = getProjectById(issue.project_id);
|
||||
const isEstimateEnabled = projectDetails?.estimate;
|
||||
@ -95,17 +68,6 @@ export const PeekOverviewProperties: FC<IPeekOverviewProperties> = observer((pro
|
||||
|
||||
return (
|
||||
<>
|
||||
<LinkModal
|
||||
isOpen={isIssueLinkModalOpen}
|
||||
handleClose={() => {
|
||||
toggleIssueLinkModal(false);
|
||||
setSelectedLinkToUpdate(null);
|
||||
}}
|
||||
data={selectedLinkToUpdate}
|
||||
status={selectedLinkToUpdate ? true : false}
|
||||
createIssueLink={issueLinkCreate}
|
||||
updateIssueLink={issueLinkUpdate}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex w-full flex-col gap-5 py-5">
|
||||
{/* state */}
|
||||
@ -223,7 +185,13 @@ export const PeekOverviewProperties: FC<IPeekOverviewProperties> = observer((pro
|
||||
<p>Parent</p>
|
||||
</div>
|
||||
<div>
|
||||
<SidebarParentSelect onChange={handleParent} issueDetails={issue} disabled={disableUserActions} />
|
||||
<IssueParentSelect
|
||||
workspaceSlug={workspaceSlug?.toString() ?? ""}
|
||||
projectId={projectId?.toString() ?? ""}
|
||||
issueId={issue?.id}
|
||||
issueOperations={issueOperations}
|
||||
disabled={disableUserActions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -238,10 +206,12 @@ export const PeekOverviewProperties: FC<IPeekOverviewProperties> = observer((pro
|
||||
<p>Cycle</p>
|
||||
</div>
|
||||
<div>
|
||||
<SidebarCycleSelect
|
||||
issueDetail={issue}
|
||||
<IssueCycleSelect
|
||||
workspaceSlug={workspaceSlug?.toString() ?? ""}
|
||||
projectId={projectId?.toString() ?? ""}
|
||||
issueId={issue?.id}
|
||||
issueOperations={issueOperations}
|
||||
disabled={disableUserActions}
|
||||
handleIssueUpdate={handleCycleOrModuleChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -254,10 +224,12 @@ export const PeekOverviewProperties: FC<IPeekOverviewProperties> = observer((pro
|
||||
<p>Module</p>
|
||||
</div>
|
||||
<div>
|
||||
<SidebarModuleSelect
|
||||
issueDetail={issue}
|
||||
<IssueModuleSelect
|
||||
workspaceSlug={workspaceSlug?.toString() ?? ""}
|
||||
projectId={projectId?.toString() ?? ""}
|
||||
issueId={issue?.id}
|
||||
issueOperations={issueOperations}
|
||||
disabled={disableUserActions}
|
||||
handleIssueUpdate={handleCycleOrModuleChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -269,12 +241,11 @@ export const PeekOverviewProperties: FC<IPeekOverviewProperties> = observer((pro
|
||||
<p>Label</p>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<SidebarLabelSelect
|
||||
issueDetails={issue}
|
||||
labelList={issue.label_ids}
|
||||
submitChanges={handleLabels}
|
||||
isNotAllowed={disableUserActions}
|
||||
uneditable={disableUserActions}
|
||||
<IssueLabel
|
||||
workspaceSlug={workspaceSlug?.toString() ?? ""}
|
||||
projectId={projectId?.toString() ?? ""}
|
||||
issueId={issue?.id}
|
||||
disabled={uneditable}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -282,10 +253,14 @@ export const PeekOverviewProperties: FC<IPeekOverviewProperties> = observer((pro
|
||||
|
||||
<span className="border-t border-custom-border-200" />
|
||||
|
||||
<div className="flex w-full flex-col gap-5 pt-5">
|
||||
<div className="flex flex-col gap-3">
|
||||
<IssueLinkRoot uneditable={uneditable} isAllowed={isAllowed} />
|
||||
</div>
|
||||
<div className="w-full pt-3">
|
||||
<IssueLinkRoot
|
||||
workspaceSlug={workspaceSlug?.toString() ?? ""}
|
||||
projectId={projectId?.toString() ?? ""}
|
||||
issueId={issue?.id}
|
||||
is_editable={uneditable}
|
||||
is_archived={isAllowed}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
@ -1,16 +1,16 @@
|
||||
import { FC, Fragment, useEffect, useState } from "react";
|
||||
import { FC, Fragment, useEffect, useState, useMemo } from "react";
|
||||
// router
|
||||
import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
import { useIssueDetail, useIssues, useProject, useUser } from "hooks/store";
|
||||
import { useIssueDetail, useIssues, useMember, useProject, useUser } from "hooks/store";
|
||||
// components
|
||||
import { IssueView } from "components/issues";
|
||||
// helpers
|
||||
import { copyUrlToClipboard } from "helpers/string.helper";
|
||||
// types
|
||||
import { TIssue, IIssueLink } from "@plane/types";
|
||||
import { TIssue } from "@plane/types";
|
||||
// constants
|
||||
import { EUserProjectRoles } from "constants/project";
|
||||
import { EIssuesStoreType } from "constants/issue";
|
||||
@ -19,14 +19,25 @@ interface IIssuePeekOverview {
|
||||
isArchived?: boolean;
|
||||
}
|
||||
|
||||
export type TIssuePeekOperations = {
|
||||
addIssueToCycle: (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => Promise<void>;
|
||||
removeIssueFromCycle: (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => Promise<void>;
|
||||
addIssueToModule: (workspaceSlug: string, projectId: string, moduleId: string, issueIds: string[]) => Promise<void>;
|
||||
removeIssueFromModule: (workspaceSlug: string, projectId: string, moduleId: string, issueId: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
|
||||
const { isArchived = false } = props;
|
||||
// router
|
||||
const router = useRouter();
|
||||
// hooks
|
||||
const {
|
||||
project: {},
|
||||
} = useMember();
|
||||
const { currentProjectDetails } = useProject();
|
||||
const { setToastAlert } = useToast();
|
||||
const {
|
||||
currentUser,
|
||||
membership: { currentProjectRole },
|
||||
} = useUser();
|
||||
const {
|
||||
@ -45,12 +56,10 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
|
||||
removeReaction,
|
||||
createSubscription,
|
||||
removeSubscription,
|
||||
createLink,
|
||||
updateLink,
|
||||
removeLink,
|
||||
issue: { getIssueById, fetchIssue },
|
||||
fetchActivities,
|
||||
} = useIssueDetail();
|
||||
const { addIssueToCycle, removeIssueFromCycle, addIssueToModule, removeIssueFromModule } = useIssueDetail();
|
||||
// state
|
||||
const [loader, setLoader] = useState(false);
|
||||
|
||||
@ -62,8 +71,7 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
|
||||
});
|
||||
}
|
||||
}, [peekIssue, fetchIssue]);
|
||||
|
||||
if (!peekIssue) return <></>;
|
||||
if (!peekIssue?.workspaceSlug || !peekIssue?.projectId || !peekIssue?.issueId) return <></>;
|
||||
|
||||
const issue = getIssueById(peekIssue.issueId) || undefined;
|
||||
|
||||
@ -90,6 +98,76 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
|
||||
});
|
||||
};
|
||||
|
||||
const issueOperations: TIssuePeekOperations = useMemo(
|
||||
() => ({
|
||||
addIssueToCycle: async (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => {
|
||||
try {
|
||||
await addIssueToCycle(workspaceSlug, projectId, cycleId, issueIds);
|
||||
setToastAlert({
|
||||
title: "Cycle added to issue successfully",
|
||||
type: "success",
|
||||
message: "Issue added to issue successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
setToastAlert({
|
||||
title: "Cycle add to issue failed",
|
||||
type: "error",
|
||||
message: "Cycle add to issue failed",
|
||||
});
|
||||
}
|
||||
},
|
||||
removeIssueFromCycle: async (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => {
|
||||
try {
|
||||
await removeIssueFromCycle(workspaceSlug, projectId, cycleId, issueId);
|
||||
setToastAlert({
|
||||
title: "Cycle removed from issue successfully",
|
||||
type: "success",
|
||||
message: "Cycle removed from issue successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
setToastAlert({
|
||||
title: "Cycle remove from issue failed",
|
||||
type: "error",
|
||||
message: "Cycle remove from issue failed",
|
||||
});
|
||||
}
|
||||
},
|
||||
addIssueToModule: async (workspaceSlug: string, projectId: string, moduleId: string, issueIds: string[]) => {
|
||||
try {
|
||||
await addIssueToModule(workspaceSlug, projectId, moduleId, issueIds);
|
||||
setToastAlert({
|
||||
title: "Module added to issue successfully",
|
||||
type: "success",
|
||||
message: "Module added to issue successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
setToastAlert({
|
||||
title: "Module add to issue failed",
|
||||
type: "error",
|
||||
message: "Module add to issue failed",
|
||||
});
|
||||
}
|
||||
},
|
||||
removeIssueFromModule: async (workspaceSlug: string, projectId: string, moduleId: string, issueId: string) => {
|
||||
try {
|
||||
await removeIssueFromModule(workspaceSlug, projectId, moduleId, issueId);
|
||||
setToastAlert({
|
||||
title: "Module removed from issue successfully",
|
||||
type: "success",
|
||||
message: "Module removed from issue successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
setToastAlert({
|
||||
title: "Module remove from issue failed",
|
||||
type: "error",
|
||||
message: "Module remove from issue failed",
|
||||
});
|
||||
}
|
||||
},
|
||||
}),
|
||||
[addIssueToCycle, removeIssueFromCycle, addIssueToModule, removeIssueFromModule, setToastAlert]
|
||||
);
|
||||
|
||||
const issueUpdate = async (_data: Partial<TIssue>) => {
|
||||
if (!issue) return;
|
||||
await updateIssue(peekIssue.workspaceSlug, peekIssue.projectId, peekIssue.issueId, _data);
|
||||
@ -104,7 +182,9 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
|
||||
const issueReactionCreate = (reaction: string) =>
|
||||
createReaction(peekIssue.workspaceSlug, peekIssue.projectId, peekIssue.issueId, reaction);
|
||||
const issueReactionRemove = (reaction: string) =>
|
||||
removeReaction(peekIssue.workspaceSlug, peekIssue.projectId, peekIssue.issueId, reaction);
|
||||
currentUser &&
|
||||
currentUser.id &&
|
||||
removeReaction(peekIssue.workspaceSlug, peekIssue.projectId, peekIssue.issueId, reaction, currentUser.id);
|
||||
|
||||
const issueCommentCreate = (comment: any) =>
|
||||
createComment(peekIssue.workspaceSlug, peekIssue.projectId, peekIssue.issueId, comment);
|
||||
@ -123,48 +203,35 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
|
||||
const issueSubscriptionRemove = () =>
|
||||
removeSubscription(peekIssue.workspaceSlug, peekIssue.projectId, peekIssue.issueId);
|
||||
|
||||
const issueLinkCreate = (formData: IIssueLink) =>
|
||||
createLink(peekIssue.workspaceSlug, peekIssue.projectId, peekIssue.issueId, formData);
|
||||
const issueLinkUpdate = (formData: IIssueLink, linkId: string) =>
|
||||
updateLink(peekIssue.workspaceSlug, peekIssue.projectId, peekIssue.issueId, linkId, formData);
|
||||
const issueLinkDelete = (linkId: string) =>
|
||||
removeLink(peekIssue.workspaceSlug, peekIssue.projectId, peekIssue.issueId, linkId);
|
||||
|
||||
const userRole = currentProjectRole ?? EUserProjectRoles.GUEST;
|
||||
const isLoading = !issue || loader ? true : false;
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{isLoading ? (
|
||||
<></> // TODO: show the spinner
|
||||
) : (
|
||||
<IssueView
|
||||
workspaceSlug={peekIssue.workspaceSlug}
|
||||
projectId={peekIssue.projectId}
|
||||
issueId={peekIssue.issueId}
|
||||
issue={issue}
|
||||
isLoading={isLoading}
|
||||
isArchived={isArchived}
|
||||
handleCopyText={handleCopyText}
|
||||
redirectToIssueDetail={redirectToIssueDetail}
|
||||
issueUpdate={issueUpdate}
|
||||
issueReactionCreate={issueReactionCreate}
|
||||
issueReactionRemove={issueReactionRemove}
|
||||
issueCommentCreate={issueCommentCreate}
|
||||
issueCommentUpdate={issueCommentUpdate}
|
||||
issueCommentRemove={issueCommentRemove}
|
||||
issueCommentReactionCreate={issueCommentReactionCreate}
|
||||
issueCommentReactionRemove={issueCommentReactionRemove}
|
||||
issueSubscriptionCreate={issueSubscriptionCreate}
|
||||
issueSubscriptionRemove={issueSubscriptionRemove}
|
||||
issueLinkCreate={issueLinkCreate}
|
||||
issueLinkUpdate={issueLinkUpdate}
|
||||
issueLinkDelete={issueLinkDelete}
|
||||
handleDeleteIssue={issueDelete}
|
||||
disableUserActions={[5, 10].includes(userRole)}
|
||||
showCommentAccessSpecifier={currentProjectDetails?.is_deployed}
|
||||
/>
|
||||
)}
|
||||
<IssueView
|
||||
workspaceSlug={peekIssue.workspaceSlug}
|
||||
projectId={peekIssue.projectId}
|
||||
issueId={peekIssue.issueId}
|
||||
isLoading={isLoading}
|
||||
isArchived={isArchived}
|
||||
issue={issue}
|
||||
handleCopyText={handleCopyText}
|
||||
redirectToIssueDetail={redirectToIssueDetail}
|
||||
issueUpdate={issueUpdate}
|
||||
issueDelete={issueDelete}
|
||||
issueReactionCreate={issueReactionCreate}
|
||||
issueReactionRemove={issueReactionRemove}
|
||||
issueCommentCreate={issueCommentCreate}
|
||||
issueCommentUpdate={issueCommentUpdate}
|
||||
issueCommentRemove={issueCommentRemove}
|
||||
issueCommentReactionCreate={issueCommentReactionCreate}
|
||||
issueCommentReactionRemove={issueCommentReactionRemove}
|
||||
issueSubscriptionCreate={issueSubscriptionCreate}
|
||||
issueSubscriptionRemove={issueSubscriptionRemove}
|
||||
disableUserActions={[5, 10].includes(userRole)}
|
||||
showCommentAccessSpecifier={currentProjectDetails?.is_deployed}
|
||||
issueOperations={issueOperations}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
});
|
||||
|
@ -22,12 +22,18 @@ interface IIssueView {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
issue: TIssue | undefined;
|
||||
|
||||
isLoading?: boolean;
|
||||
isArchived?: boolean;
|
||||
|
||||
issue: TIssue | undefined;
|
||||
|
||||
handleCopyText: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
redirectToIssueDetail: () => void;
|
||||
|
||||
issueUpdate: (issue: Partial<TIssue>) => void;
|
||||
issueDelete: () => Promise<void>;
|
||||
|
||||
issueReactionCreate: (reaction: string) => void;
|
||||
issueReactionRemove: (reaction: string) => void;
|
||||
issueCommentCreate: (comment: any) => void;
|
||||
@ -37,12 +43,11 @@ interface IIssueView {
|
||||
issueCommentReactionRemove: (commentId: string, reaction: string) => void;
|
||||
issueSubscriptionCreate: () => void;
|
||||
issueSubscriptionRemove: () => void;
|
||||
issueLinkCreate: (formData: IIssueLink) => Promise<ILinkDetails>;
|
||||
issueLinkUpdate: (formData: IIssueLink, linkId: string) => Promise<ILinkDetails>;
|
||||
issueLinkDelete: (linkId: string) => Promise<void>;
|
||||
handleDeleteIssue: () => Promise<void>;
|
||||
|
||||
disableUserActions?: boolean;
|
||||
showCommentAccessSpecifier?: boolean;
|
||||
|
||||
issueOperations: any;
|
||||
}
|
||||
|
||||
type TPeekModes = "side-peek" | "modal" | "full-screen";
|
||||
@ -75,6 +80,7 @@ export const IssueView: FC<IIssueView> = observer((props) => {
|
||||
isArchived,
|
||||
handleCopyText,
|
||||
redirectToIssueDetail,
|
||||
|
||||
issueUpdate,
|
||||
issueReactionCreate,
|
||||
issueReactionRemove,
|
||||
@ -85,12 +91,12 @@ export const IssueView: FC<IIssueView> = observer((props) => {
|
||||
issueCommentReactionRemove,
|
||||
issueSubscriptionCreate,
|
||||
issueSubscriptionRemove,
|
||||
issueLinkCreate,
|
||||
issueLinkUpdate,
|
||||
issueLinkDelete,
|
||||
handleDeleteIssue,
|
||||
|
||||
issueDelete,
|
||||
disableUserActions = false,
|
||||
showCommentAccessSpecifier = false,
|
||||
|
||||
issueOperations,
|
||||
} = props;
|
||||
// states
|
||||
const [peekMode, setPeekMode] = useState<TPeekModes>("side-peek");
|
||||
@ -109,7 +115,9 @@ export const IssueView: FC<IIssueView> = observer((props) => {
|
||||
} = useIssueDetail();
|
||||
const { currentUser } = useUser();
|
||||
|
||||
const removeRoutePeekId = () => setPeekIssue(undefined);
|
||||
const removeRoutePeekId = () => {
|
||||
setPeekIssue(undefined);
|
||||
};
|
||||
|
||||
const issueReactions = reaction.getReactionsByIssueId(issueId) || [];
|
||||
const issueActivity = activity.getActivitiesByIssueId(issueId);
|
||||
@ -126,7 +134,7 @@ export const IssueView: FC<IIssueView> = observer((props) => {
|
||||
isOpen={isDeleteIssueModalOpen}
|
||||
handleClose={() => toggleDeleteIssueModal(false)}
|
||||
data={issue}
|
||||
onSubmit={handleDeleteIssue}
|
||||
onSubmit={issueDelete}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -135,7 +143,7 @@ export const IssueView: FC<IIssueView> = observer((props) => {
|
||||
data={issue}
|
||||
isOpen={isDeleteIssueModalOpen}
|
||||
handleClose={() => toggleDeleteIssueModal(false)}
|
||||
onSubmit={handleDeleteIssue}
|
||||
onSubmit={issueDelete}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -257,10 +265,8 @@ export const IssueView: FC<IIssueView> = observer((props) => {
|
||||
<PeekOverviewProperties
|
||||
issue={issue}
|
||||
issueUpdate={issueUpdate}
|
||||
issueLinkCreate={issueLinkCreate}
|
||||
issueLinkUpdate={issueLinkUpdate}
|
||||
issueLinkDelete={issueLinkDelete}
|
||||
disableUserActions={disableUserActions}
|
||||
issueOperations={issueOperations}
|
||||
/>
|
||||
|
||||
<IssueActivity
|
||||
@ -316,10 +322,8 @@ export const IssueView: FC<IIssueView> = observer((props) => {
|
||||
<PeekOverviewProperties
|
||||
issue={issue}
|
||||
issueUpdate={issueUpdate}
|
||||
issueLinkCreate={issueLinkCreate}
|
||||
issueLinkUpdate={issueLinkUpdate}
|
||||
issueLinkDelete={issueLinkDelete}
|
||||
disableUserActions={disableUserActions}
|
||||
issueOperations={issueOperations}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -101,8 +101,7 @@ export const IssueLabelSelect: React.FC<Props> = observer((props) => {
|
||||
{isDropdownOpen && (
|
||||
<Combobox.Options className="fixed z-10" static>
|
||||
<div
|
||||
className="my-1 w-48 rounded border-[0.5px] border-custom-border-300
|
||||
bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none"
|
||||
className="my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none"
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
|
@ -1,135 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import useSWR, { mutate } from "swr";
|
||||
// hooks
|
||||
import { useCycle, useIssues } from "hooks/store";
|
||||
// services
|
||||
import { CycleService } from "services/cycle.service";
|
||||
// ui
|
||||
import { ContrastIcon, CustomSearchSelect, Spinner, Tooltip } from "@plane/ui";
|
||||
// types
|
||||
import { TIssue } from "@plane/types";
|
||||
// fetch-keys
|
||||
import { CYCLE_ISSUES, INCOMPLETE_CYCLES_LIST, ISSUE_DETAILS } from "constants/fetch-keys";
|
||||
import { EIssuesStoreType } from "constants/issue";
|
||||
|
||||
type Props = {
|
||||
issueDetail: TIssue | undefined;
|
||||
handleCycleChange?: (cycleId: string) => void;
|
||||
disabled?: boolean;
|
||||
handleIssueUpdate?: () => void;
|
||||
};
|
||||
|
||||
// services
|
||||
const cycleService = new CycleService();
|
||||
|
||||
export const SidebarCycleSelect: React.FC<Props> = (props) => {
|
||||
const { issueDetail, disabled = false, handleIssueUpdate, handleCycleChange } = props;
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
// mobx store
|
||||
const {
|
||||
issues: { removeIssueFromCycle, addIssueToCycle },
|
||||
} = useIssues(EIssuesStoreType.CYCLE);
|
||||
const { getCycleById } = useCycle();
|
||||
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
const { data: incompleteCycles } = useSWR(
|
||||
workspaceSlug && projectId ? INCOMPLETE_CYCLES_LIST(projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => cycleService.getCyclesWithParams(workspaceSlug as string, projectId as string) // FIXME, "incomplete")
|
||||
: null
|
||||
);
|
||||
|
||||
const handleCycleStoreChange = async (cycleId: string) => {
|
||||
if (!workspaceSlug || !issueDetail || !cycleId || !projectId) return;
|
||||
|
||||
setIsUpdating(true);
|
||||
await addIssueToCycle(workspaceSlug.toString(), projectId?.toString(), cycleId, [issueDetail.id])
|
||||
.then(async () => {
|
||||
handleIssueUpdate && (await handleIssueUpdate());
|
||||
})
|
||||
.finally(() => {
|
||||
setIsUpdating(false);
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveIssueFromCycle = (cycleId: string) => {
|
||||
if (!workspaceSlug || !projectId || !issueDetail) return;
|
||||
|
||||
setIsUpdating(true);
|
||||
removeIssueFromCycle(workspaceSlug.toString(), projectId.toString(), cycleId, issueDetail.id)
|
||||
.then(async () => {
|
||||
handleIssueUpdate && (await handleIssueUpdate());
|
||||
mutate(ISSUE_DETAILS(issueDetail.id));
|
||||
|
||||
mutate(CYCLE_ISSUES(cycleId));
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsUpdating(false);
|
||||
});
|
||||
};
|
||||
|
||||
const options = incompleteCycles?.map((cycle) => ({
|
||||
value: cycle.id,
|
||||
query: cycle.name,
|
||||
content: (
|
||||
<div className="flex items-center gap-1.5 truncate">
|
||||
<span className="flex h-3.5 w-3.5 flex-shrink-0 items-center justify-center">
|
||||
<ContrastIcon />
|
||||
</span>
|
||||
<span className="flex-grow truncate">{cycle.name}</span>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const issueCycle = (issueDetail && issueDetail.cycle_id && getCycleById(issueDetail.cycle_id)) || undefined;
|
||||
|
||||
const disableSelect = disabled || isUpdating;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<CustomSearchSelect
|
||||
value={issueDetail?.cycle_id}
|
||||
onChange={(value: any) => {
|
||||
value === issueDetail?.cycle_id
|
||||
? handleRemoveIssueFromCycle(issueDetail?.cycle_id ?? "")
|
||||
: handleCycleChange
|
||||
? handleCycleChange(value)
|
||||
: handleCycleStoreChange(value);
|
||||
}}
|
||||
options={options}
|
||||
customButton={
|
||||
<div>
|
||||
<Tooltip position="left" tooltipContent={`${issueCycle ? issueCycle?.name : "No cycle"}`}>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex w-full items-center rounded bg-custom-background-80 px-2.5 py-0.5 text-xs ${
|
||||
disableSelect ? "cursor-not-allowed" : ""
|
||||
} max-w-[10rem]`}
|
||||
>
|
||||
<span
|
||||
className={`flex items-center gap-1.5 truncate ${
|
||||
issueCycle ? "text-custom-text-100" : "text-custom-text-200"
|
||||
}`}
|
||||
>
|
||||
<span className="flex-shrink-0">{issueCycle && <ContrastIcon className="h-3.5 w-3.5" />}</span>
|
||||
<span className="truncate">{issueCycle ? issueCycle?.name : "No cycle"}</span>
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
}
|
||||
width="max-w-[10rem]"
|
||||
noChevron
|
||||
disabled={disableSelect}
|
||||
/>
|
||||
{isUpdating && <Spinner className="h-4 w-4" />}
|
||||
</div>
|
||||
);
|
||||
};
|
@ -1,5 +0,0 @@
|
||||
export * from "./relation";
|
||||
export * from "./cycle";
|
||||
export * from "./label";
|
||||
export * from "./module";
|
||||
export * from "./parent";
|
@ -1,225 +0,0 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { TwitterPicker } from "react-color";
|
||||
import { Popover, Transition } from "@headlessui/react";
|
||||
import { Plus, X } from "lucide-react";
|
||||
// hooks
|
||||
import { useLabel } from "hooks/store";
|
||||
import useToast from "hooks/use-toast";
|
||||
// ui
|
||||
import { Input } from "@plane/ui";
|
||||
import { IssueLabelSelect } from "../select";
|
||||
// types
|
||||
import { TIssue, IIssueLabel } from "@plane/types";
|
||||
|
||||
type Props = {
|
||||
issueDetails: TIssue | undefined;
|
||||
labelList: string[];
|
||||
submitChanges: (formData: any) => void;
|
||||
isNotAllowed: boolean;
|
||||
uneditable: boolean;
|
||||
};
|
||||
|
||||
const defaultValues: Partial<IIssueLabel> = {
|
||||
name: "",
|
||||
color: "#ff0000",
|
||||
};
|
||||
|
||||
export const SidebarLabelSelect: React.FC<Props> = observer((props) => {
|
||||
const { issueDetails, labelList, submitChanges, isNotAllowed, uneditable } = props;
|
||||
// states
|
||||
const [createLabelForm, setCreateLabelForm] = useState(false);
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
// toast
|
||||
const { setToastAlert } = useToast();
|
||||
// mobx store
|
||||
const {
|
||||
project: { projectLabels, createLabel },
|
||||
} = useLabel();
|
||||
// form info
|
||||
const {
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
reset,
|
||||
control,
|
||||
setFocus,
|
||||
} = useForm<Partial<IIssueLabel>>({
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const handleNewLabel = async (formData: Partial<IIssueLabel>) => {
|
||||
if (!workspaceSlug || !projectId || isSubmitting) return;
|
||||
|
||||
await createLabel(workspaceSlug.toString(), projectId.toString(), formData)
|
||||
.then((res) => {
|
||||
reset(defaultValues);
|
||||
|
||||
submitChanges({ labels: [...(issueDetails?.label_ids ?? []), res.id] });
|
||||
|
||||
setCreateLabelForm(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: error?.error ?? "Something went wrong. Please try again.",
|
||||
});
|
||||
reset(formData);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!createLabelForm) return;
|
||||
|
||||
setFocus("name");
|
||||
reset();
|
||||
}, [createLabelForm, reset, setFocus]);
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col gap-3 ${uneditable ? "opacity-60" : ""}`}>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{labelList?.map((labelId) => {
|
||||
const label = projectLabels?.find((l) => l.id === labelId);
|
||||
|
||||
if (label)
|
||||
return (
|
||||
<button
|
||||
key={label.id}
|
||||
className={`group flex cursor-pointer items-center gap-1 rounded-2xl border border-custom-border-100 px-1 py-0.5 text-xs ${
|
||||
isNotAllowed || uneditable ? "!cursor-not-allowed" : "hover:border-red-500/20 hover:bg-red-500/20"
|
||||
}`}
|
||||
onClick={() => {
|
||||
const updatedLabels = labelList?.filter((l) => l !== labelId);
|
||||
submitChanges({
|
||||
labels: updatedLabels,
|
||||
});
|
||||
}}
|
||||
disabled={uneditable}
|
||||
>
|
||||
<span
|
||||
className="h-2 w-2 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: label.color ?? "#000000",
|
||||
}}
|
||||
/>
|
||||
{label.name}
|
||||
<X className="h-2 w-2 group-hover:text-red-500" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<IssueLabelSelect
|
||||
setIsOpen={setCreateLabelForm}
|
||||
value={issueDetails?.label_ids ?? []}
|
||||
onChange={(val: any) => submitChanges({ labels: val })}
|
||||
projectId={issueDetails?.project_id ?? ""}
|
||||
label={
|
||||
<span
|
||||
className={`flex ${
|
||||
isNotAllowed || uneditable ? "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-300 hover:text-custom-text-200`}
|
||||
>
|
||||
Select Label
|
||||
</span>
|
||||
}
|
||||
disabled={uneditable}
|
||||
/>
|
||||
{!isNotAllowed && (
|
||||
<button
|
||||
type="button"
|
||||
className={`flex ${
|
||||
isNotAllowed || uneditable ? "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-300 hover:text-custom-text-200`}
|
||||
onClick={() => setCreateLabelForm((prevData) => !prevData)}
|
||||
disabled={uneditable}
|
||||
>
|
||||
{createLabelForm ? (
|
||||
<>
|
||||
<X className="h-3 w-3" /> Cancel
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="h-3 w-3" /> New
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{createLabelForm && (
|
||||
<form className="flex items-center gap-x-2" onSubmit={handleSubmit(handleNewLabel)}>
|
||||
<div>
|
||||
<Controller
|
||||
name="color"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Popover className="relative">
|
||||
<>
|
||||
<Popover.Button className="grid place-items-center outline-none">
|
||||
{value && value?.trim() !== "" && (
|
||||
<span
|
||||
className="h-6 w-6 rounded"
|
||||
style={{
|
||||
backgroundColor: value ?? "black",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Popover.Button>
|
||||
|
||||
<Transition
|
||||
as={React.Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<Popover.Panel className="absolute z-10 mt-1.5 max-w-xs px-2 sm:px-0">
|
||||
<TwitterPicker color={value} onChange={(value) => onChange(value.hex)} />
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
</Popover>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
rules={{
|
||||
required: "This is required",
|
||||
}}
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
value={value ?? ""}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.name)}
|
||||
placeholder="Title"
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="grid place-items-center rounded bg-red-500 p-2.5"
|
||||
onClick={() => setCreateLabelForm(false)}
|
||||
disabled={uneditable}
|
||||
>
|
||||
<X className="h-4 w-4 text-white" />
|
||||
</button>
|
||||
<button type="submit" className="grid place-items-center rounded bg-green-500 p-2.5" disabled={isSubmitting}>
|
||||
<Plus className="h-4 w-4 text-white" />
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
@ -1,128 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { mutate } from "swr";
|
||||
// hooks
|
||||
import { useIssues, useModule } from "hooks/store";
|
||||
// ui
|
||||
import { CustomSearchSelect, DiceIcon, Spinner, Tooltip } from "@plane/ui";
|
||||
// types
|
||||
import { TIssue } from "@plane/types";
|
||||
// fetch-keys
|
||||
import { ISSUE_DETAILS, MODULE_ISSUES } from "constants/fetch-keys";
|
||||
import { EIssuesStoreType } from "constants/issue";
|
||||
|
||||
type Props = {
|
||||
issueDetail: TIssue | undefined;
|
||||
handleModuleChange?: (moduleId: string) => void;
|
||||
disabled?: boolean;
|
||||
handleIssueUpdate?: () => void;
|
||||
};
|
||||
|
||||
export const SidebarModuleSelect: React.FC<Props> = observer((props) => {
|
||||
const { issueDetail, disabled = false, handleIssueUpdate, handleModuleChange } = props;
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
// mobx store
|
||||
const {
|
||||
issues: { removeIssueFromModule, addIssueToModule },
|
||||
} = useIssues(EIssuesStoreType.MODULE);
|
||||
const { projectModuleIds, getModuleById } = useModule();
|
||||
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
const handleModuleStoreChange = async (moduleId: string) => {
|
||||
if (!workspaceSlug || !issueDetail || !moduleId || !projectId) return;
|
||||
|
||||
setIsUpdating(true);
|
||||
await addIssueToModule(workspaceSlug.toString(), projectId?.toString(), moduleId, [issueDetail.id])
|
||||
.then(async () => {
|
||||
handleIssueUpdate && (await handleIssueUpdate());
|
||||
})
|
||||
.finally(() => {
|
||||
setIsUpdating(false);
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveIssueFromModule = (bridgeId: string, moduleId: string) => {
|
||||
if (!workspaceSlug || !projectId || !issueDetail) return;
|
||||
|
||||
setIsUpdating(true);
|
||||
removeIssueFromModule(workspaceSlug.toString(), projectId.toString(), moduleId, issueDetail.id)
|
||||
.then(async () => {
|
||||
handleIssueUpdate && (await handleIssueUpdate());
|
||||
mutate(ISSUE_DETAILS(issueDetail.id));
|
||||
|
||||
mutate(MODULE_ISSUES(moduleId));
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsUpdating(false);
|
||||
});
|
||||
};
|
||||
|
||||
const options = projectModuleIds?.map((moduleId) => {
|
||||
const moduleDetail = getModuleById(moduleId);
|
||||
return {
|
||||
value: moduleId,
|
||||
query: moduleDetail?.name ?? "",
|
||||
content: (
|
||||
<div className="flex items-center gap-1.5 truncate">
|
||||
<span className="flex h-3.5 w-3.5 flex-shrink-0 items-center justify-center">
|
||||
<DiceIcon />
|
||||
</span>
|
||||
<span className="flex-grow truncate">{moduleDetail?.name}</span>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
// derived values
|
||||
const issueModule = (issueDetail && issueDetail?.module_id && getModuleById(issueDetail.module_id)) || undefined;
|
||||
|
||||
const disableSelect = disabled || isUpdating;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<CustomSearchSelect
|
||||
value={issueDetail?.module_id}
|
||||
onChange={(value: any) => {
|
||||
value === issueDetail?.module_id
|
||||
? handleRemoveIssueFromModule(issueModule?.id ?? "", issueDetail?.module_id ?? "")
|
||||
: handleModuleChange
|
||||
? handleModuleChange(value)
|
||||
: handleModuleStoreChange(value);
|
||||
}}
|
||||
options={options}
|
||||
customButton={
|
||||
<div>
|
||||
<Tooltip position="left" tooltipContent={`${issueModule?.name ?? "No module"}`}>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex w-full items-center rounded bg-custom-background-80 px-2.5 py-0.5 text-xs ${
|
||||
disableSelect ? "cursor-not-allowed" : ""
|
||||
} max-w-[10rem]`}
|
||||
>
|
||||
<span
|
||||
className={`flex items-center gap-1.5 truncate ${
|
||||
issueModule ? "text-custom-text-100" : "text-custom-text-200"
|
||||
}`}
|
||||
>
|
||||
<span className="flex-shrink-0">{issueModule && <DiceIcon className="h-3.5 w-3.5" />}</span>
|
||||
<span className="truncate">{issueModule?.name ?? "No module"}</span>
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
}
|
||||
width="max-w-[10rem]"
|
||||
noChevron
|
||||
disabled={disableSelect}
|
||||
/>
|
||||
{isUpdating && <Spinner className="h-4 w-4" />}
|
||||
</div>
|
||||
);
|
||||
});
|
@ -1,69 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
// hooks
|
||||
import { useIssueDetail, useIssues, useProject } from "hooks/store";
|
||||
// components
|
||||
import { ParentIssuesListModal } from "components/issues";
|
||||
// icons
|
||||
import { X } from "lucide-react";
|
||||
// types
|
||||
import { TIssue, ISearchIssueResponse } from "@plane/types";
|
||||
import { observer } from "mobx-react-lite";
|
||||
|
||||
type Props = {
|
||||
onChange: (value: string) => void;
|
||||
issueDetails: TIssue | undefined;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const SidebarParentSelect: React.FC<Props> = observer(({ onChange, issueDetails, disabled = false }) => {
|
||||
const [selectedParentIssue, setSelectedParentIssue] = useState<ISearchIssueResponse | null>(null);
|
||||
|
||||
const { isParentIssueModalOpen, toggleParentIssueModal } = useIssueDetail();
|
||||
|
||||
const router = useRouter();
|
||||
const { projectId, issueId } = router.query;
|
||||
|
||||
// hooks
|
||||
const { getProjectById } = useProject();
|
||||
const { issueMap } = useIssues();
|
||||
|
||||
return (
|
||||
<>
|
||||
<ParentIssuesListModal
|
||||
isOpen={isParentIssueModalOpen}
|
||||
handleClose={() => toggleParentIssueModal(false)}
|
||||
onChange={(issue) => {
|
||||
onChange(issue.id);
|
||||
setSelectedParentIssue(issue);
|
||||
}}
|
||||
issueId={issueId as string}
|
||||
projectId={projectId as string}
|
||||
/>
|
||||
<button
|
||||
className={`flex items-center gap-2 rounded bg-custom-background-80 px-2.5 py-0.5 text-xs w-max max-w-max" ${
|
||||
disabled ? "cursor-not-allowed" : "cursor-pointer "
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (issueDetails?.parent_id) {
|
||||
onChange("");
|
||||
setSelectedParentIssue(null);
|
||||
} else {
|
||||
toggleParentIssueModal(true);
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
{selectedParentIssue && issueDetails?.parent_id ? (
|
||||
`${selectedParentIssue.project__identifier}-${selectedParentIssue.sequence_id}`
|
||||
) : !selectedParentIssue && issueDetails?.parent_id ? (
|
||||
`${getProjectById(issueDetails.parent_id)?.identifier}-${issueMap[issueDetails.parent_id]?.sequence_id}`
|
||||
) : (
|
||||
<span className="text-custom-text-200">Select issue</span>
|
||||
)}
|
||||
{issueDetails?.parent_id && <X className="h-2.5 w-2.5" />}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
});
|
@ -7,7 +7,7 @@ import { IssueProperty } from "./properties";
|
||||
import { CustomMenu, Tooltip } from "@plane/ui";
|
||||
// types
|
||||
import { IUser, TIssue, TIssueSubIssues } from "@plane/types";
|
||||
import { ISubIssuesRootLoaders, ISubIssuesRootLoadersHandler } from "./root";
|
||||
// import { ISubIssuesRootLoaders, ISubIssuesRootLoadersHandler } from "./root";
|
||||
import { useIssueDetail, useProject, useProjectState } from "hooks/store";
|
||||
|
||||
export interface ISubIssues {
|
||||
@ -24,8 +24,8 @@ export interface ISubIssues {
|
||||
user: IUser | undefined;
|
||||
editable: boolean;
|
||||
removeIssueFromSubIssues: (parentIssueId: string, issue: TIssue) => void;
|
||||
issuesLoader: ISubIssuesRootLoaders;
|
||||
handleIssuesLoader: ({ key, issueId }: ISubIssuesRootLoadersHandler) => void;
|
||||
issuesLoader: any; // FIXME: ISubIssuesRootLoaders replace with any
|
||||
handleIssuesLoader: ({ key, issueId }: any) => void; // FIXME: ISubIssuesRootLoadersHandler replace with any
|
||||
copyText: (text: string) => void;
|
||||
handleIssueCrudOperation: (
|
||||
key: "create" | "existing" | "edit" | "delete",
|
||||
|
@ -3,7 +3,7 @@ import { useMemo } from "react";
|
||||
import { SubIssues } from "./issue";
|
||||
// types
|
||||
import { IUser, TIssue } from "@plane/types";
|
||||
import { ISubIssuesRootLoaders, ISubIssuesRootLoadersHandler } from "./root";
|
||||
// import { ISubIssuesRootLoaders, ISubIssuesRootLoadersHandler } from "./root";
|
||||
|
||||
// fetch keys
|
||||
import { useIssueDetail } from "hooks/store";
|
||||
@ -16,8 +16,8 @@ export interface ISubIssuesRootList {
|
||||
user: IUser | undefined;
|
||||
editable: boolean;
|
||||
removeIssueFromSubIssues: (parentIssueId: string, issue: TIssue) => void;
|
||||
issuesLoader: ISubIssuesRootLoaders;
|
||||
handleIssuesLoader: ({ key, issueId }: ISubIssuesRootLoadersHandler) => void;
|
||||
issuesLoader: any; // FIXME: replace ISubIssuesRootLoaders with any
|
||||
handleIssuesLoader: ({ key, issueId }: any) => void; // FIXME: replace ISubIssuesRootLoadersHandler with any
|
||||
copyText: (text: string) => void;
|
||||
handleIssueCrudOperation: (
|
||||
key: "create" | "existing" | "edit" | "delete",
|
||||
|
@ -1,10 +1,8 @@
|
||||
import React, { useCallback } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import useSWR, { mutate } from "swr";
|
||||
import { Plus, ChevronRight, ChevronDown } from "lucide-react";
|
||||
// hooks
|
||||
import { useIssues, useUser } from "hooks/store";
|
||||
import { useIssueDetail, useIssues, useUser } from "hooks/store";
|
||||
import useToast from "hooks/use-toast";
|
||||
// components
|
||||
import { ExistingIssuesListModal } from "components/core";
|
||||
@ -26,63 +24,91 @@ import { EUserProjectRoles } from "constants/project";
|
||||
import { EIssuesStoreType } from "constants/issue";
|
||||
|
||||
export interface ISubIssuesRoot {
|
||||
parentIssue: TIssue;
|
||||
user: IUser | undefined;
|
||||
}
|
||||
|
||||
export interface ISubIssuesRootLoaders {
|
||||
visibility: string[];
|
||||
delete: string[];
|
||||
sub_issues: string[];
|
||||
}
|
||||
export interface ISubIssuesRootLoadersHandler {
|
||||
key: "visibility" | "delete" | "sub_issues";
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
currentUser: IUser;
|
||||
is_archived: boolean;
|
||||
is_editable: boolean;
|
||||
}
|
||||
|
||||
const issueService = new IssueService();
|
||||
|
||||
export const SubIssuesRoot: React.FC<ISubIssuesRoot> = observer((props) => {
|
||||
const { parentIssue, user } = props;
|
||||
const { workspaceSlug, projectId, issueId, currentUser, is_archived, is_editable } = props;
|
||||
// store hooks
|
||||
const {
|
||||
issues: { updateIssue, removeIssue },
|
||||
} = useIssues(EIssuesStoreType.PROJECT);
|
||||
const {
|
||||
membership: { currentProjectRole },
|
||||
} = useUser();
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
const {
|
||||
subIssues: { subIssuesByIssueId, subIssuesStateDistribution },
|
||||
updateIssue,
|
||||
removeIssue,
|
||||
fetchSubIssues,
|
||||
createSubIssues,
|
||||
} = useIssueDetail();
|
||||
// state
|
||||
const [currentIssue, setCurrentIssue] = useState<TIssue>();
|
||||
|
||||
const { data: issues, isLoading } = useSWR(
|
||||
workspaceSlug && projectId && parentIssue && parentIssue?.id ? SUB_ISSUES(parentIssue?.id) : null,
|
||||
workspaceSlug && projectId && parentIssue && parentIssue?.id
|
||||
? () => issueService.subIssues(workspaceSlug.toString(), projectId.toString(), parentIssue.id)
|
||||
: null
|
||||
);
|
||||
console.log("subIssuesByIssueId", subIssuesByIssueId(issueId));
|
||||
|
||||
const [issuesLoader, setIssuesLoader] = React.useState<ISubIssuesRootLoaders>({
|
||||
visibility: [parentIssue?.id],
|
||||
delete: [],
|
||||
sub_issues: [],
|
||||
});
|
||||
const handleIssuesLoader = ({ key, issueId }: ISubIssuesRootLoadersHandler) => {
|
||||
setIssuesLoader((previousData: ISubIssuesRootLoaders) => ({
|
||||
...previousData,
|
||||
[key]: previousData[key].includes(issueId)
|
||||
? previousData[key].filter((i: string) => i !== issueId)
|
||||
: [...previousData[key], issueId],
|
||||
}));
|
||||
const copyText = (text: string) => {
|
||||
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
|
||||
copyTextToClipboard(`${originURL}/${text}`).then(() => {
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Link Copied!",
|
||||
message: "Issue link copied to clipboard.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const subIssueOperations = useMemo(
|
||||
() => ({
|
||||
fetchSubIssues: async (workspaceSlug: string, projectId: string, issueId: string) => {
|
||||
try {
|
||||
await fetchSubIssues(workspaceSlug, projectId, issueId);
|
||||
} catch (error) {}
|
||||
},
|
||||
update: async (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => {
|
||||
try {
|
||||
await updateIssue(workspaceSlug, projectId, issueId, data);
|
||||
setToastAlert({
|
||||
title: "Issue updated successfully",
|
||||
type: "success",
|
||||
message: "Issue updated successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
setToastAlert({
|
||||
title: "Issue update failed",
|
||||
type: "error",
|
||||
message: "Issue update failed",
|
||||
});
|
||||
}
|
||||
},
|
||||
addSubIssue: async () => {
|
||||
try {
|
||||
} catch (error) {}
|
||||
},
|
||||
removeSubIssue: async () => {
|
||||
try {
|
||||
} catch (error) {}
|
||||
},
|
||||
updateIssue: async () => {
|
||||
try {
|
||||
} catch (error) {}
|
||||
},
|
||||
deleteIssue: async () => {
|
||||
try {
|
||||
} catch (error) {}
|
||||
},
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const [issueCrudOperation, setIssueCrudOperation] = React.useState<{
|
||||
// type: "create" | "edit";
|
||||
create: { toggle: boolean; issueId: string | null };
|
||||
existing: { toggle: boolean; issueId: string | null };
|
||||
edit: { toggle: boolean; issueId: string | null; issue: TIssue | null };
|
||||
delete: { toggle: boolean; issueId: string | null; issue: TIssue | null };
|
||||
}>({
|
||||
create: {
|
||||
toggle: false,
|
||||
@ -92,19 +118,10 @@ export const SubIssuesRoot: React.FC<ISubIssuesRoot> = observer((props) => {
|
||||
toggle: false,
|
||||
issueId: null,
|
||||
},
|
||||
edit: {
|
||||
toggle: false,
|
||||
issueId: null, // parent issue id for mutation
|
||||
issue: null,
|
||||
},
|
||||
delete: {
|
||||
toggle: false,
|
||||
issueId: null, // parent issue id for mutation
|
||||
issue: null,
|
||||
},
|
||||
});
|
||||
|
||||
const handleIssueCrudOperation = (
|
||||
key: "create" | "existing" | "edit" | "delete",
|
||||
key: "create" | "existing",
|
||||
issueId: string | null,
|
||||
issue: TIssue | null = null
|
||||
) => {
|
||||
@ -118,75 +135,14 @@ export const SubIssuesRoot: React.FC<ISubIssuesRoot> = observer((props) => {
|
||||
});
|
||||
};
|
||||
|
||||
const addAsSubIssueFromExistingIssues = async (data: ISearchIssueResponse[]) => {
|
||||
if (!workspaceSlug || !projectId || !parentIssue || issueCrudOperation?.existing?.issueId === null) return;
|
||||
const issueId = issueCrudOperation?.existing?.issueId;
|
||||
const payload = {
|
||||
sub_issue_ids: data.map((i) => i.id),
|
||||
};
|
||||
await issueService.addSubIssues(workspaceSlug.toString(), projectId.toString(), issueId, payload).finally(() => {
|
||||
if (issueId) mutate(SUB_ISSUES(issueId));
|
||||
});
|
||||
};
|
||||
|
||||
const removeIssueFromSubIssues = async (parentIssueId: string, issue: TIssue) => {
|
||||
if (!workspaceSlug || !projectId || !parentIssue || !issue?.id) return;
|
||||
issueService
|
||||
.patchIssue(workspaceSlug.toString(), projectId.toString(), issue.id, { parent_id: null })
|
||||
.then(async () => {
|
||||
if (parentIssueId) await mutate(SUB_ISSUES(parentIssueId));
|
||||
handleIssuesLoader({ key: "delete", issueId: issue?.id });
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: `Issue removed!`,
|
||||
message: `Issue removed successfully.`,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
handleIssuesLoader({ key: "delete", issueId: issue?.id });
|
||||
setToastAlert({
|
||||
type: "warning",
|
||||
title: `Error!`,
|
||||
message: `Error, Please try again later.`,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const copyText = (text: string) => {
|
||||
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
|
||||
copyTextToClipboard(`${originURL}/${text}`).then(() => {
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Link Copied!",
|
||||
message: "Issue link copied to clipboard.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateIssue = useCallback(
|
||||
(issue: TIssue, data: Partial<TIssue>) => {
|
||||
if (!workspaceSlug || !projectId || !user) return;
|
||||
|
||||
updateIssue(workspaceSlug.toString(), projectId.toString(), issue.id, data);
|
||||
},
|
||||
[projectId, updateIssue, user, workspaceSlug]
|
||||
);
|
||||
|
||||
const isEditable = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
|
||||
|
||||
const mutateSubIssues = (parentIssueId: string | null) => {
|
||||
if (parentIssueId) mutate(SUB_ISSUES(parentIssueId));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full w-full space-y-2">
|
||||
{!issues && isLoading ? (
|
||||
{/* {!issues && isLoading ? (
|
||||
<div className="py-3 text-center text-sm font-medium text-custom-text-300">Loading...</div>
|
||||
) : (
|
||||
<>
|
||||
{issues && issues?.sub_issues && issues?.sub_issues?.length > 0 ? (
|
||||
<>
|
||||
{/* header */}
|
||||
<div className="relative flex items-center gap-4 text-xs">
|
||||
<div
|
||||
className="flex cursor-pointer select-none items-center gap-1 rounded border border-custom-border-100 p-1.5 px-2 shadow transition-all hover:bg-custom-background-80"
|
||||
@ -210,7 +166,7 @@ export const SubIssuesRoot: React.FC<ISubIssuesRoot> = observer((props) => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isEditable && issuesLoader.visibility.includes(parentIssue?.id) && (
|
||||
{is_editable && issuesLoader.visibility.includes(parentIssue?.id) && (
|
||||
<div className="ml-auto flex flex-shrink-0 select-none items-center gap-2">
|
||||
<div
|
||||
className="cursor-pointer rounded border border-custom-border-100 p-1.5 px-2 shadow transition-all hover:bg-custom-background-80"
|
||||
@ -228,7 +184,7 @@ export const SubIssuesRoot: React.FC<ISubIssuesRoot> = observer((props) => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* issues */}
|
||||
|
||||
{issuesLoader.visibility.includes(parentIssue?.id) && workspaceSlug && projectId && (
|
||||
<div className="border border-b-0 border-custom-border-100">
|
||||
<SubIssuesRootList
|
||||
@ -236,7 +192,7 @@ export const SubIssuesRoot: React.FC<ISubIssuesRoot> = observer((props) => {
|
||||
projectId={projectId.toString()}
|
||||
parentIssue={parentIssue}
|
||||
user={undefined}
|
||||
editable={isEditable}
|
||||
editable={is_editable}
|
||||
removeIssueFromSubIssues={removeIssueFromSubIssues}
|
||||
issuesLoader={issuesLoader}
|
||||
handleIssuesLoader={handleIssuesLoader}
|
||||
@ -262,7 +218,6 @@ export const SubIssuesRoot: React.FC<ISubIssuesRoot> = observer((props) => {
|
||||
>
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
mutateSubIssues(parentIssue?.id);
|
||||
handleIssueCrudOperation("create", parentIssue?.id);
|
||||
}}
|
||||
>
|
||||
@ -270,7 +225,6 @@ export const SubIssuesRoot: React.FC<ISubIssuesRoot> = observer((props) => {
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
mutateSubIssues(parentIssue?.id);
|
||||
handleIssueCrudOperation("existing", parentIssue?.id);
|
||||
}}
|
||||
>
|
||||
@ -280,7 +234,7 @@ export const SubIssuesRoot: React.FC<ISubIssuesRoot> = observer((props) => {
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
isEditable && (
|
||||
is_editable && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="py-2 text-xs italic text-custom-text-300">No Sub-Issues yet</div>
|
||||
<div>
|
||||
@ -298,7 +252,6 @@ export const SubIssuesRoot: React.FC<ISubIssuesRoot> = observer((props) => {
|
||||
>
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
mutateSubIssues(parentIssue?.id);
|
||||
handleIssueCrudOperation("create", parentIssue?.id);
|
||||
}}
|
||||
>
|
||||
@ -306,7 +259,6 @@ export const SubIssuesRoot: React.FC<ISubIssuesRoot> = observer((props) => {
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
mutateSubIssues(parentIssue?.id);
|
||||
handleIssueCrudOperation("existing", parentIssue?.id);
|
||||
}}
|
||||
>
|
||||
@ -317,19 +269,20 @@ export const SubIssuesRoot: React.FC<ISubIssuesRoot> = observer((props) => {
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{isEditable && issueCrudOperation?.create?.toggle && (
|
||||
|
||||
{is_editable && issueCrudOperation?.create?.toggle && (
|
||||
<CreateUpdateIssueModal
|
||||
isOpen={issueCrudOperation?.create?.toggle}
|
||||
data={{
|
||||
parent_id: issueCrudOperation?.create?.issueId,
|
||||
}}
|
||||
onClose={() => {
|
||||
mutateSubIssues(issueCrudOperation?.create?.issueId);
|
||||
handleIssueCrudOperation("create", null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isEditable && issueCrudOperation?.existing?.toggle && issueCrudOperation?.existing?.issueId && (
|
||||
|
||||
{is_editable && issueCrudOperation?.existing?.toggle && issueCrudOperation?.existing?.issueId && (
|
||||
<ExistingIssuesListModal
|
||||
isOpen={issueCrudOperation?.existing?.toggle}
|
||||
handleClose={() => handleIssueCrudOperation("existing", null)}
|
||||
@ -338,19 +291,20 @@ export const SubIssuesRoot: React.FC<ISubIssuesRoot> = observer((props) => {
|
||||
workspaceLevelToggle
|
||||
/>
|
||||
)}
|
||||
{isEditable && issueCrudOperation?.edit?.toggle && issueCrudOperation?.edit?.issueId && (
|
||||
|
||||
{is_editable && issueCrudOperation?.edit?.toggle && issueCrudOperation?.edit?.issueId && (
|
||||
<>
|
||||
<CreateUpdateIssueModal
|
||||
isOpen={issueCrudOperation?.edit?.toggle}
|
||||
onClose={() => {
|
||||
mutateSubIssues(issueCrudOperation?.edit?.issueId);
|
||||
handleIssueCrudOperation("edit", null, null);
|
||||
}}
|
||||
data={issueCrudOperation?.edit?.issue ?? undefined}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{isEditable &&
|
||||
|
||||
{is_editable &&
|
||||
workspaceSlug &&
|
||||
projectId &&
|
||||
issueCrudOperation?.delete?.issueId &&
|
||||
@ -358,7 +312,6 @@ export const SubIssuesRoot: React.FC<ISubIssuesRoot> = observer((props) => {
|
||||
<DeleteIssueModal
|
||||
isOpen={issueCrudOperation?.delete?.toggle}
|
||||
handleClose={() => {
|
||||
mutateSubIssues(issueCrudOperation?.delete?.issueId);
|
||||
handleIssueCrudOperation("delete", null, null);
|
||||
}}
|
||||
data={issueCrudOperation?.delete?.issue}
|
||||
@ -372,7 +325,7 @@ export const SubIssuesRoot: React.FC<ISubIssuesRoot> = observer((props) => {
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
)} */}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
@ -76,7 +76,7 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
|
||||
const submitChanges = (data: Partial<IModule>) => {
|
||||
if (!workspaceSlug || !projectId || !moduleId) return;
|
||||
updateModuleDetails(workspaceSlug.toString(), projectId.toString(), moduleId, data);
|
||||
updateModuleDetails(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), data);
|
||||
};
|
||||
|
||||
const handleCreateLink = async (formData: ModuleLink) => {
|
||||
|
@ -37,90 +37,90 @@ export const CreateUpdatePageModal: FC<Props> = (props) => {
|
||||
const createProjectPage = async (payload: IPage) => {
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
await createPage(workspaceSlug.toString(), projectId, payload)
|
||||
.then((res) => {
|
||||
router.push(`/${workspaceSlug}/projects/${projectId}/pages/${res.id}`);
|
||||
onClose();
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: "Page created successfully.",
|
||||
});
|
||||
postHogEventTracker(
|
||||
"PAGE_CREATED",
|
||||
{
|
||||
...res,
|
||||
state: "SUCCESS",
|
||||
},
|
||||
{
|
||||
isGrouping: true,
|
||||
groupType: "Workspace_metrics",
|
||||
groupId: currentWorkspace?.id!,
|
||||
}
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: err.detail ?? "Page could not be created. Please try again.",
|
||||
});
|
||||
postHogEventTracker(
|
||||
"PAGE_CREATED",
|
||||
{
|
||||
state: "FAILED",
|
||||
},
|
||||
{
|
||||
isGrouping: true,
|
||||
groupType: "Workspace_metrics",
|
||||
groupId: currentWorkspace?.id!,
|
||||
}
|
||||
);
|
||||
});
|
||||
// await createPage(workspaceSlug.toString(), projectId, payload)
|
||||
// .then((res) => {
|
||||
// router.push(`/${workspaceSlug}/projects/${projectId}/pages/${res.id}`);
|
||||
// onClose();
|
||||
// setToastAlert({
|
||||
// type: "success",
|
||||
// title: "Success!",
|
||||
// message: "Page created successfully.",
|
||||
// });
|
||||
// postHogEventTracker(
|
||||
// "PAGE_CREATED",
|
||||
// {
|
||||
// ...res,
|
||||
// state: "SUCCESS",
|
||||
// },
|
||||
// {
|
||||
// isGrouping: true,
|
||||
// groupType: "Workspace_metrics",
|
||||
// groupId: currentWorkspace?.id!,
|
||||
// }
|
||||
// );
|
||||
// })
|
||||
// .catch((err) => {
|
||||
// setToastAlert({
|
||||
// type: "error",
|
||||
// title: "Error!",
|
||||
// message: err.detail ?? "Page could not be created. Please try again.",
|
||||
// });
|
||||
// postHogEventTracker(
|
||||
// "PAGE_CREATED",
|
||||
// {
|
||||
// state: "FAILED",
|
||||
// },
|
||||
// {
|
||||
// isGrouping: true,
|
||||
// groupType: "Workspace_metrics",
|
||||
// groupId: currentWorkspace?.id!,
|
||||
// }
|
||||
// );
|
||||
// });
|
||||
};
|
||||
|
||||
const updateProjectPage = async (payload: IPage) => {
|
||||
if (!data || !workspaceSlug) return;
|
||||
|
||||
await updatePage(workspaceSlug.toString(), projectId, data.id, payload)
|
||||
.then((res) => {
|
||||
onClose();
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: "Page updated successfully.",
|
||||
});
|
||||
postHogEventTracker(
|
||||
"PAGE_UPDATED",
|
||||
{
|
||||
...res,
|
||||
state: "SUCCESS",
|
||||
},
|
||||
{
|
||||
isGrouping: true,
|
||||
groupType: "Workspace_metrics",
|
||||
groupId: currentWorkspace?.id!,
|
||||
}
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: err.detail ?? "Page could not be updated. Please try again.",
|
||||
});
|
||||
postHogEventTracker(
|
||||
"PAGE_UPDATED",
|
||||
{
|
||||
state: "FAILED",
|
||||
},
|
||||
{
|
||||
isGrouping: true,
|
||||
groupType: "Workspace_metrics",
|
||||
groupId: currentWorkspace?.id!,
|
||||
}
|
||||
);
|
||||
});
|
||||
// await updatePage(workspaceSlug.toString(), projectId, data.id, payload)
|
||||
// .then((res) => {
|
||||
// onClose();
|
||||
// setToastAlert({
|
||||
// type: "success",
|
||||
// title: "Success!",
|
||||
// message: "Page updated successfully.",
|
||||
// });
|
||||
// postHogEventTracker(
|
||||
// "PAGE_UPDATED",
|
||||
// {
|
||||
// ...res,
|
||||
// state: "SUCCESS",
|
||||
// },
|
||||
// {
|
||||
// isGrouping: true,
|
||||
// groupType: "Workspace_metrics",
|
||||
// groupId: currentWorkspace?.id!,
|
||||
// }
|
||||
// );
|
||||
// })
|
||||
// .catch((err) => {
|
||||
// setToastAlert({
|
||||
// type: "error",
|
||||
// title: "Error!",
|
||||
// message: err.detail ?? "Page could not be updated. Please try again.",
|
||||
// });
|
||||
// postHogEventTracker(
|
||||
// "PAGE_UPDATED",
|
||||
// {
|
||||
// state: "FAILED",
|
||||
// },
|
||||
// {
|
||||
// isGrouping: true,
|
||||
// groupType: "Workspace_metrics",
|
||||
// groupId: currentWorkspace?.id!,
|
||||
// }
|
||||
// );
|
||||
// });
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (formData: IPage) => {
|
||||
|
@ -186,8 +186,9 @@ export const PagesListItem: FC<IPagesListItem> = observer((props) => {
|
||||
<div className="flex items-center gap-2 overflow-hidden">
|
||||
<FileText className="h-4 w-4 shrink-0" />
|
||||
<p className="mr-2 truncate text-sm text-custom-text-100">{pageDetails.name}</p>
|
||||
{/* FIXME: replace any with proper type */}
|
||||
{pageDetails.label_details.length > 0 &&
|
||||
pageDetails.label_details.map((label) => (
|
||||
pageDetails.label_details.map((label: any) => (
|
||||
<div
|
||||
key={label.id}
|
||||
className="group flex items-center gap-1 rounded-2xl border border-custom-border-200 px-2 py-0.5 text-xs"
|
||||
|
@ -23,7 +23,8 @@ export const RecentPagesList: FC = observer(() => {
|
||||
} = useUser();
|
||||
const { recentProjectPages } = usePage();
|
||||
|
||||
const isEmpty = recentProjectPages && Object.values(recentProjectPages).every((value) => value.length === 0);
|
||||
// FIXME: replace any with proper type
|
||||
const isEmpty = recentProjectPages && Object.values(recentProjectPages).every((value: any) => value.length === 0);
|
||||
|
||||
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
|
||||
|
||||
|
@ -36,6 +36,7 @@ export enum EIssueFilterType {
|
||||
FILTERS = "filters",
|
||||
DISPLAY_FILTERS = "display_filters",
|
||||
DISPLAY_PROPERTIES = "display_properties",
|
||||
KANBAN_FILTERS = "kanban_filters",
|
||||
}
|
||||
|
||||
export const ISSUE_PRIORITIES: {
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { useContext } from "react";
|
||||
import merge from "lodash/merge";
|
||||
// mobx store
|
||||
import { StoreContext } from "contexts/store-context";
|
||||
// types
|
||||
@ -14,112 +15,102 @@ import { TIssueMap } from "@plane/types";
|
||||
// constants
|
||||
import { EIssuesStoreType } from "constants/issue";
|
||||
|
||||
export interface IStoreIssues {
|
||||
[EIssuesStoreType.GLOBAL]: {
|
||||
issueMap: TIssueMap;
|
||||
type defaultIssueStore = {
|
||||
issueMap: TIssueMap;
|
||||
};
|
||||
|
||||
export type TStoreIssues = {
|
||||
[EIssuesStoreType.GLOBAL]: defaultIssueStore & {
|
||||
issues: IWorkspaceIssues;
|
||||
issuesFilter: IWorkspaceIssuesFilter;
|
||||
};
|
||||
[EIssuesStoreType.PROFILE]: {
|
||||
issueMap: TIssueMap;
|
||||
[EIssuesStoreType.PROFILE]: defaultIssueStore & {
|
||||
issues: IProfileIssues;
|
||||
issuesFilter: IProfileIssuesFilter;
|
||||
};
|
||||
[EIssuesStoreType.PROJECT]: {
|
||||
issueMap: TIssueMap;
|
||||
[EIssuesStoreType.PROJECT]: defaultIssueStore & {
|
||||
issues: IProjectIssues;
|
||||
issuesFilter: IProjectIssuesFilter;
|
||||
};
|
||||
[EIssuesStoreType.CYCLE]: {
|
||||
issueMap: TIssueMap;
|
||||
[EIssuesStoreType.CYCLE]: defaultIssueStore & {
|
||||
issues: ICycleIssues;
|
||||
issuesFilter: ICycleIssuesFilter;
|
||||
};
|
||||
[EIssuesStoreType.MODULE]: {
|
||||
issueMap: TIssueMap;
|
||||
[EIssuesStoreType.MODULE]: defaultIssueStore & {
|
||||
issues: IModuleIssues;
|
||||
issuesFilter: IModuleIssuesFilter;
|
||||
};
|
||||
[EIssuesStoreType.PROJECT_VIEW]: {
|
||||
issueMap: TIssueMap;
|
||||
[EIssuesStoreType.PROJECT_VIEW]: defaultIssueStore & {
|
||||
issues: IProjectViewIssues;
|
||||
issuesFilter: IProjectViewIssuesFilter;
|
||||
};
|
||||
[EIssuesStoreType.ARCHIVED]: {
|
||||
issueMap: TIssueMap;
|
||||
[EIssuesStoreType.ARCHIVED]: defaultIssueStore & {
|
||||
issues: IArchivedIssues;
|
||||
issuesFilter: IArchivedIssuesFilter;
|
||||
};
|
||||
[EIssuesStoreType.DRAFT]: {
|
||||
issueMap: TIssueMap;
|
||||
[EIssuesStoreType.DRAFT]: defaultIssueStore & {
|
||||
issues: IDraftIssues;
|
||||
issuesFilter: IDraftIssuesFilter;
|
||||
};
|
||||
[EIssuesStoreType.DEFAULT]: {
|
||||
issueMap: TIssueMap;
|
||||
[EIssuesStoreType.DEFAULT]: defaultIssueStore & {
|
||||
issues: undefined;
|
||||
issuesFilter: undefined;
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const useIssues = <T extends EIssuesStoreType>(storeType?: T): IStoreIssues[T] => {
|
||||
export const useIssues = <T extends EIssuesStoreType>(storeType?: T): TStoreIssues[T] => {
|
||||
const context = useContext(StoreContext);
|
||||
if (context === undefined) throw new Error("useIssues must be used within StoreProvider");
|
||||
|
||||
const defaultStore: defaultIssueStore = {
|
||||
issueMap: context.issue.issues.issuesMap,
|
||||
};
|
||||
|
||||
switch (storeType) {
|
||||
case EIssuesStoreType.GLOBAL:
|
||||
return {
|
||||
issueMap: context.issue.issues.issuesMap,
|
||||
return merge(defaultStore, {
|
||||
issues: context.issue.workspaceIssues,
|
||||
issuesFilter: context.issue.workspaceIssuesFilter,
|
||||
} as IStoreIssues[T];
|
||||
}) as TStoreIssues[T];
|
||||
case EIssuesStoreType.PROFILE:
|
||||
return {
|
||||
issueMap: context.issue.issues.issuesMap,
|
||||
return merge(defaultStore, {
|
||||
issues: context.issue.profileIssues,
|
||||
issuesFilter: context.issue.profileIssuesFilter,
|
||||
} as IStoreIssues[T];
|
||||
}) as TStoreIssues[T];
|
||||
case EIssuesStoreType.PROJECT:
|
||||
return {
|
||||
issueMap: context.issue.issues.issuesMap,
|
||||
return merge(defaultStore, {
|
||||
issues: context.issue.projectIssues,
|
||||
issuesFilter: context.issue.projectIssuesFilter,
|
||||
} as IStoreIssues[T];
|
||||
}) as TStoreIssues[T];
|
||||
case EIssuesStoreType.CYCLE:
|
||||
return {
|
||||
issueMap: context.issue.issues.issuesMap,
|
||||
return merge(defaultStore, {
|
||||
issues: context.issue.cycleIssues,
|
||||
issuesFilter: context.issue.cycleIssuesFilter,
|
||||
} as IStoreIssues[T];
|
||||
}) as TStoreIssues[T];
|
||||
case EIssuesStoreType.MODULE:
|
||||
return {
|
||||
issueMap: context.issue.issues.issuesMap,
|
||||
return merge(defaultStore, {
|
||||
issues: context.issue.moduleIssues,
|
||||
issuesFilter: context.issue.moduleIssuesFilter,
|
||||
} as IStoreIssues[T];
|
||||
}) as TStoreIssues[T];
|
||||
case EIssuesStoreType.PROJECT_VIEW:
|
||||
return {
|
||||
issueMap: context.issue.issues.issuesMap,
|
||||
return merge(defaultStore, {
|
||||
issues: context.issue.projectViewIssues,
|
||||
issuesFilter: context.issue.projectViewIssuesFilter,
|
||||
} as IStoreIssues[T];
|
||||
}) as TStoreIssues[T];
|
||||
case EIssuesStoreType.ARCHIVED:
|
||||
return {
|
||||
issueMap: context.issue.issues.issuesMap,
|
||||
return merge(defaultStore, {
|
||||
issues: context.issue.archivedIssues,
|
||||
issuesFilter: context.issue.archivedIssuesFilter,
|
||||
} as IStoreIssues[T];
|
||||
}) as TStoreIssues[T];
|
||||
case EIssuesStoreType.DRAFT:
|
||||
return {
|
||||
issueMap: context.issue.issues.issuesMap,
|
||||
return merge(defaultStore, {
|
||||
issues: context.issue.draftIssues,
|
||||
issuesFilter: context.issue.draftIssuesFilter,
|
||||
} as IStoreIssues[T];
|
||||
}) as TStoreIssues[T];
|
||||
default:
|
||||
return {
|
||||
issueMap: context.issue.issues.issuesMap,
|
||||
return merge(defaultStore, {
|
||||
issues: undefined,
|
||||
issuesFilter: undefined,
|
||||
} as IStoreIssues[T];
|
||||
}) as TStoreIssues[T];
|
||||
}
|
||||
};
|
||||
|
@ -4,8 +4,8 @@ import { StoreContext } from "contexts/store-context";
|
||||
// types
|
||||
import { IPageStore } from "store/page.store";
|
||||
|
||||
export const usePage = (): IPageStore => {
|
||||
export const usePage = (): any => {
|
||||
const context = useContext(StoreContext);
|
||||
if (context === undefined) throw new Error("usePage must be used within StoreProvider");
|
||||
return context.page;
|
||||
return context as any;
|
||||
};
|
||||
|
@ -1,91 +0,0 @@
|
||||
import useSWR from "swr";
|
||||
|
||||
// fetch keys
|
||||
import { ISSUE_REACTION_LIST } from "constants/fetch-keys";
|
||||
// helpers
|
||||
import { groupReactions } from "helpers/emoji.helper";
|
||||
// services
|
||||
import { IssueReactionService } from "services/issue";
|
||||
import { useUser } from "./store";
|
||||
// hooks
|
||||
|
||||
const issueReactionService = new IssueReactionService();
|
||||
|
||||
const useIssueReaction = (
|
||||
workspaceSlug?: string | string[] | null,
|
||||
projectId?: string | string[] | null,
|
||||
issueId?: string | string[] | null
|
||||
) => {
|
||||
const user = useUser();
|
||||
|
||||
const {
|
||||
data: reactions,
|
||||
mutate: mutateReaction,
|
||||
error,
|
||||
} = useSWR(
|
||||
workspaceSlug && projectId && issueId
|
||||
? ISSUE_REACTION_LIST(workspaceSlug.toString(), projectId.toString(), issueId.toString())
|
||||
: null,
|
||||
workspaceSlug && projectId && issueId
|
||||
? () =>
|
||||
issueReactionService.listIssueReactions(workspaceSlug.toString(), projectId.toString(), issueId.toString())
|
||||
: null
|
||||
);
|
||||
|
||||
const groupedReactions = groupReactions(reactions || [], "reaction");
|
||||
|
||||
/**
|
||||
* @description Use this function to create user's reaction to an issue. This function will mutate the reactions state.
|
||||
* @param {string} reaction
|
||||
* @example handleReactionCreate("128077") // hexa-code of the emoji
|
||||
*/
|
||||
|
||||
const handleReactionCreate = async (reaction: string) => {
|
||||
if (!workspaceSlug || !projectId || !issueId) return;
|
||||
|
||||
const data = await issueReactionService.createIssueReaction(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
issueId.toString(),
|
||||
{ reaction }
|
||||
);
|
||||
|
||||
mutateReaction((prev: any) => [...(prev || []), data]);
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Use this function to delete user's reaction from an issue. This function will mutate the reactions state.
|
||||
* @param {string} reaction
|
||||
* @example handleReactionDelete("123") // 123 -> is emoji hexa-code
|
||||
*/
|
||||
|
||||
const handleReactionDelete = async (reaction: string) => {
|
||||
if (!workspaceSlug || !projectId || !issueId) return;
|
||||
|
||||
mutateReaction(
|
||||
(prevData: any) =>
|
||||
prevData?.filter((r: any) => r.actor !== user?.currentUser?.id || r.reaction !== reaction) || [],
|
||||
false
|
||||
);
|
||||
|
||||
await issueReactionService.deleteIssueReaction(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
issueId.toString(),
|
||||
reaction
|
||||
);
|
||||
|
||||
mutateReaction();
|
||||
};
|
||||
|
||||
return {
|
||||
isLoading: !reactions && !error,
|
||||
reactions,
|
||||
groupedReactions,
|
||||
handleReactionCreate,
|
||||
handleReactionDelete,
|
||||
mutateReaction,
|
||||
} as const;
|
||||
};
|
||||
|
||||
export default useIssueReaction;
|
@ -9,7 +9,8 @@ import useToast from "hooks/use-toast";
|
||||
// layouts
|
||||
import { AppLayout } from "layouts/app-layout";
|
||||
// components
|
||||
import { IssueDetailsSidebar, IssueMainContent } from "components/issues";
|
||||
// FIXME: have to replace this once the issue details page is ready --issue-detail--
|
||||
// import { IssueDetailsSidebar, IssueMainContent } from "components/issues";
|
||||
import { ProjectArchivedIssueDetailsHeader } from "components/headers";
|
||||
// ui
|
||||
import { ArchiveIcon, Loader } from "@plane/ui";
|
||||
@ -158,11 +159,13 @@ const ArchivedIssueDetailsPage: NextPageWithLayout = () => {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="pointer-events-none space-y-5 divide-y-2 divide-custom-border-200 opacity-60">
|
||||
{/* FIXME: have to replace this once the issue details page is ready --issue-detail-- */}
|
||||
{/* <div className="pointer-events-none space-y-5 divide-y-2 divide-custom-border-200 opacity-60">
|
||||
<IssueMainContent issueDetails={issueDetails} submitChanges={submitChanges} uneditable />
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
<div className="h-full w-1/3 space-y-5 overflow-hidden border-l border-custom-border-300 p-5">
|
||||
{/* FIXME: have to replace this once the issue details page is ready --issue-detail-- */}
|
||||
{/* <div className="h-full w-1/3 space-y-5 overflow-hidden border-l border-custom-border-300 p-5">
|
||||
<IssueDetailsSidebar
|
||||
control={control}
|
||||
issueDetail={issueDetails}
|
||||
@ -170,7 +173,7 @@ const ArchivedIssueDetailsPage: NextPageWithLayout = () => {
|
||||
watch={watch}
|
||||
uneditable
|
||||
/>
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
) : (
|
||||
<Loader className="flex h-full gap-5 p-5">
|
||||
|
@ -1,143 +1,42 @@
|
||||
import React, { useCallback, useEffect, ReactElement } from "react";
|
||||
import React, { ReactElement } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import useSWR, { mutate } from "swr";
|
||||
import { useForm } from "react-hook-form";
|
||||
// services
|
||||
import { IssueService } from "services/issue";
|
||||
import useSWR from "swr";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// layouts
|
||||
import { AppLayout } from "layouts/app-layout";
|
||||
// components
|
||||
import { ProjectIssueDetailsHeader } from "components/headers";
|
||||
import { IssueDetailsSidebar, IssueMainContent } from "components/issues";
|
||||
import { IssueDetailRoot } from "components/issues";
|
||||
// ui
|
||||
import { EmptyState } from "components/common";
|
||||
import { Loader } from "@plane/ui";
|
||||
// images
|
||||
import emptyIssue from "public/empty-state/issue.svg";
|
||||
// types
|
||||
import { TIssue } from "@plane/types";
|
||||
import { NextPageWithLayout } from "lib/types";
|
||||
// fetch-keys
|
||||
import { PROJECT_ISSUES_ACTIVITY, ISSUE_DETAILS } from "constants/fetch-keys";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useIssueDetail } from "hooks/store";
|
||||
|
||||
const defaultValues: Partial<TIssue> = {
|
||||
// description: "",
|
||||
description_html: "",
|
||||
estimate_point: null,
|
||||
cycle_id: null,
|
||||
module_id: null,
|
||||
name: "",
|
||||
priority: "low",
|
||||
start_date: undefined,
|
||||
state_id: "",
|
||||
target_date: undefined,
|
||||
};
|
||||
|
||||
// services
|
||||
const issueService = new IssueService();
|
||||
|
||||
const IssueDetailsPage: NextPageWithLayout = observer(() => {
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, issueId: routeIssueId } = router.query;
|
||||
|
||||
const { peekIssue, fetchIssue } = useIssueDetail();
|
||||
useEffect(() => {
|
||||
if (!workspaceSlug || !projectId || !routeIssueId) return;
|
||||
fetchIssue(workspaceSlug as string, projectId as string, routeIssueId as string);
|
||||
}, [workspaceSlug, projectId, routeIssueId, fetchIssue]);
|
||||
|
||||
const { workspaceSlug, projectId, issueId } = router.query;
|
||||
// hooks
|
||||
const {
|
||||
data: issueDetails,
|
||||
mutate: mutateIssueDetails,
|
||||
error,
|
||||
} = useSWR(
|
||||
workspaceSlug && projectId && peekIssue?.issueId ? ISSUE_DETAILS(peekIssue?.issueId as string) : null,
|
||||
workspaceSlug && projectId && peekIssue?.issueId
|
||||
? () => issueService.retrieve(workspaceSlug as string, projectId as string, peekIssue?.issueId as string)
|
||||
fetchIssue,
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
|
||||
const { isLoading } = useSWR(
|
||||
workspaceSlug && projectId && issueId ? `ISSUE_DETAIL_${workspaceSlug}_${projectId}_${issueId}` : null,
|
||||
workspaceSlug && projectId && issueId
|
||||
? () => fetchIssue(workspaceSlug.toString(), projectId.toString(), issueId.toString())
|
||||
: null
|
||||
);
|
||||
|
||||
const { reset, control, watch } = useForm<TIssue>({
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const submitChanges = useCallback(
|
||||
async (formData: Partial<TIssue>) => {
|
||||
if (!workspaceSlug || !projectId || !peekIssue?.issueId) return;
|
||||
|
||||
mutate<TIssue>(
|
||||
ISSUE_DETAILS(peekIssue?.issueId as string),
|
||||
(prevData) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
return {
|
||||
...prevData,
|
||||
...formData,
|
||||
};
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
const payload: Partial<TIssue> = {
|
||||
...formData,
|
||||
};
|
||||
|
||||
// delete payload.related_issues;
|
||||
// delete payload.issue_relations;
|
||||
|
||||
await issueService
|
||||
.patchIssue(workspaceSlug as string, projectId as string, peekIssue?.issueId as string, payload)
|
||||
.then(() => {
|
||||
mutateIssueDetails();
|
||||
mutate(PROJECT_ISSUES_ACTIVITY(peekIssue?.issueId as string));
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
});
|
||||
},
|
||||
[workspaceSlug, peekIssue?.issueId, projectId, mutateIssueDetails]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!issueDetails) return;
|
||||
|
||||
mutate(PROJECT_ISSUES_ACTIVITY(peekIssue?.issueId as string));
|
||||
reset({
|
||||
...issueDetails,
|
||||
});
|
||||
}, [issueDetails, reset, peekIssue?.issueId]);
|
||||
const issue = getIssueById(issueId?.toString() || "") || undefined;
|
||||
const issueLoader = !issue || isLoading ? true : false;
|
||||
|
||||
return (
|
||||
<>
|
||||
{" "}
|
||||
{error ? (
|
||||
<EmptyState
|
||||
image={emptyIssue}
|
||||
title="Issue does not exist"
|
||||
description="The issue you are looking for does not exist, has been archived, or has been deleted."
|
||||
primaryButton={{
|
||||
text: "View other issues",
|
||||
onClick: () => router.push(`/${workspaceSlug}/projects/${projectId}/issues`),
|
||||
}}
|
||||
/>
|
||||
) : issueDetails && projectId && peekIssue?.issueId ? (
|
||||
<div className="flex h-full overflow-hidden">
|
||||
<div className="h-full w-2/3 space-y-5 divide-y-2 divide-custom-border-300 overflow-y-auto p-5">
|
||||
<IssueMainContent issueDetails={issueDetails} submitChanges={submitChanges} />
|
||||
</div>
|
||||
<div className="h-full w-1/3 space-y-5 overflow-hidden border-l border-custom-border-300 py-5">
|
||||
<IssueDetailsSidebar
|
||||
control={control}
|
||||
issueDetail={issueDetails}
|
||||
submitChanges={submitChanges}
|
||||
watch={watch}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
{issueLoader ? (
|
||||
<Loader className="flex h-full gap-5 p-5">
|
||||
<div className="basis-2/3 space-y-2">
|
||||
<Loader.Item height="30px" width="40%" />
|
||||
@ -152,6 +51,16 @@ const IssueDetailsPage: NextPageWithLayout = observer(() => {
|
||||
<Loader.Item height="30px" />
|
||||
</div>
|
||||
</Loader>
|
||||
) : (
|
||||
workspaceSlug &&
|
||||
projectId &&
|
||||
issueId && (
|
||||
<IssueDetailRoot
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
issueId={issueId.toString()}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
@ -38,6 +38,7 @@ const ModuleIssuesPage: NextPageWithLayout = () => {
|
||||
setValue(`${!isSidebarCollapsed}`);
|
||||
};
|
||||
|
||||
if (!workspaceSlug || !projectId || !moduleId) return <></>;
|
||||
return (
|
||||
<>
|
||||
{error ? (
|
||||
|
@ -49,8 +49,10 @@ export class IssueService extends APIService {
|
||||
});
|
||||
}
|
||||
|
||||
async retrieve(workspaceSlug: string, projectId: string, issueId: string): Promise<TIssue> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/`)
|
||||
async retrieve(workspaceSlug: string, projectId: string, issueId: string, queries?: any): Promise<TIssue> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/`, {
|
||||
params: queries,
|
||||
})
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
|
@ -29,7 +29,7 @@ export class IssueFiltersService extends APIService {
|
||||
// }
|
||||
|
||||
// project issue filters
|
||||
async fetchProjectIssueFilters(workspaceSlug: string, projectId: string): Promise<any> {
|
||||
async fetchProjectIssueFilters(workspaceSlug: string, projectId: string): Promise<IIssueFiltersResponse> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/user-properties/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
@ -49,7 +49,11 @@ export class IssueFiltersService extends APIService {
|
||||
}
|
||||
|
||||
// cycle issue filters
|
||||
async fetchCycleIssueFilters(workspaceSlug: string, projectId: string, cycleId: string): Promise<any> {
|
||||
async fetchCycleIssueFilters(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
cycleId: string
|
||||
): Promise<IIssueFiltersResponse> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}/user-properties/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
@ -70,7 +74,11 @@ export class IssueFiltersService extends APIService {
|
||||
}
|
||||
|
||||
// module issue filters
|
||||
async fetchModuleIssueFilters(workspaceSlug: string, projectId: string, moduleId: string): Promise<any> {
|
||||
async fetchModuleIssueFilters(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
moduleId: string
|
||||
): Promise<IIssueFiltersResponse> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/user-properties/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { computed, makeObservable, observable, runInAction } from "mobx";
|
||||
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import set from "lodash/set";
|
||||
// base class
|
||||
@ -11,6 +11,7 @@ import {
|
||||
IIssueFilterOptions,
|
||||
IIssueDisplayFilterOptions,
|
||||
IIssueDisplayProperties,
|
||||
TIssueKanbanFilters,
|
||||
IIssueFilters,
|
||||
TIssueParams,
|
||||
} from "@plane/types";
|
||||
@ -31,7 +32,7 @@ export interface IArchivedIssuesFilter {
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
filterType: EIssueFilterType,
|
||||
filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties
|
||||
filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
@ -51,6 +52,9 @@ export class ArchivedIssuesFilter extends IssueFilterHelperStore implements IArc
|
||||
// computed
|
||||
issueFilters: computed,
|
||||
appliedFilters: computed,
|
||||
// actions
|
||||
fetchFilters: action,
|
||||
updateFilters: action,
|
||||
});
|
||||
// root store
|
||||
this.rootIssueStore = _rootStore;
|
||||
@ -100,22 +104,18 @@ export class ArchivedIssuesFilter extends IssueFilterHelperStore implements IArc
|
||||
const filters: IIssueFilterOptions = this.computedFilters(_filters?.filters);
|
||||
const displayFilters: IIssueDisplayFilterOptions = this.computedDisplayFilters(_filters?.display_filters);
|
||||
const displayProperties: IIssueDisplayProperties = this.computedDisplayProperties(_filters?.display_properties);
|
||||
|
||||
this.handleIssuesLocalFilters.set(
|
||||
EIssuesStoreType.ARCHIVED,
|
||||
EIssueFilterType.FILTERS,
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
undefined,
|
||||
{
|
||||
filters: filters,
|
||||
}
|
||||
);
|
||||
const kanbanFilters = {
|
||||
group_by: [],
|
||||
sub_group_by: [],
|
||||
};
|
||||
kanbanFilters.group_by = _filters?.kanban_filters?.group_by || [];
|
||||
kanbanFilters.sub_group_by = _filters?.kanban_filters?.sub_group_by || [];
|
||||
|
||||
runInAction(() => {
|
||||
set(this.filters, [projectId, "filters"], filters);
|
||||
set(this.filters, [projectId, "displayFilters"], displayFilters);
|
||||
set(this.filters, [projectId, "displayProperties"], displayProperties);
|
||||
set(this.filters, [projectId, "kanbanFilters"], kanbanFilters);
|
||||
});
|
||||
} catch (error) {
|
||||
throw error;
|
||||
@ -126,7 +126,7 @@ export class ArchivedIssuesFilter extends IssueFilterHelperStore implements IArc
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
type: EIssueFilterType,
|
||||
filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties
|
||||
filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters
|
||||
) => {
|
||||
try {
|
||||
if (isEmpty(this.filters) || isEmpty(this.filters[projectId]) || isEmpty(filters)) return;
|
||||
@ -135,6 +135,7 @@ export class ArchivedIssuesFilter extends IssueFilterHelperStore implements IArc
|
||||
filters: this.filters[projectId].filters as IIssueFilterOptions,
|
||||
displayFilters: this.filters[projectId].displayFilters as IIssueDisplayFilterOptions,
|
||||
displayProperties: this.filters[projectId].displayProperties as IIssueDisplayProperties,
|
||||
kanbanFilters: this.filters[projectId].kanbanFilters as TIssueKanbanFilters,
|
||||
};
|
||||
|
||||
switch (type) {
|
||||
@ -148,7 +149,7 @@ export class ArchivedIssuesFilter extends IssueFilterHelperStore implements IArc
|
||||
});
|
||||
});
|
||||
|
||||
this.rootIssueStore.projectIssues.fetchIssues(workspaceSlug, projectId, "mutation");
|
||||
this.rootIssueStore.archivedIssues.fetchIssues(workspaceSlug, projectId, "mutation");
|
||||
this.handleIssuesLocalFilters.set(EIssuesStoreType.ARCHIVED, type, workspaceSlug, projectId, undefined, {
|
||||
filters: _filters.filters,
|
||||
});
|
||||
@ -209,6 +210,28 @@ export class ArchivedIssuesFilter extends IssueFilterHelperStore implements IArc
|
||||
display_properties: _filters.displayProperties,
|
||||
});
|
||||
break;
|
||||
|
||||
case EIssueFilterType.KANBAN_FILTERS:
|
||||
const updatedKanbanFilters = filters as TIssueKanbanFilters;
|
||||
_filters.kanbanFilters = { ..._filters.kanbanFilters, ...updatedKanbanFilters };
|
||||
|
||||
const currentUserId = this.rootIssueStore.currentUserId;
|
||||
if (currentUserId)
|
||||
this.handleIssuesLocalFilters.set(EIssuesStoreType.PROJECT, type, workspaceSlug, projectId, undefined, {
|
||||
kanban_filters: _filters.kanbanFilters,
|
||||
});
|
||||
|
||||
runInAction(() => {
|
||||
Object.keys(updatedKanbanFilters).forEach((_key) => {
|
||||
set(
|
||||
this.filters,
|
||||
[projectId, "kanbanFilters", _key],
|
||||
updatedKanbanFilters[_key as keyof TIssueKanbanFilters]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { computed, makeObservable, observable, runInAction } from "mobx";
|
||||
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import set from "lodash/set";
|
||||
// base class
|
||||
@ -11,11 +11,12 @@ import {
|
||||
IIssueFilterOptions,
|
||||
IIssueDisplayFilterOptions,
|
||||
IIssueDisplayProperties,
|
||||
TIssueKanbanFilters,
|
||||
IIssueFilters,
|
||||
TIssueParams,
|
||||
} from "@plane/types";
|
||||
// constants
|
||||
import { EIssueFilterType } from "constants/issue";
|
||||
import { EIssueFilterType, EIssuesStoreType } from "constants/issue";
|
||||
// services
|
||||
import { IssueFiltersService } from "services/issue_filter.service";
|
||||
|
||||
@ -31,7 +32,7 @@ export interface ICycleIssuesFilter {
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
filterType: EIssueFilterType,
|
||||
filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties,
|
||||
filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters,
|
||||
cycleId?: string | undefined
|
||||
) => Promise<void>;
|
||||
}
|
||||
@ -52,6 +53,9 @@ export class CycleIssuesFilter extends IssueFilterHelperStore implements ICycleI
|
||||
// computed
|
||||
issueFilters: computed,
|
||||
appliedFilters: computed,
|
||||
// actions
|
||||
fetchFilters: action,
|
||||
updateFilters: action,
|
||||
});
|
||||
// root store
|
||||
this.rootIssueStore = _rootStore;
|
||||
@ -97,10 +101,28 @@ export class CycleIssuesFilter extends IssueFilterHelperStore implements ICycleI
|
||||
const displayFilters: IIssueDisplayFilterOptions = this.computedDisplayFilters(_filters?.display_filters);
|
||||
const displayProperties: IIssueDisplayProperties = this.computedDisplayProperties(_filters?.display_properties);
|
||||
|
||||
// fetching the kanban toggle helpers in the local storage
|
||||
const kanbanFilters = {
|
||||
group_by: [],
|
||||
sub_group_by: [],
|
||||
};
|
||||
const currentUserId = this.rootIssueStore.currentUserId;
|
||||
if (currentUserId) {
|
||||
const _kanbanFilters = this.handleIssuesLocalFilters.get(
|
||||
EIssuesStoreType.CYCLE,
|
||||
workspaceSlug,
|
||||
cycleId,
|
||||
currentUserId
|
||||
);
|
||||
kanbanFilters.group_by = _kanbanFilters?.kanban_filters?.group_by || [];
|
||||
kanbanFilters.sub_group_by = _kanbanFilters?.kanban_filters?.sub_group_by || [];
|
||||
}
|
||||
|
||||
runInAction(() => {
|
||||
set(this.filters, [cycleId, "filters"], filters);
|
||||
set(this.filters, [cycleId, "displayFilters"], displayFilters);
|
||||
set(this.filters, [cycleId, "displayProperties"], displayProperties);
|
||||
set(this.filters, [cycleId, "kanbanFilters"], kanbanFilters);
|
||||
});
|
||||
} catch (error) {
|
||||
throw error;
|
||||
@ -111,7 +133,7 @@ export class CycleIssuesFilter extends IssueFilterHelperStore implements ICycleI
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
type: EIssueFilterType,
|
||||
filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties,
|
||||
filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters,
|
||||
cycleId: string | undefined = undefined
|
||||
) => {
|
||||
try {
|
||||
@ -119,9 +141,10 @@ export class CycleIssuesFilter extends IssueFilterHelperStore implements ICycleI
|
||||
if (isEmpty(this.filters) || isEmpty(this.filters[projectId]) || isEmpty(filters)) return;
|
||||
|
||||
const _filters = {
|
||||
filters: this.filters[projectId].filters as IIssueFilterOptions,
|
||||
displayFilters: this.filters[projectId].displayFilters as IIssueDisplayFilterOptions,
|
||||
displayProperties: this.filters[projectId].displayProperties as IIssueDisplayProperties,
|
||||
filters: this.filters[cycleId].filters as IIssueFilterOptions,
|
||||
displayFilters: this.filters[cycleId].displayFilters as IIssueDisplayFilterOptions,
|
||||
displayProperties: this.filters[cycleId].displayProperties as IIssueDisplayProperties,
|
||||
kanbanFilters: this.filters[cycleId].kanbanFilters as TIssueKanbanFilters,
|
||||
};
|
||||
|
||||
switch (type) {
|
||||
@ -135,7 +158,7 @@ export class CycleIssuesFilter extends IssueFilterHelperStore implements ICycleI
|
||||
});
|
||||
});
|
||||
|
||||
this.rootIssueStore.projectIssues.fetchIssues(workspaceSlug, projectId, "mutation");
|
||||
this.rootIssueStore.cycleIssues.fetchIssues(workspaceSlug, projectId, "mutation", cycleId);
|
||||
await this.issueFilterService.patchCycleIssueFilters(workspaceSlug, projectId, cycleId, {
|
||||
filters: _filters.filters,
|
||||
});
|
||||
@ -196,6 +219,28 @@ export class CycleIssuesFilter extends IssueFilterHelperStore implements ICycleI
|
||||
display_properties: _filters.displayProperties,
|
||||
});
|
||||
break;
|
||||
|
||||
case EIssueFilterType.KANBAN_FILTERS:
|
||||
const updatedKanbanFilters = filters as TIssueKanbanFilters;
|
||||
_filters.kanbanFilters = { ..._filters.kanbanFilters, ...updatedKanbanFilters };
|
||||
|
||||
const currentUserId = this.rootIssueStore.currentUserId;
|
||||
if (currentUserId)
|
||||
this.handleIssuesLocalFilters.set(EIssuesStoreType.PROJECT, type, workspaceSlug, cycleId, currentUserId, {
|
||||
kanban_filters: _filters.kanbanFilters,
|
||||
});
|
||||
|
||||
runInAction(() => {
|
||||
Object.keys(updatedKanbanFilters).forEach((_key) => {
|
||||
set(
|
||||
this.filters,
|
||||
[cycleId, "kanbanFilters", _key],
|
||||
updatedKanbanFilters[_key as keyof TIssueKanbanFilters]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
@ -1,5 +1,8 @@
|
||||
import { action, observable, makeObservable, computed, runInAction } from "mobx";
|
||||
import set from "lodash/set";
|
||||
import update from "lodash/update";
|
||||
import concat from "lodash/concat";
|
||||
import pull from "lodash/pull";
|
||||
// base class
|
||||
import { IssueHelperStore } from "../helpers/issue-helper.store";
|
||||
// services
|
||||
@ -259,13 +262,17 @@ export class CycleIssues extends IssueHelperStore implements ICycleIssues {
|
||||
addIssueToCycle = async (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => {
|
||||
try {
|
||||
runInAction(() => {
|
||||
this.issues[cycleId].push(...issueIds);
|
||||
update(this.issues, cycleId, (cycleIssueIds) => {
|
||||
if (!cycleIssueIds) return issueIds;
|
||||
else return concat(cycleIssueIds, issueIds);
|
||||
});
|
||||
});
|
||||
|
||||
issueIds.map((issueId) => this.rootStore.issues.updateIssue(issueId, { cycle_id: cycleId }));
|
||||
|
||||
const issueToCycle = await this.issueService.addIssueToCycle(workspaceSlug, projectId, cycleId, {
|
||||
issues: issueIds,
|
||||
});
|
||||
|
||||
return issueToCycle;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
@ -274,13 +281,13 @@ export class CycleIssues extends IssueHelperStore implements ICycleIssues {
|
||||
|
||||
removeIssueFromCycle = async (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => {
|
||||
try {
|
||||
const response = await this.issueService.removeIssueFromCycle(workspaceSlug, projectId, cycleId, issueId);
|
||||
runInAction(() => {
|
||||
pull(this.issues[cycleId], issueId);
|
||||
});
|
||||
|
||||
const issueIndex = this.issues[cycleId].findIndex((_issueId) => _issueId === issueId);
|
||||
if (issueIndex >= 0)
|
||||
runInAction(() => {
|
||||
this.issues[cycleId].splice(issueIndex, 1);
|
||||
});
|
||||
this.rootStore.issues.updateIssue(issueId, { cycle_id: null });
|
||||
|
||||
const response = await this.issueService.removeIssueFromCycle(workspaceSlug, projectId, cycleId, issueId);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { computed, makeObservable, observable, runInAction } from "mobx";
|
||||
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import set from "lodash/set";
|
||||
// base class
|
||||
@ -11,6 +11,7 @@ import {
|
||||
IIssueFilterOptions,
|
||||
IIssueDisplayFilterOptions,
|
||||
IIssueDisplayProperties,
|
||||
TIssueKanbanFilters,
|
||||
IIssueFilters,
|
||||
TIssueParams,
|
||||
} from "@plane/types";
|
||||
@ -31,7 +32,7 @@ export interface IDraftIssuesFilter {
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
filterType: EIssueFilterType,
|
||||
filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties
|
||||
filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
@ -51,6 +52,9 @@ export class DraftIssuesFilter extends IssueFilterHelperStore implements IDraftI
|
||||
// computed
|
||||
issueFilters: computed,
|
||||
appliedFilters: computed,
|
||||
// actions
|
||||
fetchFilters: action,
|
||||
updateFilters: action,
|
||||
});
|
||||
// root store
|
||||
this.rootIssueStore = _rootStore;
|
||||
@ -95,11 +99,18 @@ export class DraftIssuesFilter extends IssueFilterHelperStore implements IDraftI
|
||||
const filters: IIssueFilterOptions = this.computedFilters(_filters?.filters);
|
||||
const displayFilters: IIssueDisplayFilterOptions = this.computedDisplayFilters(_filters?.display_filters);
|
||||
const displayProperties: IIssueDisplayProperties = this.computedDisplayProperties(_filters?.display_properties);
|
||||
const kanbanFilters = {
|
||||
group_by: [],
|
||||
sub_group_by: [],
|
||||
};
|
||||
kanbanFilters.group_by = _filters?.kanban_filters?.group_by || [];
|
||||
kanbanFilters.sub_group_by = _filters?.kanban_filters?.sub_group_by || [];
|
||||
|
||||
runInAction(() => {
|
||||
set(this.filters, [projectId, "filters"], filters);
|
||||
set(this.filters, [projectId, "displayFilters"], displayFilters);
|
||||
set(this.filters, [projectId, "displayProperties"], displayProperties);
|
||||
set(this.filters, [projectId, "kanbanFilters"], kanbanFilters);
|
||||
});
|
||||
} catch (error) {
|
||||
throw error;
|
||||
@ -110,7 +121,7 @@ export class DraftIssuesFilter extends IssueFilterHelperStore implements IDraftI
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
type: EIssueFilterType,
|
||||
filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties
|
||||
filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters
|
||||
) => {
|
||||
try {
|
||||
if (isEmpty(this.filters) || isEmpty(this.filters[projectId]) || isEmpty(filters)) return;
|
||||
@ -119,6 +130,7 @@ export class DraftIssuesFilter extends IssueFilterHelperStore implements IDraftI
|
||||
filters: this.filters[projectId].filters as IIssueFilterOptions,
|
||||
displayFilters: this.filters[projectId].displayFilters as IIssueDisplayFilterOptions,
|
||||
displayProperties: this.filters[projectId].displayProperties as IIssueDisplayProperties,
|
||||
kanbanFilters: this.filters[projectId].kanbanFilters as TIssueKanbanFilters,
|
||||
};
|
||||
|
||||
switch (type) {
|
||||
@ -132,7 +144,7 @@ export class DraftIssuesFilter extends IssueFilterHelperStore implements IDraftI
|
||||
});
|
||||
});
|
||||
|
||||
this.rootIssueStore.projectIssues.fetchIssues(workspaceSlug, projectId, "mutation");
|
||||
this.rootIssueStore.draftIssues.fetchIssues(workspaceSlug, projectId, "mutation");
|
||||
this.handleIssuesLocalFilters.set(EIssuesStoreType.DRAFT, type, workspaceSlug, projectId, undefined, {
|
||||
filters: _filters.filters,
|
||||
});
|
||||
@ -193,6 +205,28 @@ export class DraftIssuesFilter extends IssueFilterHelperStore implements IDraftI
|
||||
display_properties: _filters.displayProperties,
|
||||
});
|
||||
break;
|
||||
|
||||
case EIssueFilterType.KANBAN_FILTERS:
|
||||
const updatedKanbanFilters = filters as TIssueKanbanFilters;
|
||||
_filters.kanbanFilters = { ..._filters.kanbanFilters, ...updatedKanbanFilters };
|
||||
|
||||
const currentUserId = this.rootIssueStore.currentUserId;
|
||||
if (currentUserId)
|
||||
this.handleIssuesLocalFilters.set(EIssuesStoreType.PROJECT, type, workspaceSlug, projectId, undefined, {
|
||||
kanban_filters: _filters.kanbanFilters,
|
||||
});
|
||||
|
||||
runInAction(() => {
|
||||
Object.keys(updatedKanbanFilters).forEach((_key) => {
|
||||
set(
|
||||
this.filters,
|
||||
[projectId, "kanbanFilters", _key],
|
||||
updatedKanbanFilters[_key as keyof TIssueKanbanFilters]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
@ -6,6 +6,7 @@ import {
|
||||
IIssueFilterOptions,
|
||||
IIssueFilters,
|
||||
IIssueFiltersResponse,
|
||||
TIssueKanbanFilters,
|
||||
TIssueParams,
|
||||
} from "@plane/types";
|
||||
// constants
|
||||
@ -17,7 +18,7 @@ import { storage } from "lib/local-storage";
|
||||
interface ILocalStoreIssueFilters {
|
||||
key: EIssuesStoreType;
|
||||
workspaceSlug: string;
|
||||
projectId: string | undefined;
|
||||
viewId: string | undefined; // It can be projectId, moduleId, cycleId, projectViewId
|
||||
userId: string | undefined;
|
||||
filters: IIssueFilters;
|
||||
}
|
||||
@ -46,6 +47,7 @@ export class IssueFilterHelperStore implements IIssueFilterHelperStore {
|
||||
filters: isEmpty(filters?.filters) ? undefined : filters?.filters,
|
||||
displayFilters: isEmpty(filters?.displayFilters) ? undefined : filters?.displayFilters,
|
||||
displayProperties: isEmpty(filters?.displayProperties) ? undefined : filters?.displayProperties,
|
||||
kanbanFilters: isEmpty(filters?.kanbanFilters) ? undefined : filters?.kanbanFilters,
|
||||
});
|
||||
|
||||
/**
|
||||
@ -157,7 +159,7 @@ export class IssueFilterHelperStore implements IIssueFilterHelperStore {
|
||||
get: (
|
||||
currentView: EIssuesStoreType,
|
||||
workspaceSlug: string,
|
||||
projectId: string | undefined,
|
||||
viewId: string | undefined, // It can be projectId, moduleId, cycleId, projectViewId
|
||||
userId: string | undefined
|
||||
) => {
|
||||
const storageFilters = this.handleIssuesLocalFilters.fetchFiltersFromStorage();
|
||||
@ -165,28 +167,28 @@ export class IssueFilterHelperStore implements IIssueFilterHelperStore {
|
||||
(filter: ILocalStoreIssueFilters) =>
|
||||
filter.key === currentView &&
|
||||
filter.workspaceSlug === workspaceSlug &&
|
||||
filter.projectId === projectId &&
|
||||
filter.viewId === viewId &&
|
||||
filter.userId === userId
|
||||
);
|
||||
if (!currentFilterIndex && currentFilterIndex.length < 0) return undefined;
|
||||
|
||||
return storageFilters[currentFilterIndex];
|
||||
return storageFilters[currentFilterIndex]?.filters || {};
|
||||
},
|
||||
|
||||
set: (
|
||||
currentView: EIssuesStoreType,
|
||||
filterType: EIssueFilterType,
|
||||
workspaceSlug: string,
|
||||
projectId: string | undefined,
|
||||
viewId: string | undefined, // It can be projectId, moduleId, cycleId, projectViewId
|
||||
userId: string | undefined,
|
||||
filters: Partial<IIssueFiltersResponse>
|
||||
filters: Partial<IIssueFiltersResponse & { kanban_filters: TIssueKanbanFilters }>
|
||||
) => {
|
||||
const storageFilters = this.handleIssuesLocalFilters.fetchFiltersFromStorage();
|
||||
const currentFilterIndex = storageFilters.findIndex(
|
||||
(filter: ILocalStoreIssueFilters) =>
|
||||
filter.key === currentView &&
|
||||
filter.workspaceSlug === workspaceSlug &&
|
||||
filter.projectId === projectId &&
|
||||
filter.viewId === viewId &&
|
||||
filter.userId === userId
|
||||
);
|
||||
|
||||
@ -194,14 +196,17 @@ export class IssueFilterHelperStore implements IIssueFilterHelperStore {
|
||||
storageFilters.push({
|
||||
key: currentView,
|
||||
workspaceSlug: workspaceSlug,
|
||||
projectId: projectId,
|
||||
viewId: viewId,
|
||||
userId: userId,
|
||||
filters: filters,
|
||||
});
|
||||
else
|
||||
storageFilters[currentFilterIndex] = {
|
||||
...storageFilters[currentFilterIndex],
|
||||
[filterType]: filters,
|
||||
filters: {
|
||||
...storageFilters[currentFilterIndex].filters,
|
||||
[filterType]: filters[filterType],
|
||||
},
|
||||
};
|
||||
|
||||
storage.set("issue_local_filters", JSON.stringify(storageFilters));
|
||||
|
@ -49,11 +49,18 @@ export class IssueStore implements IIssueStore {
|
||||
// actions
|
||||
fetchIssue = async (workspaceSlug: string, projectId: string, issueId: string) => {
|
||||
try {
|
||||
const issue = await this.issueService.retrieve(workspaceSlug, projectId, issueId);
|
||||
const query = {
|
||||
expand: "state,assignees,labels,parent",
|
||||
};
|
||||
const issue = (await this.issueService.retrieve(workspaceSlug, projectId, issueId, query)) as any;
|
||||
if (!issue) throw new Error("Issue not found");
|
||||
|
||||
this.rootIssueDetailStore.rootIssueStore.issues.addIssue([issue]);
|
||||
|
||||
// store handlers from issue detail
|
||||
if (issue && issue?.parent && issue?.parent?.id)
|
||||
this.rootIssueDetailStore.rootIssueStore.issues.addIssue([issue?.parent]);
|
||||
|
||||
// issue reactions
|
||||
this.rootIssueDetailStore.reaction.fetchReactions(workspaceSlug, projectId, issueId);
|
||||
|
||||
|
@ -134,10 +134,10 @@ export class IssueLinkStore implements IIssueLinkStore {
|
||||
try {
|
||||
const response = await this.issueService.deleteIssueLink(workspaceSlug, projectId, issueId, linkId);
|
||||
|
||||
const reactionIndex = this.links[issueId].findIndex((_comment) => _comment === linkId);
|
||||
if (reactionIndex >= 0)
|
||||
const linkIndex = this.links[issueId].findIndex((_comment) => _comment === linkId);
|
||||
if (linkIndex >= 0)
|
||||
runInAction(() => {
|
||||
this.links[issueId].splice(reactionIndex, 1);
|
||||
this.links[issueId].splice(linkIndex, 1);
|
||||
delete this.linkMap[linkId];
|
||||
});
|
||||
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user