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:
guru_sainath 2024-01-10 20:09:45 +05:30 committed by GitHub
parent 09603cf189
commit 73eed69aa6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
112 changed files with 3303 additions and 2560 deletions

View File

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

View File

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

View File

@ -222,7 +222,7 @@ export type GroupByColumnTypes =
export interface IGroupByColumn {
id: string;
name: string;
Icon: ReactElement | undefined;
icon: ReactElement | undefined;
payload: Partial<TIssue>;
}

View File

@ -17,5 +17,5 @@ export type TIssueReactionMap = {
};
export type TIssueReactionIdMap = {
[issue_id: string]: string[];
[issue_id: string]: { [reaction: string]: string[] };
};

View File

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

View File

@ -29,4 +29,8 @@ export interface IWorkspaceView {
};
}
export type TStaticViewTypes = "all-issues" | "assigned" | "created" | "subscribed";
export type TStaticViewTypes =
| "all-issues"
| "assigned"
| "created"
| "subscribed";

View File

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

View File

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

View File

@ -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>
)}

View File

@ -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>
) : (

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

@ -0,0 +1,5 @@
export * from "./root";
export * from "./label-list";
export * from "./label-list-item";
export * from "./create-label";

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

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

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

View File

@ -0,0 +1,9 @@
import { FC } from "react";
type TLabelExistingSelect = {};
export const LabelExistingSelect: FC<TLabelExistingSelect> = (props) => {
const {} = props;
return <></>;
};

View File

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

View File

@ -0,0 +1,4 @@
export * from "./root";
export * from "./links";
export * from "./link-detail";

View File

@ -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" />

View File

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

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

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

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

View File

@ -0,0 +1,4 @@
export * from "./root";
export * from "./siblings";
export * from "./sibling-item";

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

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

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

View File

@ -0,0 +1,4 @@
export * from "./reaction-selector";
export * from "./issue";
// export * from "./issue-comment";

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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} />

View File

@ -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} />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 */}

View File

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

View File

@ -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" ? (

View File

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

View File

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

View File

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

View File

@ -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" ? (

View File

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

View File

@ -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: {},
};
});

View File

@ -1 +0,0 @@
export * from "./root";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +0,0 @@
export * from "./relation";
export * from "./cycle";
export * from "./label";
export * from "./module";
export * from "./parent";

View File

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

View File

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

View File

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

View File

@ -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",

View File

@ -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",

View File

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

View File

@ -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) => {

View File

@ -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) => {

View File

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

View File

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

View File

@ -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: {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -38,6 +38,7 @@ const ModuleIssuesPage: NextPageWithLayout = () => {
setValue(`${!isSidebarCollapsed}`);
};
if (!workspaceSlug || !projectId || !moduleId) return <></>;
return (
<>
{error ? (

View File

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

View File

@ -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) => {

View File

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

View File

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

View File

@ -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) {

View File

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

View File

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

View File

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

View File

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