diff --git a/apiserver/plane/app/serializers/base.py b/apiserver/plane/app/serializers/base.py index f67f5cf52..d25d3b4b0 100644 --- a/apiserver/plane/app/serializers/base.py +++ b/apiserver/plane/app/serializers/base.py @@ -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: diff --git a/apiserver/plane/app/serializers/issue.py b/apiserver/plane/app/serializers/issue.py index f9b5b579f..64ae9ccfb 100644 --- a/apiserver/plane/app/serializers/issue.py +++ b/apiserver/plane/app/serializers/issue.py @@ -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 diff --git a/packages/types/src/issues.d.ts b/packages/types/src/issues.d.ts index c9376b34b..b6ab05f2c 100644 --- a/packages/types/src/issues.d.ts +++ b/packages/types/src/issues.d.ts @@ -222,7 +222,7 @@ export type GroupByColumnTypes = export interface IGroupByColumn { id: string; name: string; - Icon: ReactElement | undefined; + icon: ReactElement | undefined; payload: Partial; } diff --git a/packages/types/src/issues/issue_reaction.d.ts b/packages/types/src/issues/issue_reaction.d.ts index 2fe646246..6fc071a9f 100644 --- a/packages/types/src/issues/issue_reaction.d.ts +++ b/packages/types/src/issues/issue_reaction.d.ts @@ -17,5 +17,5 @@ export type TIssueReactionMap = { }; export type TIssueReactionIdMap = { - [issue_id: string]: string[]; + [issue_id: string]: { [reaction: string]: string[] }; }; diff --git a/packages/types/src/view-props.d.ts b/packages/types/src/view-props.d.ts index 282fc5a9c..7f1d49632 100644 --- a/packages/types/src/view-props.d.ts +++ b/packages/types/src/view-props.d.ts @@ -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 { diff --git a/packages/types/src/workspace-views.d.ts b/packages/types/src/workspace-views.d.ts index 29aa56742..e270f4f69 100644 --- a/packages/types/src/workspace-views.d.ts +++ b/packages/types/src/workspace-views.d.ts @@ -29,4 +29,8 @@ export interface IWorkspaceView { }; } -export type TStaticViewTypes = "all-issues" | "assigned" | "created" | "subscribed"; +export type TStaticViewTypes = + | "all-issues" + | "assigned" + | "created" + | "subscribed"; diff --git a/web/components/core/index.ts b/web/components/core/index.ts index ff0fabc4e..4f99f3606 100644 --- a/web/components/core/index.ts +++ b/web/components/core/index.ts @@ -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"; diff --git a/web/components/core/sidebar/sidebar-progress-stats.tsx b/web/components/core/sidebar/sidebar-progress-stats.tsx index 6d89981cd..698695ee4 100644 --- a/web/components/core/sidebar/sidebar-progress-stats.tsx +++ b/web/components/core/sidebar/sidebar-progress-stats.tsx @@ -126,7 +126,7 @@ export const SidebarProgressStats: React.FC = ({ - {distribution.assignees.length > 0 ? ( + {distribution?.assignees.length > 0 ? ( distribution.assignees.map((assignee, index) => { if (assignee.assignee_id) return ( diff --git a/web/components/gantt-chart/sidebar/sidebar.tsx b/web/components/gantt-chart/sidebar/sidebar.tsx index c366bcfed..88a138a1b 100644 --- a/web/components/gantt-chart/sidebar/sidebar.tsx +++ b/web/components/gantt-chart/sidebar/sidebar.tsx @@ -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) => { {droppableProvided.placeholder} {enableQuickIssueCreate && !disableIssueCreation && ( - + )} )} diff --git a/web/components/inbox/main-content.tsx b/web/components/inbox/main-content.tsx index c43a132ec..97346d0a0 100644 --- a/web/components/inbox/main-content.tsx +++ b/web/components/inbox/main-content.tsx @@ -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(() => { )} -
+ + {/* FIXME: have to replace this once the issue details page is ready --issue-detail-- */} + {/*
setIsSubmitting(value)} isSubmitting={isSubmitting} @@ -239,26 +247,28 @@ export const InboxMainContent: React.FC = observer(() => { handleFormSubmit={submitChanges} isAllowed={isAllowed || currentUser?.id === issueDetails.created_by} /> -
+
*/} - {workspaceSlug && projectId && ( + {/* FIXME: have to replace this once the issue details page is ready --issue-detail-- */} + {/* {workspaceSlug && projectId && ( - )} + )} */}
- + /> */}
) : ( diff --git a/web/components/issues/attachment/attachment-upload.tsx b/web/components/issues/attachment/attachment-upload.tsx index c53574cb4..264f0c643 100644 --- a/web/components/issues/attachment/attachment-upload.tsx +++ b/web/components/issues/attachment/attachment-upload.tsx @@ -11,15 +11,15 @@ import { TAttachmentOperations } from "./root"; type TAttachmentOperationsModal = Exclude; type Props = { + workspaceSlug: string; disabled?: boolean; handleAttachmentOperations: TAttachmentOperationsModal; }; export const IssueAttachmentUpload: React.FC = observer((props) => { - const { disabled = false, handleAttachmentOperations } = props; + const { workspaceSlug, disabled = false, handleAttachmentOperations } = props; // store hooks const { - router: { workspaceSlug }, config: { envConfig }, } = useApplication(); // states diff --git a/web/components/issues/attachment/root.tsx b/web/components/issues/attachment/root.tsx index ac92bb5b6..209058f9f 100644 --- a/web/components/issues/attachment/root.tsx +++ b/web/components/issues/attachment/root.tsx @@ -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 = (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 = (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 = (props) => { } }, }), - [workspaceSlug, projectId, peekIssue, createAttachment, removeAttachment, setToastAlert] + [workspaceSlug, projectId, issueId, createAttachment, removeAttachment, setToastAlert] ); return (

Attachments

- +
diff --git a/web/components/issues/comment/comment-reaction.tsx b/web/components/issues/comment/comment-reaction.tsx index eb80b0323..a59337575 100644 --- a/web/components/issues/comment/comment-reaction.tsx +++ b/web/components/issues/comment/comment-reaction.tsx @@ -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 = observer((props) => { return (
- {!readonly && ( + {/* FIXME: have to replace this once the issue details page is ready --issue-detail-- */} + {/* {!readonly && ( = observer((props) => { } onSelect={handleReactionClick} /> - )} + )} */} {Object.keys(groupedReactions || {}).map( (reaction) => diff --git a/web/components/issues/description-form.tsx b/web/components/issues/description-form.tsx index 3f463496e..cd678735d 100644 --- a/web/components/issues/description-form.tsx +++ b/web/components/issues/description-form.tsx @@ -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; + 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 = (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 = (props) => { async (formData: Partial) => { 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 ?? "

", }); }, - [handleFormSubmit] + [workspaceSlug, projectId, issueId, issueOperations] ); useEffect(() => { diff --git a/web/components/issues/index.ts b/web/components/issues/index.ts index b8af27d40..f1c6636cd 100644 --- a/web/components/issues/index.ts +++ b/web/components/issues/index.ts @@ -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"; diff --git a/web/components/issues/issue-detail/cycle-select.tsx b/web/components/issues/issue-detail/cycle-select.tsx new file mode 100644 index 000000000..24ed1c963 --- /dev/null +++ b/web/components/issues/issue-detail/cycle-select.tsx @@ -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 = 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: ( +
+ + + + {cycle.name} +
+ ) as ReactNode, + }; + }) + .filter((cycle) => cycle !== undefined) as TDropdownOptions) + : undefined; + + return ( +
+ handleIssueCycleChange(value)} + options={options} + customButton={ +
+ + + +
+ } + width="max-w-[10rem]" + noChevron + disabled={disableSelect} + /> + {isUpdating && } +
+ ); +}); diff --git a/web/components/issues/issue-detail/index.ts b/web/components/issues/issue-detail/index.ts new file mode 100644 index 000000000..63ef560a1 --- /dev/null +++ b/web/components/issues/issue-detail/index.ts @@ -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"; diff --git a/web/components/issues/issue-detail/label/create-label.tsx b/web/components/issues/issue-detail/label/create-label.tsx new file mode 100644 index 000000000..94af347d6 --- /dev/null +++ b/web/components/issues/issue-detail/label/create-label.tsx @@ -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 = { + name: "", + color: "#ff0000", +}; + +export const LabelCreate: FC = (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>({ + defaultValues, + }); + + useEffect(() => { + if (!isCreateToggle) return; + + setFocus("name"); + reset(); + }, [isCreateToggle, reset, setFocus]); + + const handleLabel = async (formData: Partial) => { + 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 ( + <> +
+
+ {isCreateToggle ? ( + + ) : ( + + )} +
+
{isCreateToggle ? "Cancel" : "New"}
+
+ + {isCreateToggle && ( +
+
+ ( + + <> + + {value && value?.trim() !== "" && ( + + )} + + + + + onChange(value.hex)} /> + + + + + )} + /> +
+ ( + + )} + /> + + + + )} + + ); +}; diff --git a/web/components/issues/issue-detail/label/index.ts b/web/components/issues/issue-detail/label/index.ts new file mode 100644 index 000000000..005620ddd --- /dev/null +++ b/web/components/issues/issue-detail/label/index.ts @@ -0,0 +1,5 @@ +export * from "./root"; + +export * from "./label-list"; +export * from "./label-list-item"; +export * from "./create-label"; diff --git a/web/components/issues/issue-detail/label/label-list-item.tsx b/web/components/issues/issue-detail/label/label-list-item.tsx new file mode 100644 index 000000000..3368e9a56 --- /dev/null +++ b/web/components/issues/issue-detail/label/label-list-item.tsx @@ -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 = (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 ( +
+
+
{label.name}
+
+ +
+
+ ); +}; diff --git a/web/components/issues/issue-detail/label/label-list.tsx b/web/components/issues/issue-detail/label/label-list.tsx new file mode 100644 index 000000000..b29e9b920 --- /dev/null +++ b/web/components/issues/issue-detail/label/label-list.tsx @@ -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 = (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) => ( + + ))} + + ); +}; diff --git a/web/components/issues/issue-detail/label/root.tsx b/web/components/issues/issue-detail/label/root.tsx new file mode 100644 index 000000000..f0ffdd19d --- /dev/null +++ b/web/components/issues/issue-detail/label/root.tsx @@ -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) => Promise; + createLabel: (workspaceSlug: string, projectId: string, data: Partial) => Promise; +}; + +export const IssueLabel: FC = 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) => { + 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) => { + 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 ( +
+ + + {/*
select existing labels
*/} + + +
+ ); +}); diff --git a/web/components/issues/issue-detail/label/select-existing.tsx b/web/components/issues/issue-detail/label/select-existing.tsx new file mode 100644 index 000000000..f4c287e86 --- /dev/null +++ b/web/components/issues/issue-detail/label/select-existing.tsx @@ -0,0 +1,9 @@ +import { FC } from "react"; + +type TLabelExistingSelect = {}; + +export const LabelExistingSelect: FC = (props) => { + const {} = props; + + return <>; +}; diff --git a/web/components/issues/issue-links/create-update-link-modal.tsx b/web/components/issues/issue-detail/links/create-update-link-modal.tsx similarity index 99% rename from web/components/issues/issue-links/create-update-link-modal.tsx rename to web/components/issues/issue-detail/links/create-update-link-modal.tsx index 1cbcd4656..fc9eb3838 100644 --- a/web/components/issues/issue-links/create-update-link-modal.tsx +++ b/web/components/issues/issue-detail/links/create-update-link-modal.tsx @@ -42,7 +42,7 @@ export const IssueLinkCreateUpdateModal: FC = (props) const onClose = () => { handleModal(false); const timeout = setTimeout(() => { - reset(defaultValues); + reset(preloadedData ? preloadedData : defaultValues); clearTimeout(timeout); }, 500); }; diff --git a/web/components/issues/issue-detail/links/index.ts b/web/components/issues/issue-detail/links/index.ts new file mode 100644 index 000000000..4a06c89af --- /dev/null +++ b/web/components/issues/issue-detail/links/index.ts @@ -0,0 +1,4 @@ +export * from "./root"; + +export * from "./links"; +export * from "./link-detail"; diff --git a/web/components/issues/issue-links/link-detail.tsx b/web/components/issues/issue-detail/links/link-detail.tsx similarity index 94% rename from web/components/issues/issue-links/link-detail.tsx rename to web/components/issues/issue-detail/links/link-detail.tsx index 3a5fdc224..c92c13977 100644 --- a/web/components/issues/issue-links/link-detail.tsx +++ b/web/components/issues/issue-detail/links/link-detail.tsx @@ -23,13 +23,17 @@ export const IssueLinkDetail: FC = (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 = (props) => { onClick={(e) => { e.preventDefault(); e.stopPropagation(); - setIsIssueLinkModalOpen(true); + toggleIssueLinkModal(true); }} > diff --git a/web/components/issues/issue-links/links.tsx b/web/components/issues/issue-detail/links/links.tsx similarity index 100% rename from web/components/issues/issue-links/links.tsx rename to web/components/issues/issue-detail/links/links.tsx diff --git a/web/components/issues/issue-links/root.tsx b/web/components/issues/issue-detail/links/root.tsx similarity index 63% rename from web/components/issues/issue-links/root.tsx rename to web/components/issues/issue-detail/links/root.tsx index d4e948bb2..5a0fb2bdf 100644 --- a/web/components/issues/issue-links/root.tsx +++ b/web/components/issues/issue-detail/links/root.tsx @@ -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 = (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 = (props) => { () => ({ create: async (data: Partial) => { 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 = (props) => { }, update: async (linkId: string, data: Partial) => { 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 = (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 = (props) => { } }, }), - [workspaceSlug, projectId, peekIssue, createLink, updateLink, removeLink, setToastAlert] + [workspaceSlug, projectId, issueId, createLink, updateLink, removeLink, setToastAlert, toggleIssueLinkModal] ); return ( <> -
+

Links

- {isAllowed && ( + {is_editable && ( diff --git a/web/components/issues/issue-detail/main-content.tsx b/web/components/issues/issue-detail/main-content.tsx new file mode 100644 index 000000000..116a0a006 --- /dev/null +++ b/web/components/issues/issue-detail/main-content.tsx @@ -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 = 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 ( + <> +
+ {issue.parent_id && ( + + )} + +
+ {currentIssueState && ( + + )} + +
+ + setIsSubmitting(value)} + isSubmitting={isSubmitting} + issue={issue} + issueOperations={issueOperations} + isAllowed={isAllowed || !is_editable} + /> + + {currentUser && ( + + )} + + {currentUser && ( + + )} +
+ + {/* issue attachments */} + + + {/*
+

Comments/Activity

+ + +
*/} + + ); +}); diff --git a/web/components/issues/issue-detail/module-select.tsx b/web/components/issues/issue-detail/module-select.tsx new file mode 100644 index 000000000..4ac5f1fa5 --- /dev/null +++ b/web/components/issues/issue-detail/module-select.tsx @@ -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 = 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: ( +
+ + + + {_module.name} +
+ ) as ReactNode, + }; + }) + .filter((_module) => _module !== undefined) as TDropdownOptions) + : undefined; + + return ( +
+ handleIssueModuleChange(value)} + options={options} + customButton={ +
+ + + +
+ } + width="max-w-[10rem]" + noChevron + disabled={disableSelect} + /> + {isUpdating && } +
+ ); +}); diff --git a/web/components/issues/issue-detail/parent-select.tsx b/web/components/issues/issue-detail/parent-select.tsx new file mode 100644 index 000000000..ad1bb6dda --- /dev/null +++ b/web/components/issues/issue-detail/parent-select.tsx @@ -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 = 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 ( +
+ toggleParentIssueModal(false)} + onChange={(issue: any) => handleParentIssue(issue?.id)} + /> + + + + {updating && } +
+ ); + } +); diff --git a/web/components/issues/issue-detail/parent/index.ts b/web/components/issues/issue-detail/parent/index.ts new file mode 100644 index 000000000..1b5a96749 --- /dev/null +++ b/web/components/issues/issue-detail/parent/index.ts @@ -0,0 +1,4 @@ +export * from "./root"; + +export * from "./siblings"; +export * from "./sibling-item"; diff --git a/web/components/issues/issue-detail/parent/root.tsx b/web/components/issues/issue-detail/parent/root.tsx new file mode 100644 index 000000000..2176ccecc --- /dev/null +++ b/web/components/issues/issue-detail/parent/root.tsx @@ -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 = (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 ( + <> +
+ +
+
+ + + {getProjectById(parentIssue.project_id)?.identifier}-{parentIssue?.sequence_id} + +
+ {(parentIssue?.name ?? "").substring(0, 50)} +
+ + + +
+ Sibling issues +
+ + + + issueOperations.update(workspaceSlug, projectId, issueId, { parent_id: null })} + className="flex items-center gap-2 py-2 text-red-500" + > + + Remove Parent Issue + +
+
+ + ); +}; diff --git a/web/components/issues/issue-detail/parent/sibling-item.tsx b/web/components/issues/issue-detail/parent/sibling-item.tsx new file mode 100644 index 000000000..cbcf4741b --- /dev/null +++ b/web/components/issues/issue-detail/parent/sibling-item.tsx @@ -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 = (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 ( + <> + + + + {projectDetails?.identifier}-{issueDetail.sequence_id} + + + + ); +}; diff --git a/web/components/issues/issue-detail/parent/siblings.tsx b/web/components/issues/issue-detail/parent/siblings.tsx new file mode 100644 index 000000000..b8ebc9ec9 --- /dev/null +++ b/web/components/issues/issue-detail/parent/siblings.tsx @@ -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 = (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 ( +
+ {isLoading ? ( +
+ Loading +
+ ) : subIssueIds && subIssueIds.length > 0 ? ( + subIssueIds.map((issueId) => currentIssue.id != issueId && ) + ) : ( +
+ No sibling issues +
+ )} +
+ ); +}; diff --git a/web/components/issues/issue-detail/reactions/index.ts b/web/components/issues/issue-detail/reactions/index.ts new file mode 100644 index 000000000..8dc6f05bd --- /dev/null +++ b/web/components/issues/issue-detail/reactions/index.ts @@ -0,0 +1,4 @@ +export * from "./reaction-selector"; + +export * from "./issue"; +// export * from "./issue-comment"; diff --git a/web/components/issues/issue-detail/reactions/issue.tsx b/web/components/issues/issue-detail/reactions/issue.tsx new file mode 100644 index 000000000..1627a6730 --- /dev/null +++ b/web/components/issues/issue-detail/reactions/issue.tsx @@ -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 = 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 ( +
+ + + {reactionIds && + Object.keys(reactionIds || {}).map( + (reaction) => + reactionIds[reaction]?.length > 0 && ( + <> + + + ) + )} +
+ ); +}); diff --git a/web/components/core/reaction-selector.tsx b/web/components/issues/issue-detail/reactions/reaction-selector.tsx similarity index 100% rename from web/components/core/reaction-selector.tsx rename to web/components/issues/issue-detail/reactions/reaction-selector.tsx diff --git a/web/components/issues/sidebar-select/relation.tsx b/web/components/issues/issue-detail/relation-select.tsx similarity index 95% rename from web/components/issues/sidebar-select/relation.tsx rename to web/components/issues/issue-detail/relation-select.tsx index 58e0d720b..801c04ebd 100644 --- a/web/components/issues/sidebar-select/relation.tsx +++ b/web/components/issues/issue-detail/relation-select.tsx @@ -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 = { }, }; -type Props = { +type TIssueRelationSelect = { + workspaceSlug: string; + projectId: string; issueId: string; relationKey: TIssueRelationTypes; disabled?: boolean; }; -export const SidebarIssueRelationSelect: React.FC = observer((props) => { - const { issueId, relationKey, disabled = false } = props; - // router - const router = useRouter(); - const { workspaceSlug, projectId } = router.query; +export const IssueRelationSelect: React.FC = observer((props) => { + const { workspaceSlug, projectId, issueId, relationKey, disabled = false } = props; // hooks const { currentUser } = useUser(); const { getProjectById } = useProject(); diff --git a/web/components/issues/issue-detail/root.tsx b/web/components/issues/issue-detail/root.tsx new file mode 100644 index 000000000..876f55369 --- /dev/null +++ b/web/components/issues/issue-detail/root.tsx @@ -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) => Promise; + remove: (workspaceSlug: string, projectId: string, issueId: string) => Promise; + addIssueToCycle: (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => Promise; + removeIssueFromCycle: (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => Promise; + addIssueToModule: (workspaceSlug: string, projectId: string, moduleId: string, issueIds: string[]) => Promise; + removeIssueFromModule: (workspaceSlug: string, projectId: string, moduleId: string, issueId: string) => Promise; +}; + +export type TIssueDetailRoot = { + workspaceSlug: string; + projectId: string; + issueId: string; + is_archived?: boolean; + is_editable?: boolean; +}; + +export const IssueDetailRoot: FC = (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) => { + 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 ? ( + router.push(`/${workspaceSlug}/projects/${projectId}/issues`), + }} + /> + ) : ( +
+
+ +
+
+ +
+
+ )} + + ); +}; diff --git a/web/components/issues/sidebar.tsx b/web/components/issues/issue-detail/sidebar.tsx similarity index 50% rename from web/components/issues/sidebar.tsx rename to web/components/issues/issue-detail/sidebar.tsx index 6b24b84b6..ce4071f06 100644 --- a/web/components/issues/sidebar.tsx +++ b/web/components/issues/issue-detail/sidebar.tsx @@ -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; + 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 = 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 = 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 = 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 && ( 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 = observer((props) => { ) : null}

- {projectDetails?.identifier}-{issueDetail?.sequence_id} + {projectDetails?.identifier}-{issue?.sequence_id}

+
- {issueDetail?.created_by !== currentUser?.id && - !issueDetail?.assignee_ids.includes(currentUser?.id ?? "") && - !router.pathname.includes("[archivedIssueId]") && - (fieldsToShow.includes("all") || fieldsToShow.includes("subscribe")) && ( - - )} - {(fieldsToShow.includes("all") || fieldsToShow.includes("link")) && ( + {currentUser && !is_archived && (fieldsToShow.includes("all") || fieldsToShow.includes("subscribe")) && ( + + )} + + {/* {(fieldsToShow.includes("all") || fieldsToShow.includes("link")) && ( - )} - {isAllowed && (fieldsToShow.includes("all") || fieldsToShow.includes("delete")) && ( + )} */} + + {/* {isAllowed && (fieldsToShow.includes("all") || fieldsToShow.includes("delete")) && ( - )} + )} */}
-
+
{showFirstSection && (
{(fieldsToShow.includes("all") || fieldsToShow.includes("state")) && ( @@ -247,71 +202,63 @@ export const IssueDetailsSidebar: React.FC = observer((props) => {

State

- ( -
- submitChanges({ state: val })} - projectId={projectId?.toString() ?? ""} - disabled={!isAllowed || uneditable} - buttonVariant="background-with-text" - /> -
- )} - /> + +
+ issueOperations.update(workspaceSlug, projectId, issueId, { state_id: val })} + projectId={projectId?.toString() ?? ""} + disabled={!isAllowed || !is_editable} + buttonVariant="background-with-text" + /> +
)} + {(fieldsToShow.includes("all") || fieldsToShow.includes("assignee")) && (

Assignees

- ( -
- 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" : ""} - /> -
- )} - /> + +
+ + 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" : ""} + /> +
)} + {(fieldsToShow.includes("all") || fieldsToShow.includes("priority")) && (

Priority

- ( -
- submitChanges({ priority: val })} - disabled={!isAllowed || uneditable} - buttonVariant="background-with-text" - /> -
- )} - /> + +
+ issueOperations.update(workspaceSlug, projectId, issueId, { priority: val })} + disabled={!isAllowed || !is_editable} + buttonVariant="background-with-text" + /> +
)} + {(fieldsToShow.includes("all") || fieldsToShow.includes("estimate")) && areEstimatesEnabledForCurrentProject && (
@@ -319,25 +266,23 @@ export const IssueDetailsSidebar: React.FC = observer((props) => {

Estimate

- ( -
- submitChanges({ estimate_point: val })} - projectId={projectId?.toString() ?? ""} - disabled={!isAllowed || uneditable} - buttonVariant="background-with-text" - /> -
- )} - /> + +
+ + issueOperations.update(workspaceSlug, projectId, issueId, { estimate_point: val }) + } + projectId={projectId} + disabled={!isAllowed || !is_editable} + buttonVariant="background-with-text" + /> +
)}
)} + {showSecondSection && (
{(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && ( @@ -347,53 +292,54 @@ export const IssueDetailsSidebar: React.FC = observer((props) => {

Parent

- ( - { - submitChanges({ parent: val }); - onChange(val); - }} - issueDetails={issueDetail} - disabled={!isAllowed || uneditable} - /> - )} +
)} {(fieldsToShow.includes("all") || fieldsToShow.includes("blocker")) && ( - )} {(fieldsToShow.includes("all") || fieldsToShow.includes("blocked")) && ( - )} {(fieldsToShow.includes("all") || fieldsToShow.includes("duplicate")) && ( - )} {(fieldsToShow.includes("all") || fieldsToShow.includes("relates_to")) && ( - )} @@ -404,27 +350,20 @@ export const IssueDetailsSidebar: React.FC = observer((props) => {

Start date

- ( - - submitChanges({ - start_date: val, - }) - } - className="border-none bg-custom-background-80" - maxDate={maxDate ?? undefined} - disabled={!isAllowed || uneditable} - /> - )} + + issueOperations.update(workspaceSlug, projectId, issueId, { start_date: val }) + } + className="border-none bg-custom-background-80" + maxDate={maxDate ?? undefined} + disabled={!isAllowed || !is_editable} />
)} + {(fieldsToShow.includes("all") || fieldsToShow.includes("dueDate")) && (
@@ -432,23 +371,15 @@ export const IssueDetailsSidebar: React.FC = observer((props) => {

Due date

- ( - - submitChanges({ - target_date: val, - }) - } - className="border-none bg-custom-background-80" - minDate={minDate ?? undefined} - disabled={!isAllowed || uneditable} - /> - )} + + issueOperations.update(workspaceSlug, projectId, issueId, { target_date: val }) + } + className="border-none bg-custom-background-80" + minDate={minDate ?? undefined} + disabled={!isAllowed || !is_editable} />
@@ -465,14 +396,17 @@ export const IssueDetailsSidebar: React.FC = observer((props) => {

Cycle

-
)} + {(fieldsToShow.includes("all") || fieldsToShow.includes("module")) && projectDetails?.module_view && (
@@ -480,10 +414,12 @@ export const IssueDetailsSidebar: React.FC = observer((props) => {

Module

-
@@ -499,19 +435,24 @@ export const IssueDetailsSidebar: React.FC = observer((props) => {

Label

-
)} {(fieldsToShow.includes("all") || fieldsToShow.includes("link")) && ( - + )} diff --git a/web/components/issues/issue-detail/subscription.tsx b/web/components/issues/issue-detail/subscription.tsx new file mode 100644 index 000000000..8f76eca25 --- /dev/null +++ b/web/components/issues/issue-detail/subscription.tsx @@ -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 = 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 ( +
+ +
+ ); +}); diff --git a/web/components/issues/issue-layouts/calendar/issue-blocks.tsx b/web/components/issues/issue-layouts/calendar/issue-blocks.tsx index be30560fb..a4d874430 100644 --- a/web/components/issues/issue-layouts/calendar/issue-blocks.tsx +++ b/web/components/issues/issue-layouts/calendar/issue-blocks.tsx @@ -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 = 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(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 = observer((props) => { {...provided.draggableProps} {...provided.dragHandleProps} ref={provided.innerRef} - onClick={() => handleIssuePeekOverview(issue)} > - {issue?.tempId !== undefined && ( -
- )} - -
handleIssuePeekOverview(issue)} + className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100" > -
- state?.id == issue?.state_id - )?.color, - }} - /> -
- {getProjectById(issue?.project_id)?.identifier}-{issue.sequence_id} + <> + {issue?.tempId !== undefined && ( +
+ )} + +
+
+ state?.id == issue?.state_id + )?.color, + }} + /> +
+ {getProjectById(issue?.project_id)?.identifier}-{issue.sequence_id} +
+ +
{issue.name}
+
+
+
{ + e.preventDefault(); + e.stopPropagation(); + }} + > + {quickActions(issue, customActionButton)} +
- -
{issue.name}
-
-
-
{ - e.preventDefault(); - e.stopPropagation(); - }} - > - {quickActions(issue, customActionButton)} -
-
+ +
)} diff --git a/web/components/issues/issue-layouts/gantt/blocks.tsx b/web/components/issues/issue-layouts/gantt/blocks.tsx index fd79cdde1..5622cd672 100644 --- a/web/components/issues/issue-layouts/gantt/blocks.tsx +++ b/web/components/issues/issue-layouts/gantt/blocks.tsx @@ -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 (
{ // 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 ( -
- {currentStateDetails != undefined && ( - - )} -
- {getProjectById(data?.project_id)?.identifier} {data?.sequence_id} + +
+ {currentStateDetails != undefined && ( + + )} +
+ {getProjectById(data?.project_id)?.identifier} {data?.sequence_id} +
+ + {data?.name} +
- - {data?.name} - -
+ ); }; diff --git a/web/components/issues/issue-layouts/gantt/quick-add-issue-form.tsx b/web/components/issues/issue-layouts/gantt/quick-add-issue-form.tsx index a370440f9..78f332ddd 100644 --- a/web/components/issues/issue-layouts/gantt/quick-add-issue-form.tsx +++ b/web/components/issues/issue-layouts/gantt/quick-add-issue-form.tsx @@ -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 = (props) => { + const { formKey, register, setFocus, projectDetail } = props; + + useEffect(() => { + setFocus(formKey); + }, [formKey, setFocus]); + + return ( +
+
{projectDetail?.identifier ?? "..."}
+ +
+ ); +}; + +type IGanttQuickAddIssueForm = { prePopulatedData?: Partial; onSuccess?: (data: TIssue) => Promise | void; quickAddCallback?: ( @@ -30,34 +59,25 @@ const defaultValues: Partial = { name: "", }; -const Inputs = (props: any) => { - const { register, setFocus } = props; - - useEffect(() => { - setFocus("name"); - }, [setFocus]); - - return ( - - ); -}; - -export const GanttInlineCreateIssueForm: React.FC = observer((props) => { +export const GanttQuickAddIssueForm: React.FC = 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(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 = observer((props) => { formState: { errors, isSubmitting }, } = useForm({ defaultValues }); - // ref - const ref = useRef(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 && ( -
-
-

{currentProjectDetails?.identifier ?? "..."}

- - - )} - - {isOpen && ( -

- Press {"'"}Enter{"'"} to add another issue -

- )} - - {!isOpen && ( - - )} +
+ {isOpen ? ( +
+
+ + +
{`Press 'Enter' to add another issue`}
+
+ ) : ( +
setIsOpen(true)} + > + + New Issue +
+ )} +
); }); diff --git a/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx b/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx index c262af2ca..4167619cc 100644 --- a/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx +++ b/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx @@ -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 = 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 = 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 = 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 ( <> = observer((props: IBas
)} -
+
+ {/* drag and delete component */}
= 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 = observer((props: IBas />
- - {/* {workspaceSlug && peekIssueId && peekProjectId && ( - await handleIssues(issueToUpdate as TIssue, EIssueActions.UPDATE)} - /> - )} */} ); }); diff --git a/web/components/issues/issue-layouts/kanban/block.tsx b/web/components/issues/issue-layouts/kanban/block.tsx index 682f3c416..40bd19cf1 100644 --- a/web/components/issues/issue-layouts/kanban/block.tsx +++ b/web/components/issues/issue-layouts/kanban/block.tsx @@ -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 = 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 = observer((prop
{quickActions(issue)}
- -
- {issue.name} -
-
+ + handleIssuePeekOverview(issue)} + className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100" + > + + {issue.name} + + + 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 = observer((props) => { isDragDisabled, handleIssues, quickActions, - kanBanToggle, - handleKanBanToggle, + kanbanFilters, + handleKanbanFilters, enableQuickIssueCreate, quickAddCallback, viewId, @@ -77,58 +78,63 @@ const GroupByKanBan: React.FC = 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 ( -
+
{list && list.length > 0 && list.map((_list: IGroupByColumn) => { - const verticalPosition = verticalAlignPosition(_list); + const groupByVisibilityToggle = visibilityGroupBy(_list); return (
{sub_group_by === null && ( -
+
)} - + + {!groupByVisibilityToggle && ( + + )}
); })} @@ -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 = observer((props) => { sub_group_id = "null", handleIssues, quickActions, - kanBanToggle, - handleKanBanToggle, + kanbanFilters, + handleKanbanFilters, enableQuickIssueCreate, quickAddCallback, viewId, @@ -186,27 +192,25 @@ export const KanBan: React.FC = observer((props) => { const issueKanBanView = useKanbanView(); return ( -
- -
+ ); }); diff --git a/web/components/issues/issue-layouts/kanban/headers/group-by-card.tsx b/web/components/issues/issue-layouts/kanban/headers/group-by-card.tsx index 4d4776d38..decc816f6 100644 --- a/web/components/issues/issue-layouts/kanban/headers/group-by-card.tsx +++ b/web/components/issues/issue-layouts/kanban/headers/group-by-card.tsx @@ -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; disableIssueCreation?: boolean; currentStore?: TCreateModalStoreTypes; @@ -36,14 +36,14 @@ export const HeaderGroupByCard: FC = 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 = observer((props) => { {sub_group_by === null && (
handleKanBanToggle("groupByHeaderMinMax", column_id)} + onClick={() => handleKanbanFilters("group_by", column_id)} > {verticalAlignPosition ? ( diff --git a/web/components/issues/issue-layouts/kanban/headers/sub-group-by-card.tsx b/web/components/issues/issue-layouts/kanban/headers/sub-group-by-card.tsx index de5e7abc4..ea9464780 100644 --- a/web/components/issues/issue-layouts/kanban/headers/sub-group-by-card.tsx +++ b/web/components/issues/issue-layouts/kanban/headers/sub-group-by-card.tsx @@ -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) => (
handleKanBanToggle("subgroupByIssuesVisibility", column_id)} + onClick={() => handleKanbanFilters("sub_group_by", column_id)} > - {kanBanToggle?.subgroupByIssuesVisibility.includes(column_id) ? ( + {kanbanFilters?.sub_group_by.includes(column_id) ? ( ) : ( diff --git a/web/components/issues/issue-layouts/kanban/kanban-group.tsx b/web/components/issues/issue-layouts/kanban/kanban-group.tsx index cbd1b1fc1..cec36ad0c 100644 --- a/web/components/issues/issue-layouts/kanban/kanban-group.tsx +++ b/web/components/issues/issue-layouts/kanban/kanban-group.tsx @@ -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 ( -
+
{(provided: any, snapshot: any) => (
- {!verticalPosition ? ( - - ) : null} + {provided.placeholder} + + {enableQuickIssueCreate && !disableIssueCreation && ( +
+ +
+ )}
)}
- -
- {enableQuickIssueCreate && !disableIssueCreation && !isGroupByCreatedBy && ( - - )} -
); }; diff --git a/web/components/issues/issue-layouts/kanban/quick-add-issue-form.tsx b/web/components/issues/issue-layouts/kanban/quick-add-issue-form.tsx index e98030018..21aeb3d9d 100644 --- a/web/components/issues/issue-layouts/kanban/quick-add-issue-form.tsx +++ b/web/components/issues/issue-layouts/kanban/quick-add-issue-form.tsx @@ -120,13 +120,13 @@ export const KanBanQuickAddIssueForm: React.FC = obser }; return ( -
+ <> {isOpen ? ( -
+
@@ -141,6 +141,6 @@ export const KanBanQuickAddIssueForm: React.FC = obser New Issue
)} -
+ ); }); diff --git a/web/components/issues/issue-layouts/kanban/swimlanes.tsx b/web/components/issues/issue-layouts/kanban/swimlanes.tsx index d65bbee57..7beba8a92 100644 --- a/web/components/issues/issue-layouts/kanban/swimlanes.tsx +++ b/web/components/issues/issue-layouts/kanban/swimlanes.tsx @@ -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 = ({ issueIds, sub_group_by, group_by, list, - kanBanToggle, - handleKanBanToggle, + kanbanFilters, + handleKanbanFilters, }) => (
{list && @@ -45,11 +46,11 @@ const SubGroupSwimlaneHeader: React.FC = ({ 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} />
@@ -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 = observer((props) => { handleIssues, quickActions, displayProperties, - kanBanToggle, - handleKanBanToggle, + kanbanFilters, + handleKanbanFilters, showEmptyGroup, enableQuickIssueCreate, canEditProperties, @@ -123,13 +124,14 @@ const SubGroupSwimlane: React.FC = observer((props) => { icon={_list.Icon} title={_list.name || ""} count={calculateIssueCount(_list.id)} - kanBanToggle={kanBanToggle} - handleKanBanToggle={handleKanBanToggle} + kanbanFilters={kanbanFilters} + handleKanbanFilters={handleKanbanFilters} />
- {!kanBanToggle?.subgroupByIssuesVisibility.includes(_list.id) && ( + + {!kanbanFilters?.sub_group_by.includes(_list.id) && (
= 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 = observer((props) => { group_by, handleIssues, quickActions, - kanBanToggle, - handleKanBanToggle, + kanbanFilters, + handleKanbanFilters, showEmptyGroup, isDragStarted, disableIssueCreation, @@ -227,8 +229,8 @@ export const KanBanSwimLanes: React.FC = observer((props) => { issueIds={issueIds} group_by={group_by} sub_group_by={sub_group_by} - kanBanToggle={kanBanToggle} - handleKanBanToggle={handleKanBanToggle} + kanbanFilters={kanbanFilters} + handleKanbanFilters={handleKanbanFilters} list={groupByList} />
@@ -243,8 +245,8 @@ export const KanBanSwimLanes: React.FC = 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} diff --git a/web/components/issues/issue-layouts/kanban/utils.ts b/web/components/issues/issue-layouts/kanban/utils.ts index e12fc2477..5c5de8c45 100644 --- a/web/components/issues/issue-layouts/kanban/utils.ts +++ b/web/components/issues/issue-layouts/kanban/utils.ts @@ -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; -}; diff --git a/web/components/issues/issue-layouts/list/base-list-root.tsx b/web/components/issues/issue-layouts/list/base-list-root.tsx index dc92aa7d2..87cafa3ad 100644 --- a/web/components/issues/issue-layouts/list/base-list-root.tsx +++ b/web/components/issues/issue-layouts/list/base-list-root.tsx @@ -138,15 +138,6 @@ export const BaseListRoot = observer((props: IBaseListRoot) => { addIssuesToView={addIssuesToView} />
- - {/* {workspaceSlug && peekIssueId && peekProjectId && ( - await handleIssues(issueToUpdate as TIssue, EIssueActions.UPDATE)} - /> - )} */} ); }); diff --git a/web/components/issues/issue-layouts/list/block.tsx b/web/components/issues/issue-layouts/list/block.tsx index 99138d8f9..820f98fdd 100644 --- a/web/components/issues/issue-layouts/list/block.tsx +++ b/web/components/issues/issue-layouts/list/block.tsx @@ -62,7 +62,7 @@ export const IssueBlock: React.FC = 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" > {issue.name} diff --git a/web/components/issues/issue-layouts/properties/all-properties.tsx b/web/components/issues/issue-layouts/properties/all-properties.tsx index 0df4b415e..e258c96fc 100644 --- a/web/components/issues/issue-layouts/properties/all-properties.tsx +++ b/web/components/issues/issue-layouts/properties/all-properties.tsx @@ -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 = 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 = observer((props) => { {/* label */} - = observer((props) => {
} @@ -148,17 +149,19 @@ export const IssueProperties: React.FC = observer((props) => { {/* estimates */} - -
- -
-
+ {areEstimatesEnabledForCurrentProject && ( + +
+ +
+
+ )} {/* extra render properties */} {/* sub-issues */} diff --git a/web/components/issues/issue-layouts/roots/archived-issue-layout-root.tsx b/web/components/issues/issue-layouts/roots/archived-issue-layout-root.tsx index 3b23351d9..bcd30555e 100644 --- a/web/components/issues/issue-layouts/roots/archived-issue-layout-root.tsx +++ b/web/components/issues/issue-layouts/roots/archived-issue-layout-root.tsx @@ -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 (
-
- -
+ + {issues?.loader === "init-loader" ? ( +
+ +
+ ) : ( + <> + {!issues?.groupedIssueIds ? ( + // TODO: Replace this with project view empty state + + ) : ( +
+ +
+ )} + + )}
); }); diff --git a/web/components/issues/issue-layouts/roots/cycle-layout-root.tsx b/web/components/issues/issue-layouts/roots/cycle-layout-root.tsx index bc63a8aaf..e5398ef90 100644 --- a/web/components/issues/issue-layouts/roots/cycle-layout-root.tsx +++ b/web/components/issues/issue-layouts/roots/cycle-layout-root.tsx @@ -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 ( <> setTransferIssuesModal(false)} isOpen={transferIssuesModal} /> @@ -59,14 +60,18 @@ export const CycleLayoutRoot: React.FC = observer(() => { {cycleStatus === "completed" && setTransferIssuesModal(true)} />} - {loader === "init-loader" || !groupedIssueIds ? ( + {issues?.loader === "init-loader" ? (
) : ( <> - {Object.keys(groupedIssueIds ?? {}).length == 0 ? ( - + {!issues?.groupedIssueIds ? ( + ) : (
{activeLayout === "list" ? ( diff --git a/web/components/issues/issue-layouts/roots/draft-issue-layout-root.tsx b/web/components/issues/issue-layouts/roots/draft-issue-layout-root.tsx index 79d4b0ac9..19a4da553 100644 --- a/web/components/issues/issue-layouts/roots/draft-issue-layout-root.tsx +++ b/web/components/issues/issue-layouts/roots/draft-issue-layout-root.tsx @@ -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 (
- {loader === "init-loader" ? ( + {issues?.loader === "init-loader" ? (
) : ( <> -
- {activeLayout === "list" ? ( - - ) : activeLayout === "kanban" ? ( - - ) : null} -
+ {!issues?.groupedIssueIds ? ( + // TODO: Replace this with project view empty state + + ) : ( +
+ {activeLayout === "list" ? ( + + ) : activeLayout === "kanban" ? ( + + ) : null} +
+ )} )}
diff --git a/web/components/issues/issue-layouts/roots/index.ts b/web/components/issues/issue-layouts/roots/index.ts index 72f71aae2..727e3e393 100644 --- a/web/components/issues/issue-layouts/roots/index.ts +++ b/web/components/issues/issue-layouts/roots/index.ts @@ -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"; diff --git a/web/components/issues/issue-layouts/roots/module-layout-root.tsx b/web/components/issues/issue-layouts/roots/module-layout-root.tsx index b14d3cb2f..4478a0faa 100644 --- a/web/components/issues/issue-layouts/roots/module-layout-root.tsx +++ b/web/components/issues/issue-layouts/roots/module-layout-root.tsx @@ -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 (
- {loader === "init-loader" || !groupedIssueIds ? ( + {issues?.loader === "init-loader" ? (
) : ( <> - {Object.keys(groupedIssueIds ?? {}).length == 0 ? ( - + {!issues?.groupedIssueIds ? ( + ) : (
{activeLayout === "list" ? ( @@ -71,7 +76,6 @@ export const ModuleLayoutRoot: React.FC = observer(() => { ) : null}
)} - {/* */} )}
diff --git a/web/components/issues/issue-layouts/roots/project-layout-root.tsx b/web/components/issues/issue-layouts/roots/project-layout-root.tsx index 453f331cb..da9811c61 100644 --- a/web/components/issues/issue-layouts/roots/project-layout-root.tsx +++ b/web/components/issues/issue-layouts/roots/project-layout-root.tsx @@ -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 (
@@ -56,6 +62,13 @@ export const ProjectLayoutRoot: FC = observer(() => { ) : ( <>
+ {/* mutation loader */} + {issues?.loader === "mutation" && ( +
+ +
+ )} + {activeLayout === "list" ? ( ) : activeLayout === "kanban" ? ( diff --git a/web/components/issues/issue-layouts/roots/project-view-layout-root.tsx b/web/components/issues/issue-layouts/roots/project-view-layout-root.tsx index f6b5500a6..b11d18a59 100644 --- a/web/components/issues/issue-layouts/roots/project-view-layout-root.tsx +++ b/web/components/issues/issue-layouts/roots/project-view-layout-root.tsx @@ -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 (
- {loader === "init-loader" ? ( + {issues?.loader === "init-loader" ? (
) : ( <> -
- {activeLayout === "list" ? ( - - ) : activeLayout === "kanban" ? ( - - ) : activeLayout === "calendar" ? ( - - ) : activeLayout === "gantt_chart" ? ( - - ) : activeLayout === "spreadsheet" ? ( - - ) : null} -
+ {!issues?.groupedIssueIds ? ( + // TODO: Replace this with project view empty state + + ) : ( +
+ {activeLayout === "list" ? ( + + ) : activeLayout === "kanban" ? ( + + ) : activeLayout === "calendar" ? ( + + ) : activeLayout === "gantt_chart" ? ( + + ) : activeLayout === "spreadsheet" ? ( + + ) : null} +
+ )} )}
diff --git a/web/components/issues/issue-layouts/utils.tsx b/web/components/issues/issue-layouts/utils.tsx index f3f4a483e..0a55db3fd 100644 --- a/web/components/issues/issue-layouts/utils.tsx +++ b/web/components/issues/issue-layouts/utils.tsx @@ -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:
{renderEmoji(project.emoji || "")}
, + icon:
{renderEmoji(project.emoji || "")}
, 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: (
@@ -76,7 +76,7 @@ const getStateGroupColumns = () => { return stateGroups.map((stateGroup) => ({ id: stateGroup.key, name: stateGroup.title, - Icon: ( + icon: (
@@ -91,7 +91,7 @@ const getPriorityColumns = () => { return priorities.map((priority) => ({ id: priority.key, name: priority.title, - Icon: , + icon: , payload: { priority: priority.key }, })); }; @@ -108,7 +108,7 @@ const getLabelsColumns = (projectLabel: ILabelRootStore) => { return labels.map((label) => ({ id: label.id, name: label.name, - Icon: ( + icon: (
), payload: label?.id === "None" ? {} : { label_ids: [label.id] }, @@ -128,12 +128,12 @@ const getAssigneeColumns = (member: IMemberRootStore) => { return { id: memberId, name: member?.display_name || "", - Icon: , + icon: , payload: { assignee_ids: [memberId] }, }; }); - assigneeColumns.push({ id: "None", name: "None", Icon: , payload: {} }); + assigneeColumns.push({ id: "None", name: "None", icon: , payload: {} }); return assigneeColumns; }; @@ -151,7 +151,7 @@ const getCreatedByColumns = (member: IMemberRootStore) => { return { id: memberId, name: member?.display_name || "", - Icon: , + icon: , payload: {}, }; }); diff --git a/web/components/issues/issue-links/index.ts b/web/components/issues/issue-links/index.ts deleted file mode 100644 index 1efe34c51..000000000 --- a/web/components/issues/issue-links/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./root"; diff --git a/web/components/issues/issue-reaction.tsx b/web/components/issues/issue-reaction.tsx deleted file mode 100644 index 37d0599e4..000000000 --- a/web/components/issues/issue-reaction.tsx +++ /dev/null @@ -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 = 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 ( -
- reaction.actor === currentUser?.id).map((r) => r.reaction) || []} - onSelect={handleReactionClick} - /> - - {Object.keys(groupedReactions || {}).map( - (reaction) => - groupedReactions?.[reaction]?.length && - groupedReactions[reaction].length > 0 && ( - - ) - )} -
- ); -}); diff --git a/web/components/issues/main-content.tsx b/web/components/issues/main-content.tsx deleted file mode 100644 index 2863cb3b9..000000000 --- a/web/components/issues/main-content.tsx +++ /dev/null @@ -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) => Promise; - uneditable?: boolean; -}; - -// services -const issueService = new IssueService(); -const issueCommentService = new IssueCommentService(); - -export const IssueMainContent: React.FC = 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) => { - 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 ( - <> -
- {issueDetails?.parent_id && parentDetail ? ( -
- -
-
- state?.id === parentDetail?.state_id - )?.color, - }} - /> - - {getProjectById(parentDetail?.project_id)?.identifier}-{parentDetail?.sequence_id} - -
- {(parentDetail?.name ?? "").substring(0, 50)} -
- - - - {siblingIssuesList ? ( - siblingIssuesList.length > 0 ? ( - <> -

- Sibling issues -

- {siblingIssuesList.map((issue) => ( - - router.push(`/${workspaceSlug}/projects/${projectId as string}/issues/${issue.id}`) - } - className="flex items-center gap-2 py-2" - > - - {getProjectById(issueDetails?.project_id)?.identifier}-{issue.sequence_id} - - ))} - - ) : ( -

- No sibling issues -

- ) - ) : null} - submitChanges({ parent_id: null })} - className="flex items-center gap-2 py-2 text-red-500" - > - - Remove Parent Issue - -
-
- ) : null} - -
- {currentIssueState && ( - - )} - -
- - setIsSubmitting(value)} - isSubmitting={isSubmitting} - workspaceSlug={workspaceSlug as string} - issue={issueDetails} - handleFormSubmit={submitChanges} - isAllowed={isAllowed || !uneditable} - /> - - {workspaceSlug && projectId && ( - - )} - -
- -
-
- - {/* issue attachments */} - - -
-

Comments/Activity

- - -
- - ); -}); diff --git a/web/components/issues/peek-overview/properties.tsx b/web/components/issues/peek-overview/properties.tsx index 48afc4cd4..7f21f01b7 100644 --- a/web/components/issues/peek-overview/properties.tsx +++ b/web/components/issues/peek-overview/properties.tsx @@ -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) => void; - issueLinkCreate: (data: IIssueLink) => Promise; - issueLinkUpdate: (data: IIssueLink, linkId: string) => Promise; - issueLinkDelete: (linkId: string) => Promise; disableUserActions: boolean; + issueOperations: any; } export const PeekOverviewProperties: FC = observer((props) => { - const { issue, issueUpdate, issueLinkCreate, issueLinkUpdate, issueLinkDelete, disableUserActions } = props; - // states - const [selectedLinkToUpdate, setSelectedLinkToUpdate] = useState(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 = 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) => { - 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 = observer((pro return ( <> - { - toggleIssueLinkModal(false); - setSelectedLinkToUpdate(null); - }} - data={selectedLinkToUpdate} - status={selectedLinkToUpdate ? true : false} - createIssueLink={issueLinkCreate} - updateIssueLink={issueLinkUpdate} - />
{/* state */} @@ -223,7 +185,13 @@ export const PeekOverviewProperties: FC = observer((pro

Parent

- +
@@ -238,10 +206,12 @@ export const PeekOverviewProperties: FC = observer((pro

Cycle

-
@@ -254,10 +224,12 @@ export const PeekOverviewProperties: FC = observer((pro

Module

-
@@ -269,12 +241,11 @@ export const PeekOverviewProperties: FC = observer((pro

Label

-
@@ -282,10 +253,14 @@ export const PeekOverviewProperties: FC = observer((pro -
-
- -
+
+
diff --git a/web/components/issues/peek-overview/root.tsx b/web/components/issues/peek-overview/root.tsx index 2b06bd0da..8253600fd 100644 --- a/web/components/issues/peek-overview/root.tsx +++ b/web/components/issues/peek-overview/root.tsx @@ -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; + removeIssueFromCycle: (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => Promise; + addIssueToModule: (workspaceSlug: string, projectId: string, moduleId: string, issueIds: string[]) => Promise; + removeIssueFromModule: (workspaceSlug: string, projectId: string, moduleId: string, issueId: string) => Promise; +}; + export const IssuePeekOverview: FC = 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 = 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 = 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 = 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) => { if (!issue) return; await updateIssue(peekIssue.workspaceSlug, peekIssue.projectId, peekIssue.issueId, _data); @@ -104,7 +182,9 @@ export const IssuePeekOverview: FC = 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 = 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 ( - {isLoading ? ( - <> // TODO: show the spinner - ) : ( - - )} + ); }); diff --git a/web/components/issues/peek-overview/view.tsx b/web/components/issues/peek-overview/view.tsx index 6a7737f73..f22f2ee2d 100644 --- a/web/components/issues/peek-overview/view.tsx +++ b/web/components/issues/peek-overview/view.tsx @@ -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) => void; redirectToIssueDetail: () => void; + issueUpdate: (issue: Partial) => void; + issueDelete: () => Promise; + 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; - issueLinkUpdate: (formData: IIssueLink, linkId: string) => Promise; - issueLinkDelete: (linkId: string) => Promise; - handleDeleteIssue: () => Promise; + disableUserActions?: boolean; showCommentAccessSpecifier?: boolean; + + issueOperations: any; } type TPeekModes = "side-peek" | "modal" | "full-screen"; @@ -75,6 +80,7 @@ export const IssueView: FC = observer((props) => { isArchived, handleCopyText, redirectToIssueDetail, + issueUpdate, issueReactionCreate, issueReactionRemove, @@ -85,12 +91,12 @@ export const IssueView: FC = observer((props) => { issueCommentReactionRemove, issueSubscriptionCreate, issueSubscriptionRemove, - issueLinkCreate, - issueLinkUpdate, - issueLinkDelete, - handleDeleteIssue, + + issueDelete, disableUserActions = false, showCommentAccessSpecifier = false, + + issueOperations, } = props; // states const [peekMode, setPeekMode] = useState("side-peek"); @@ -109,7 +115,9 @@ export const IssueView: FC = 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 = observer((props) => { isOpen={isDeleteIssueModalOpen} handleClose={() => toggleDeleteIssueModal(false)} data={issue} - onSubmit={handleDeleteIssue} + onSubmit={issueDelete} /> )} @@ -135,7 +143,7 @@ export const IssueView: FC = observer((props) => { data={issue} isOpen={isDeleteIssueModalOpen} handleClose={() => toggleDeleteIssueModal(false)} - onSubmit={handleDeleteIssue} + onSubmit={issueDelete} /> )} @@ -257,10 +265,8 @@ export const IssueView: FC = observer((props) => { = observer((props) => {
diff --git a/web/components/issues/select/label.tsx b/web/components/issues/select/label.tsx index f72006677..485adfcaa 100644 --- a/web/components/issues/select/label.tsx +++ b/web/components/issues/select/label.tsx @@ -101,8 +101,7 @@ export const IssueLabelSelect: React.FC = observer((props) => { {isDropdownOpen && (
void; - disabled?: boolean; - handleIssueUpdate?: () => void; -}; - -// services -const cycleService = new CycleService(); - -export const SidebarCycleSelect: React.FC = (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: ( -
- - - - {cycle.name} -
- ), - })); - - const issueCycle = (issueDetail && issueDetail.cycle_id && getCycleById(issueDetail.cycle_id)) || undefined; - - const disableSelect = disabled || isUpdating; - - return ( -
- { - value === issueDetail?.cycle_id - ? handleRemoveIssueFromCycle(issueDetail?.cycle_id ?? "") - : handleCycleChange - ? handleCycleChange(value) - : handleCycleStoreChange(value); - }} - options={options} - customButton={ -
- - - -
- } - width="max-w-[10rem]" - noChevron - disabled={disableSelect} - /> - {isUpdating && } -
- ); -}; diff --git a/web/components/issues/sidebar-select/index.ts b/web/components/issues/sidebar-select/index.ts deleted file mode 100644 index 323bf1bb3..000000000 --- a/web/components/issues/sidebar-select/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from "./relation"; -export * from "./cycle"; -export * from "./label"; -export * from "./module"; -export * from "./parent"; diff --git a/web/components/issues/sidebar-select/label.tsx b/web/components/issues/sidebar-select/label.tsx deleted file mode 100644 index 3eca9cccc..000000000 --- a/web/components/issues/sidebar-select/label.tsx +++ /dev/null @@ -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 = { - name: "", - color: "#ff0000", -}; - -export const SidebarLabelSelect: React.FC = 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>({ - defaultValues, - }); - - const handleNewLabel = async (formData: Partial) => { - 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 ( -
-
- {labelList?.map((labelId) => { - const label = projectLabels?.find((l) => l.id === labelId); - - if (label) - return ( - - ); - })} - submitChanges({ labels: val })} - projectId={issueDetails?.project_id ?? ""} - label={ - - Select Label - - } - disabled={uneditable} - /> - {!isNotAllowed && ( - - )} -
- - {createLabelForm && ( -
-
- ( - - <> - - {value && value?.trim() !== "" && ( - - )} - - - - - onChange(value.hex)} /> - - - - - )} - /> -
- ( - - )} - /> - - - - )} -
- ); -}); diff --git a/web/components/issues/sidebar-select/module.tsx b/web/components/issues/sidebar-select/module.tsx deleted file mode 100644 index 235f8486b..000000000 --- a/web/components/issues/sidebar-select/module.tsx +++ /dev/null @@ -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 = 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: ( -
- - - - {moduleDetail?.name} -
- ), - }; - }); - - // derived values - const issueModule = (issueDetail && issueDetail?.module_id && getModuleById(issueDetail.module_id)) || undefined; - - const disableSelect = disabled || isUpdating; - - return ( -
- { - value === issueDetail?.module_id - ? handleRemoveIssueFromModule(issueModule?.id ?? "", issueDetail?.module_id ?? "") - : handleModuleChange - ? handleModuleChange(value) - : handleModuleStoreChange(value); - }} - options={options} - customButton={ -
- - - -
- } - width="max-w-[10rem]" - noChevron - disabled={disableSelect} - /> - {isUpdating && } -
- ); -}); diff --git a/web/components/issues/sidebar-select/parent.tsx b/web/components/issues/sidebar-select/parent.tsx deleted file mode 100644 index 47fb01b27..000000000 --- a/web/components/issues/sidebar-select/parent.tsx +++ /dev/null @@ -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 = observer(({ onChange, issueDetails, disabled = false }) => { - const [selectedParentIssue, setSelectedParentIssue] = useState(null); - - const { isParentIssueModalOpen, toggleParentIssueModal } = useIssueDetail(); - - const router = useRouter(); - const { projectId, issueId } = router.query; - - // hooks - const { getProjectById } = useProject(); - const { issueMap } = useIssues(); - - return ( - <> - toggleParentIssueModal(false)} - onChange={(issue) => { - onChange(issue.id); - setSelectedParentIssue(issue); - }} - issueId={issueId as string} - projectId={projectId as string} - /> - - - ); -}); diff --git a/web/components/issues/sub-issues/issue.tsx b/web/components/issues/sub-issues/issue.tsx index 57a887798..2d6de08d1 100644 --- a/web/components/issues/sub-issues/issue.tsx +++ b/web/components/issues/sub-issues/issue.tsx @@ -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", diff --git a/web/components/issues/sub-issues/issues-list.tsx b/web/components/issues/sub-issues/issues-list.tsx index 2fe4baea3..e8def0a98 100644 --- a/web/components/issues/sub-issues/issues-list.tsx +++ b/web/components/issues/sub-issues/issues-list.tsx @@ -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", diff --git a/web/components/issues/sub-issues/root.tsx b/web/components/issues/sub-issues/root.tsx index 025e4741f..18e1bf1f4 100644 --- a/web/components/issues/sub-issues/root.tsx +++ b/web/components/issues/sub-issues/root.tsx @@ -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 = 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(); - 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({ - 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) => { + 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 = 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 = 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) => { - 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 (
- {!issues && isLoading ? ( + {/* {!issues && isLoading ? (
Loading...
) : ( <> {issues && issues?.sub_issues && issues?.sub_issues?.length > 0 ? ( <> - {/* header */}
= observer((props) => { />
- {isEditable && issuesLoader.visibility.includes(parentIssue?.id) && ( + {is_editable && issuesLoader.visibility.includes(parentIssue?.id) && (
= observer((props) => { )}
- {/* issues */} + {issuesLoader.visibility.includes(parentIssue?.id) && workspaceSlug && projectId && (
= 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 = observer((props) => { > { - mutateSubIssues(parentIssue?.id); handleIssueCrudOperation("create", parentIssue?.id); }} > @@ -270,7 +225,6 @@ export const SubIssuesRoot: React.FC = observer((props) => { { - mutateSubIssues(parentIssue?.id); handleIssueCrudOperation("existing", parentIssue?.id); }} > @@ -280,7 +234,7 @@ export const SubIssuesRoot: React.FC = observer((props) => {
) : ( - isEditable && ( + is_editable && (
No Sub-Issues yet
@@ -298,7 +252,6 @@ export const SubIssuesRoot: React.FC = observer((props) => { > { - mutateSubIssues(parentIssue?.id); handleIssueCrudOperation("create", parentIssue?.id); }} > @@ -306,7 +259,6 @@ export const SubIssuesRoot: React.FC = observer((props) => { { - mutateSubIssues(parentIssue?.id); handleIssueCrudOperation("existing", parentIssue?.id); }} > @@ -317,19 +269,20 @@ export const SubIssuesRoot: React.FC = observer((props) => {
) )} - {isEditable && issueCrudOperation?.create?.toggle && ( + + {is_editable && issueCrudOperation?.create?.toggle && ( { - mutateSubIssues(issueCrudOperation?.create?.issueId); handleIssueCrudOperation("create", null); }} /> )} - {isEditable && issueCrudOperation?.existing?.toggle && issueCrudOperation?.existing?.issueId && ( + + {is_editable && issueCrudOperation?.existing?.toggle && issueCrudOperation?.existing?.issueId && ( handleIssueCrudOperation("existing", null)} @@ -338,19 +291,20 @@ export const SubIssuesRoot: React.FC = observer((props) => { workspaceLevelToggle /> )} - {isEditable && issueCrudOperation?.edit?.toggle && issueCrudOperation?.edit?.issueId && ( + + {is_editable && issueCrudOperation?.edit?.toggle && issueCrudOperation?.edit?.issueId && ( <> { - 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 = observer((props) => { { - mutateSubIssues(issueCrudOperation?.delete?.issueId); handleIssueCrudOperation("delete", null, null); }} data={issueCrudOperation?.delete?.issue} @@ -372,7 +325,7 @@ export const SubIssuesRoot: React.FC = observer((props) => { /> )} - )} + )} */}
); }); diff --git a/web/components/modules/sidebar.tsx b/web/components/modules/sidebar.tsx index 5b6456207..1ecd468d7 100644 --- a/web/components/modules/sidebar.tsx +++ b/web/components/modules/sidebar.tsx @@ -76,7 +76,7 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => { const submitChanges = (data: Partial) => { 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) => { diff --git a/web/components/pages/create-update-page-modal.tsx b/web/components/pages/create-update-page-modal.tsx index 38896a222..21142cab9 100644 --- a/web/components/pages/create-update-page-modal.tsx +++ b/web/components/pages/create-update-page-modal.tsx @@ -37,90 +37,90 @@ export const CreateUpdatePageModal: FC = (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) => { diff --git a/web/components/pages/pages-list/list-item.tsx b/web/components/pages/pages-list/list-item.tsx index e0c32067b..7a92f3296 100644 --- a/web/components/pages/pages-list/list-item.tsx +++ b/web/components/pages/pages-list/list-item.tsx @@ -186,8 +186,9 @@ export const PagesListItem: FC = observer((props) => {

{pageDetails.name}

+ {/* FIXME: replace any with proper type */} {pageDetails.label_details.length > 0 && - pageDetails.label_details.map((label) => ( + pageDetails.label_details.map((label: any) => (
{ } = 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; diff --git a/web/constants/issue.ts b/web/constants/issue.ts index edce5b306..8a48f5eb6 100644 --- a/web/constants/issue.ts +++ b/web/constants/issue.ts @@ -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: { diff --git a/web/hooks/store/use-issues.ts b/web/hooks/store/use-issues.ts index 4ec03847b..f2da9d954 100644 --- a/web/hooks/store/use-issues.ts +++ b/web/hooks/store/use-issues.ts @@ -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 = (storeType?: T): IStoreIssues[T] => { +export const useIssues = (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]; } }; diff --git a/web/hooks/store/use-page.ts b/web/hooks/store/use-page.ts index 8cd13dcdc..cc348a4ad 100644 --- a/web/hooks/store/use-page.ts +++ b/web/hooks/store/use-page.ts @@ -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; }; diff --git a/web/hooks/use-issue-reaction.tsx b/web/hooks/use-issue-reaction.tsx deleted file mode 100644 index f1159c328..000000000 --- a/web/hooks/use-issue-reaction.tsx +++ /dev/null @@ -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; diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/archived-issues/[archivedIssueId].tsx b/web/pages/[workspaceSlug]/projects/[projectId]/archived-issues/[archivedIssueId].tsx index 43c23eb78..d8238d372 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/archived-issues/[archivedIssueId].tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/archived-issues/[archivedIssueId].tsx @@ -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 = () => {
)} -
+ {/* FIXME: have to replace this once the issue details page is ready --issue-detail-- */} + {/*
-
+
*/}
-
+ {/* FIXME: have to replace this once the issue details page is ready --issue-detail-- */} + {/*
{ watch={watch} uneditable /> -
+
*/}
) : ( diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/issues/[issueId].tsx b/web/pages/[workspaceSlug]/projects/[projectId]/issues/[issueId].tsx index d6cc2b04d..6be784368 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/issues/[issueId].tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/issues/[issueId].tsx @@ -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 = { - // 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({ - defaultValues, - }); - - const submitChanges = useCallback( - async (formData: Partial) => { - if (!workspaceSlug || !projectId || !peekIssue?.issueId) return; - - mutate( - ISSUE_DETAILS(peekIssue?.issueId as string), - (prevData) => { - if (!prevData) return prevData; - - return { - ...prevData, - ...formData, - }; - }, - false - ); - - const payload: Partial = { - ...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 ? ( - router.push(`/${workspaceSlug}/projects/${projectId}/issues`), - }} - /> - ) : issueDetails && projectId && peekIssue?.issueId ? ( -
-
- -
-
- -
-
- ) : ( + {issueLoader ? (
@@ -152,6 +51,16 @@ const IssueDetailsPage: NextPageWithLayout = observer(() => {
+ ) : ( + workspaceSlug && + projectId && + issueId && ( + + ) )} ); diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/modules/[moduleId].tsx b/web/pages/[workspaceSlug]/projects/[projectId]/modules/[moduleId].tsx index 4f9b1abb0..946041176 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/modules/[moduleId].tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/modules/[moduleId].tsx @@ -38,6 +38,7 @@ const ModuleIssuesPage: NextPageWithLayout = () => { setValue(`${!isSidebarCollapsed}`); }; + if (!workspaceSlug || !projectId || !moduleId) return <>; return ( <> {error ? ( diff --git a/web/services/issue/issue.service.ts b/web/services/issue/issue.service.ts index 1c17d9b6a..5301e368c 100644 --- a/web/services/issue/issue.service.ts +++ b/web/services/issue/issue.service.ts @@ -49,8 +49,10 @@ export class IssueService extends APIService { }); } - async retrieve(workspaceSlug: string, projectId: string, issueId: string): Promise { - return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/`) + async retrieve(workspaceSlug: string, projectId: string, issueId: string, queries?: any): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/`, { + params: queries, + }) .then((response) => response?.data) .catch((error) => { throw error?.response?.data; diff --git a/web/services/issue_filter.service.ts b/web/services/issue_filter.service.ts index 8bb19c305..5103a4bc8 100644 --- a/web/services/issue_filter.service.ts +++ b/web/services/issue_filter.service.ts @@ -29,7 +29,7 @@ export class IssueFiltersService extends APIService { // } // project issue filters - async fetchProjectIssueFilters(workspaceSlug: string, projectId: string): Promise { + async fetchProjectIssueFilters(workspaceSlug: string, projectId: string): Promise { 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 { + async fetchCycleIssueFilters( + workspaceSlug: string, + projectId: string, + cycleId: string + ): Promise { 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 { + async fetchModuleIssueFilters( + workspaceSlug: string, + projectId: string, + moduleId: string + ): Promise { return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/user-properties/`) .then((response) => response?.data) .catch((error) => { diff --git a/web/store/issue/archived/filter.store.ts b/web/store/issue/archived/filter.store.ts index aeb71d7d2..7192cc012 100644 --- a/web/store/issue/archived/filter.store.ts +++ b/web/store/issue/archived/filter.store.ts @@ -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; } @@ -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; } diff --git a/web/store/issue/cycle/filter.store.ts b/web/store/issue/cycle/filter.store.ts index fa933c372..7fd967d94 100644 --- a/web/store/issue/cycle/filter.store.ts +++ b/web/store/issue/cycle/filter.store.ts @@ -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; } @@ -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; } diff --git a/web/store/issue/cycle/issue.store.ts b/web/store/issue/cycle/issue.store.ts index dcb25ca63..21fce7a76 100644 --- a/web/store/issue/cycle/issue.store.ts +++ b/web/store/issue/cycle/issue.store.ts @@ -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) { diff --git a/web/store/issue/draft/filter.store.ts b/web/store/issue/draft/filter.store.ts index 3e43eb147..a303ee90e 100644 --- a/web/store/issue/draft/filter.store.ts +++ b/web/store/issue/draft/filter.store.ts @@ -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; } @@ -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; } diff --git a/web/store/issue/helpers/issue-filter-helper.store.ts b/web/store/issue/helpers/issue-filter-helper.store.ts index c599e1932..55ffe3e81 100644 --- a/web/store/issue/helpers/issue-filter-helper.store.ts +++ b/web/store/issue/helpers/issue-filter-helper.store.ts @@ -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 + filters: Partial ) => { 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)); diff --git a/web/store/issue/issue-details/issue.store.ts b/web/store/issue/issue-details/issue.store.ts index 9d36ccd86..69fdaa79f 100644 --- a/web/store/issue/issue-details/issue.store.ts +++ b/web/store/issue/issue-details/issue.store.ts @@ -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); diff --git a/web/store/issue/issue-details/link.store.ts b/web/store/issue/issue-details/link.store.ts index a77ee7417..e5760e802 100644 --- a/web/store/issue/issue-details/link.store.ts +++ b/web/store/issue/issue-details/link.store.ts @@ -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]; }); diff --git a/web/store/issue/issue-details/reaction.store.ts b/web/store/issue/issue-details/reaction.store.ts index bac47ccde..59c7edd7c 100644 --- a/web/store/issue/issue-details/reaction.store.ts +++ b/web/store/issue/issue-details/reaction.store.ts @@ -1,27 +1,38 @@ -import { action, computed, makeObservable, observable, runInAction } from "mobx"; +import { action, makeObservable, observable, runInAction } from "mobx"; import set from "lodash/set"; +import update from "lodash/update"; +import concat from "lodash/concat"; +import find from "lodash/find"; +import pull from "lodash/pull"; // services import { IssueReactionService } from "services/issue"; // types import { IIssueDetail } from "./root.store"; import { TIssueReaction, TIssueReactionMap, TIssueReactionIdMap } from "@plane/types"; +// helpers +import { groupReactions } from "helpers/emoji.helper"; export interface IIssueReactionStoreActions { // actions fetchReactions: (workspaceSlug: string, projectId: string, issueId: string) => Promise; createReaction: (workspaceSlug: string, projectId: string, issueId: string, reaction: string) => Promise; - removeReaction: (workspaceSlug: string, projectId: string, issueId: string, reaction: string) => Promise; + removeReaction: ( + workspaceSlug: string, + projectId: string, + issueId: string, + reaction: string, + userId: string + ) => Promise; } export interface IIssueReactionStore extends IIssueReactionStoreActions { // observables reactions: TIssueReactionIdMap; reactionMap: TIssueReactionMap; - // computed - issueReactions: string[] | undefined; // helper methods - getReactionsByIssueId: (issueId: string) => string[] | undefined; + getReactionsByIssueId: (issueId: string) => { [reaction_id: string]: string[] } | undefined; getReactionById: (reactionId: string) => TIssueReaction | undefined; + reactionsByUser: (issueId: string, userId: string) => TIssueReaction[]; } export class IssueReactionStore implements IIssueReactionStore { @@ -38,8 +49,6 @@ export class IssueReactionStore implements IIssueReactionStore { // observables reactions: observable, reactionMap: observable, - // computed - issueReactions: computed, // actions fetchReactions: action, createReaction: action, @@ -51,13 +60,6 @@ export class IssueReactionStore implements IIssueReactionStore { this.issueReactionService = new IssueReactionService(); } - // computed - get issueReactions() { - const issueId = this.rootIssueDetailStore.peekIssue?.issueId; - if (!issueId) return undefined; - return this.reactions[issueId] ?? undefined; - } - // helper methods getReactionsByIssueId = (issueId: string) => { if (!issueId) return undefined; @@ -69,13 +71,38 @@ export class IssueReactionStore implements IIssueReactionStore { return this.reactionMap[reactionId] ?? undefined; }; + reactionsByUser = (issueId: string, userId: string) => { + if (!issueId || !userId) return []; + + const reactions = this.getReactionsByIssueId(issueId); + if (!reactions) return []; + + const _userReactions: TIssueReaction[] = []; + Object.keys(reactions).forEach((reaction) => { + reactions[reaction].map((reactionId) => { + const currentReaction = this.getReactionById(reactionId); + if (currentReaction && currentReaction.actor === userId) _userReactions.push(currentReaction); + }); + }); + + return _userReactions; + }; + // actions fetchReactions = async (workspaceSlug: string, projectId: string, issueId: string) => { try { const response = await this.issueReactionService.listIssueReactions(workspaceSlug, projectId, issueId); + const groupedReactions = groupReactions(response || [], "reaction"); + + const issueReactionIdsMap: { [reaction: string]: string[] } = {}; + + Object.keys(groupedReactions).map((reactionId) => { + const reactionIds = (groupedReactions[reactionId] || []).map((reaction) => reaction.id); + issueReactionIdsMap[reactionId] = reactionIds; + }); runInAction(() => { - this.reactions[issueId] = response.map((reaction) => reaction.id); + set(this.reactions, issueId, issueReactionIdsMap); response.forEach((reaction) => set(this.reactionMap, reaction.id, reaction)); }); @@ -92,7 +119,10 @@ export class IssueReactionStore implements IIssueReactionStore { }); runInAction(() => { - this.reactions[issueId].push(response.id); + update(this.reactions, [issueId, reaction], (reactionId) => { + if (!reactionId) return [response.id]; + return concat(reactionId, response.id); + }); set(this.reactionMap, response.id, response); }); @@ -102,21 +132,28 @@ export class IssueReactionStore implements IIssueReactionStore { } }; - removeReaction = async (workspaceSlug: string, projectId: string, issueId: string, reaction: string) => { + removeReaction = async ( + workspaceSlug: string, + projectId: string, + issueId: string, + reaction: string, + userId: string + ) => { try { - const reactionIndex = this.reactions[issueId].findIndex((_reaction) => _reaction === reaction); - if (reactionIndex >= 0) + const userReactions = this.reactionsByUser(issueId, userId); + const currentReaction = find(userReactions, { actor: userId, reaction: reaction }); + + if (currentReaction && currentReaction.id) { runInAction(() => { - this.reactions[issueId].splice(reactionIndex, 1); + pull(this.reactions[issueId][reaction], currentReaction.id); delete this.reactionMap[reaction]; }); + } const response = await this.issueReactionService.deleteIssueReaction(workspaceSlug, projectId, issueId, reaction); return response; } catch (error) { - // TODO: Replace with fetch issue details - // this.fetchReactions(workspaceSlug, projectId, issueId); throw error; } }; diff --git a/web/store/issue/issue-details/root.store.ts b/web/store/issue/issue-details/root.store.ts index 67aa4b46f..88b2e2958 100644 --- a/web/store/issue/issue-details/root.store.ts +++ b/web/store/issue/issue-details/root.store.ts @@ -142,8 +142,13 @@ export class IssueDetail implements IIssueDetail { this.reaction.fetchReactions(workspaceSlug, projectId, issueId); createReaction = async (workspaceSlug: string, projectId: string, issueId: string, reaction: string) => this.reaction.createReaction(workspaceSlug, projectId, issueId, reaction); - removeReaction = async (workspaceSlug: string, projectId: string, issueId: string, reaction: string) => - this.reaction.removeReaction(workspaceSlug, projectId, issueId, reaction); + removeReaction = async ( + workspaceSlug: string, + projectId: string, + issueId: string, + reaction: string, + userId: string + ) => this.reaction.removeReaction(workspaceSlug, projectId, issueId, reaction, userId); // activity fetchActivities = async (workspaceSlug: string, projectId: string, issueId: string) => @@ -198,6 +203,15 @@ export class IssueDetail implements IIssueDetail { this.subIssues.fetchSubIssues(workspaceSlug, projectId, issueId); createSubIssues = async (workspaceSlug: string, projectId: string, issueId: string, data: string[]) => this.subIssues.createSubIssues(workspaceSlug, projectId, issueId, data); + updateSubIssue = async ( + workspaceSlug: string, + projectId: string, + parentIssueId: string, + issueId: string, + data: { oldParentId: string; newParentId: string } + ) => this.subIssues.updateSubIssue(workspaceSlug, projectId, parentIssueId, issueId, data); + removeSubIssue = async (workspaceSlug: string, projectId: string, parentIssueId: string, issueIds: string[]) => + this.subIssues.removeSubIssue(workspaceSlug, projectId, parentIssueId, issueIds); // subscription fetchSubscriptions = async (workspaceSlug: string, projectId: string, issueId: string) => diff --git a/web/store/issue/issue-details/sub_issues.store.ts b/web/store/issue/issue-details/sub_issues.store.ts index e74c647fa..d578e20f0 100644 --- a/web/store/issue/issue-details/sub_issues.store.ts +++ b/web/store/issue/issue-details/sub_issues.store.ts @@ -20,6 +20,19 @@ export interface IIssueSubIssuesStoreActions { issueId: string, data: string[] ) => Promise; + updateSubIssue: ( + workspaceSlug: string, + projectId: string, + parentIssueId: string, + issueId: string, + data: { oldParentId: string; newParentId: string } + ) => any; + removeSubIssue: ( + workspaceSlug: string, + projectId: string, + parentIssueId: string, + issueIds: string[] + ) => Promise; } export interface IIssueSubIssuesStore extends IIssueSubIssuesStoreActions { @@ -48,6 +61,8 @@ export class IssueSubIssuesStore implements IIssueSubIssuesStore { // actions fetchSubIssues: action, createSubIssues: action, + updateSubIssue: action, + removeSubIssue: action, }); // root store this.rootIssueDetailStore = rootStore; @@ -113,4 +128,53 @@ export class IssueSubIssuesStore implements IIssueSubIssuesStore { throw error; } }; + + updateSubIssue = async ( + workspaceSlug: string, + projectId: string, + parentIssueId: string, + issueId: string, + data: { oldParentId: string; newParentId: string } + ) => { + try { + const oldIssueParentId = data.oldParentId; + const newIssueParentId = data.newParentId; + + // const issue = this.rootIssueDetailStore.rootIssueStore.issues.getIssueById(issueId); + + // runInAction(() => { + // Object.keys(subIssuesStateDistribution).forEach((key) => { + // const stateGroup = key as keyof TSubIssuesStateDistribution; + // set(this.subIssuesStateDistribution, [issueId, key], subIssuesStateDistribution[stateGroup]); + // }); + // set(this.subIssuesStateDistribution, issueId, data); + // }); + + return {} as any; + } catch (error) { + throw error; + } + }; + + removeSubIssue = async (workspaceSlug: string, projectId: string, issueId: string, data: string[]) => { + try { + const response = await this.issueService.addSubIssues(workspaceSlug, projectId, issueId, { sub_issue_ids: data }); + const subIssuesStateDistribution = response?.state_distribution; + const subIssues = response.sub_issues as TIssue[]; + + this.rootIssueDetailStore.rootIssueStore.issues.addIssue(subIssues); + + runInAction(() => { + Object.keys(subIssuesStateDistribution).forEach((key) => { + const stateGroup = key as keyof TSubIssuesStateDistribution; + set(this.subIssuesStateDistribution, [issueId, key], subIssuesStateDistribution[stateGroup]); + }); + set(this.subIssuesStateDistribution, issueId, data); + }); + + return response; + } catch (error) { + throw error; + } + }; } diff --git a/web/store/issue/module/filter.store.ts b/web/store/issue/module/filter.store.ts index 7819ad6e0..d7d4d56da 100644 --- a/web/store/issue/module/filter.store.ts +++ b/web/store/issue/module/filter.store.ts @@ -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 IModuleIssuesFilter { workspaceSlug: string, projectId: string, filterType: EIssueFilterType, - filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties, + filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters, moduleId?: string | undefined ) => Promise; } @@ -52,6 +53,9 @@ export class ModuleIssuesFilter extends IssueFilterHelperStore implements IModul // computed issueFilters: computed, appliedFilters: computed, + // actions + fetchFilters: action, + updateFilters: action, }); // root store this.rootIssueStore = _rootStore; @@ -97,10 +101,28 @@ export class ModuleIssuesFilter extends IssueFilterHelperStore implements IModul 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.MODULE, + workspaceSlug, + moduleId, + currentUserId + ); + kanbanFilters.group_by = _kanbanFilters?.kanban_filters?.group_by || []; + kanbanFilters.sub_group_by = _kanbanFilters?.kanban_filters?.sub_group_by || []; + } + runInAction(() => { set(this.filters, [moduleId, "filters"], filters); set(this.filters, [moduleId, "displayFilters"], displayFilters); set(this.filters, [moduleId, "displayProperties"], displayProperties); + set(this.filters, [moduleId, "kanbanFilters"], kanbanFilters); }); } catch (error) { throw error; @@ -111,17 +133,18 @@ export class ModuleIssuesFilter extends IssueFilterHelperStore implements IModul workspaceSlug: string, projectId: string, type: EIssueFilterType, - filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties, + filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters, moduleId: string | undefined = undefined ) => { try { if (!moduleId) throw new Error("Module id is required"); - if (isEmpty(this.filters) || isEmpty(this.filters[projectId]) || isEmpty(filters)) return; + if (isEmpty(this.filters) || isEmpty(this.filters[moduleId]) || 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[moduleId].filters as IIssueFilterOptions, + displayFilters: this.filters[moduleId].displayFilters as IIssueDisplayFilterOptions, + displayProperties: this.filters[moduleId].displayProperties as IIssueDisplayProperties, + kanbanFilters: this.filters[moduleId].kanbanFilters as TIssueKanbanFilters, }; switch (type) { @@ -135,7 +158,7 @@ export class ModuleIssuesFilter extends IssueFilterHelperStore implements IModul }); }); - this.rootIssueStore.projectIssues.fetchIssues(workspaceSlug, projectId, "mutation"); + this.rootIssueStore.moduleIssues.fetchIssues(workspaceSlug, projectId, "mutation", moduleId); await this.issueFilterService.patchModuleIssueFilters(workspaceSlug, projectId, moduleId, { filters: _filters.filters, }); @@ -196,6 +219,28 @@ export class ModuleIssuesFilter extends IssueFilterHelperStore implements IModul 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, moduleId, currentUserId, { + kanban_filters: _filters.kanbanFilters, + }); + + runInAction(() => { + Object.keys(updatedKanbanFilters).forEach((_key) => { + set( + this.filters, + [moduleId, "kanbanFilters", _key], + updatedKanbanFilters[_key as keyof TIssueKanbanFilters] + ); + }); + }); + + break; default: break; } diff --git a/web/store/issue/module/issue.store.ts b/web/store/issue/module/issue.store.ts index 3928b2554..80874abe3 100644 --- a/web/store/issue/module/issue.store.ts +++ b/web/store/issue/module/issue.store.ts @@ -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 @@ -250,9 +253,14 @@ export class ModuleIssues extends IssueHelperStore implements IModuleIssues { addIssueToModule = async (workspaceSlug: string, projectId: string, moduleId: string, issueIds: string[]) => { try { runInAction(() => { - this.issues[moduleId].push(...issueIds); + update(this.issues, moduleId, (moduleIssueIds) => { + if (!moduleIssueIds) return issueIds; + else return concat(moduleIssueIds, issueIds); + }); }); + issueIds.map((issueId) => this.rootStore.issues.updateIssue(issueId, { module_id: moduleId })); + const issueToModule = await this.moduleService.addIssuesToModule(workspaceSlug, projectId, moduleId, { issues: issueIds, }); @@ -265,13 +273,13 @@ export class ModuleIssues extends IssueHelperStore implements IModuleIssues { removeIssueFromModule = async (workspaceSlug: string, projectId: string, moduleId: string, issueId: string) => { try { - const response = await this.moduleService.removeIssueFromModule(workspaceSlug, projectId, moduleId, issueId); + runInAction(() => { + pull(this.issues[moduleId], issueId); + }); - const issueIndex = this.issues[moduleId].findIndex((_issueId) => _issueId === issueId); - if (issueIndex >= 0) - runInAction(() => { - this.issues[moduleId].splice(issueIndex, 1); - }); + this.rootStore.issues.updateIssue(issueId, { module_id: null }); + + const response = await this.moduleService.removeIssueFromModule(workspaceSlug, projectId, moduleId, issueId); return response; } catch (error) { diff --git a/web/store/issue/profile/filter.store.ts b/web/store/issue/profile/filter.store.ts index e14b7179d..fe30a1c58 100644 --- a/web/store/issue/profile/filter.store.ts +++ b/web/store/issue/profile/filter.store.ts @@ -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"; @@ -32,7 +33,7 @@ export interface IProfileIssuesFilter { workspaceSlug: string, projectId: string | undefined, filterType: EIssueFilterType, - filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties, + filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters, userId?: string | undefined ) => Promise; } @@ -55,6 +56,9 @@ export class ProfileIssuesFilter extends IssueFilterHelperStore implements IProf // computed issueFilters: computed, appliedFilters: computed, + // actions + fetchFilters: action, + updateFilters: action, }); // root store this.rootIssueStore = _rootStore; @@ -100,11 +104,16 @@ export class ProfileIssuesFilter extends IssueFilterHelperStore implements IProf 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: _filters?.kanban_filters?.group_by || [], + sub_group_by: _filters?.kanban_filters?.sub_group_by || [], + }; runInAction(() => { set(this.filters, [userId, "filters"], filters); set(this.filters, [userId, "displayFilters"], displayFilters); set(this.filters, [userId, "displayProperties"], displayProperties); + set(this.filters, [userId, "kanbanFilters"], kanbanFilters); }); } catch (error) { throw error; @@ -115,7 +124,7 @@ export class ProfileIssuesFilter extends IssueFilterHelperStore implements IProf workspaceSlug: string, projectId: string | undefined, type: EIssueFilterType, - filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties, + filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters, userId: string | undefined = undefined ) => { try { @@ -127,6 +136,7 @@ export class ProfileIssuesFilter extends IssueFilterHelperStore implements IProf filters: this.filters[userId].filters as IIssueFilterOptions, displayFilters: this.filters[userId].displayFilters as IIssueDisplayFilterOptions, displayProperties: this.filters[userId].displayProperties as IIssueDisplayProperties, + kanbanFilters: this.filters[userId].kanbanFilters as TIssueKanbanFilters, }; switch (type) { @@ -140,7 +150,13 @@ export class ProfileIssuesFilter extends IssueFilterHelperStore implements IProf }); }); - this.rootIssueStore.projectIssues.fetchIssues(workspaceSlug, userId, "mutation"); + this.rootIssueStore.profileIssues.fetchIssues( + workspaceSlug, + undefined, + "mutation", + userId, + this.rootIssueStore.profileIssues.currentView + ); this.handleIssuesLocalFilters.set(EIssuesStoreType.PROFILE, type, workspaceSlug, userId, undefined, { filters: _filters.filters, }); @@ -201,6 +217,28 @@ export class ProfileIssuesFilter extends IssueFilterHelperStore implements IProf 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, userId, undefined, { + kanban_filters: _filters.kanbanFilters, + }); + + runInAction(() => { + Object.keys(updatedKanbanFilters).forEach((_key) => { + set( + this.filters, + [userId, "kanbanFilters", _key], + updatedKanbanFilters[_key as keyof TIssueKanbanFilters] + ); + }); + }); + + break; default: break; } diff --git a/web/store/issue/project-views/filter.store.ts b/web/store/issue/project-views/filter.store.ts index 5d0ec332b..83bc2e79e 100644 --- a/web/store/issue/project-views/filter.store.ts +++ b/web/store/issue/project-views/filter.store.ts @@ -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 { ViewService } from "services/view.service"; @@ -31,7 +32,7 @@ export interface IProjectViewIssuesFilter { workspaceSlug: string, projectId: string, filterType: EIssueFilterType, - filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties, + filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters, viewId?: string | undefined ) => Promise; } @@ -52,6 +53,9 @@ export class ProjectViewIssuesFilter extends IssueFilterHelperStore implements I // computed issueFilters: computed, appliedFilters: computed, + // actions + fetchFilters: action, + updateFilters: action, }); // root store this.rootIssueStore = _rootStore; @@ -97,10 +101,28 @@ export class ProjectViewIssuesFilter extends IssueFilterHelperStore implements I 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.PROJECT_VIEW, + workspaceSlug, + viewId, + currentUserId + ); + kanbanFilters.group_by = _kanbanFilters?.kanban_filters?.group_by || []; + kanbanFilters.sub_group_by = _kanbanFilters?.kanban_filters?.sub_group_by || []; + } + runInAction(() => { set(this.filters, [viewId, "filters"], filters); set(this.filters, [viewId, "displayFilters"], displayFilters); set(this.filters, [viewId, "displayProperties"], displayProperties); + set(this.filters, [viewId, "kanbanFilters"], kanbanFilters); }); } catch (error) { throw error; @@ -111,7 +133,7 @@ export class ProjectViewIssuesFilter extends IssueFilterHelperStore implements I workspaceSlug: string, projectId: string, type: EIssueFilterType, - filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties, + filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters, viewId: string | undefined = undefined ) => { try { @@ -120,9 +142,10 @@ export class ProjectViewIssuesFilter extends IssueFilterHelperStore implements I 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[viewId].filters as IIssueFilterOptions, + displayFilters: this.filters[viewId].displayFilters as IIssueDisplayFilterOptions, + displayProperties: this.filters[viewId].displayProperties as IIssueDisplayProperties, + kanbanFilters: this.filters[viewId].kanbanFilters as TIssueKanbanFilters, }; switch (type) { @@ -136,7 +159,7 @@ export class ProjectViewIssuesFilter extends IssueFilterHelperStore implements I }); }); - this.rootIssueStore.projectIssues.fetchIssues(workspaceSlug, projectId, "mutation"); + this.rootIssueStore.projectViewIssues.fetchIssues(workspaceSlug, projectId, "mutation", viewId); await this.issueFilterService.patchView(workspaceSlug, projectId, viewId, { filters: _filters.filters, }); @@ -197,6 +220,28 @@ export class ProjectViewIssuesFilter extends IssueFilterHelperStore implements I 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, viewId, currentUserId, { + kanban_filters: _filters.kanbanFilters, + }); + + runInAction(() => { + Object.keys(updatedKanbanFilters).forEach((_key) => { + set( + this.filters, + [viewId, "kanbanFilters", _key], + updatedKanbanFilters[_key as keyof TIssueKanbanFilters] + ); + }); + }); + + break; default: break; } diff --git a/web/store/issue/project/filter.store.ts b/web/store/issue/project/filter.store.ts index 2b47e4187..83a95aa6d 100644 --- a/web/store/issue/project/filter.store.ts +++ b/web/store/issue/project/filter.store.ts @@ -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 IProjectIssuesFilter { workspaceSlug: string, projectId: string, filterType: EIssueFilterType, - filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties + filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters ) => Promise; } @@ -51,6 +52,9 @@ export class ProjectIssuesFilter extends IssueFilterHelperStore implements IProj // computed issueFilters: computed, appliedFilters: computed, + // actions + fetchFilters: action, + updateFilters: action, }); // root store this.rootIssueStore = _rootStore; @@ -92,14 +96,32 @@ export class ProjectIssuesFilter extends IssueFilterHelperStore implements IProj try { const _filters = await this.issueFilterService.fetchProjectIssueFilters(workspaceSlug, projectId); - const filters: IIssueFilterOptions = this.computedFilters(_filters?.filters); - const displayFilters: IIssueDisplayFilterOptions = this.computedDisplayFilters(_filters?.display_filters); - const displayProperties: IIssueDisplayProperties = this.computedDisplayProperties(_filters?.display_properties); + const filters = this.computedFilters(_filters?.filters); + const displayFilters = this.computedDisplayFilters(_filters?.display_filters); + const displayProperties = 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.PROJECT, + workspaceSlug, + projectId, + currentUserId + ); + kanbanFilters.group_by = _kanbanFilters?.kanban_filters?.group_by || []; + kanbanFilters.sub_group_by = _kanbanFilters?.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 +132,7 @@ export class ProjectIssuesFilter extends IssueFilterHelperStore implements IProj 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 +141,7 @@ export class ProjectIssuesFilter extends IssueFilterHelperStore implements IProj 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) { @@ -193,6 +216,28 @@ export class ProjectIssuesFilter extends IssueFilterHelperStore implements IProj 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, currentUserId, { + kanban_filters: _filters.kanbanFilters, + }); + + runInAction(() => { + Object.keys(updatedKanbanFilters).forEach((_key) => { + set( + this.filters, + [projectId, "kanbanFilters", _key], + updatedKanbanFilters[_key as keyof TIssueKanbanFilters] + ); + }); + }); + + break; default: break; } diff --git a/web/store/issue/project/issue.store.ts b/web/store/issue/project/issue.store.ts index 3b45fd9cb..056bb5ebb 100644 --- a/web/store/issue/project/issue.store.ts +++ b/web/store/issue/project/issue.store.ts @@ -134,8 +134,6 @@ export class ProjectIssues extends IssueHelperStore implements IProjectIssues { updateIssue = async (workspaceSlug: string, projectId: string, issueId: string, data: Partial) => { try { - if (!issueId || !this.issues[projectId]) return; - this.rootStore.issues.updateIssue(issueId, data); const response = await this.issueService.patchIssue(workspaceSlug, projectId, issueId, data); @@ -148,8 +146,6 @@ export class ProjectIssues extends IssueHelperStore implements IProjectIssues { removeIssue = async (workspaceSlug: string, projectId: string, issueId: string) => { try { - if (!issueId || !this.issues[projectId]) return; - const response = await this.issueService.deleteIssue(workspaceSlug, projectId, issueId); const issueIndex = this.issues[projectId].findIndex((_issueId) => _issueId === issueId); diff --git a/web/store/issue/root.store.ts b/web/store/issue/root.store.ts index 06efaebbb..edde040c4 100644 --- a/web/store/issue/root.store.ts +++ b/web/store/issue/root.store.ts @@ -86,6 +86,8 @@ export class IssueRootStore implements IIssueRootStore { issues: IIssueStore; + issueDetail: IIssueDetail; + workspaceIssuesFilter: IWorkspaceIssuesFilter; workspaceIssues: IWorkspaceIssues; @@ -113,8 +115,6 @@ export class IssueRootStore implements IIssueRootStore { issueKanBanView: IIssueKanBanViewStore; issueCalendarView: ICalendarStore; - issueDetail: IIssueDetail; - constructor(rootStore: RootStore) { makeObservable(this, { workspaceSlug: observable.ref, diff --git a/web/store/issue/workspace/filter.store.ts b/web/store/issue/workspace/filter.store.ts index 3f6aa97b9..4825f593c 100644 --- a/web/store/issue/workspace/filter.store.ts +++ b/web/store/issue/workspace/filter.store.ts @@ -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,9 +11,9 @@ import { IIssueFilterOptions, IIssueDisplayFilterOptions, IIssueDisplayProperties, + TIssueKanbanFilters, IIssueFilters, TIssueParams, - IIssueFiltersResponse, } from "@plane/types"; // constants import { EIssueFilterType, EIssuesStoreType } from "constants/issue"; @@ -33,7 +33,7 @@ export interface IWorkspaceIssuesFilter { workspaceSlug: string, projectId: string | undefined, filterType: EIssueFilterType, - filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties, + filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters, viewId?: string | undefined ) => Promise; } @@ -54,6 +54,9 @@ export class WorkspaceIssuesFilter extends IssueFilterHelperStore implements IWo // computed issueFilters: computed, appliedFilters: computed, + // actions + fetchFilters: action, + updateFilters: action, }); // root store this.rootIssueStore = _rootStore; @@ -93,19 +96,35 @@ export class WorkspaceIssuesFilter extends IssueFilterHelperStore implements IWo fetchFilters = async (workspaceSlug: string, viewId: TWorkspaceFilters) => { try { - let _filters: IIssueFiltersResponse; - if (["all-issues", "assigned", "created", "subscribed"].includes(viewId)) - _filters = this.handleIssuesLocalFilters.get(EIssuesStoreType.GLOBAL, workspaceSlug, undefined, viewId); - else _filters = await this.issueFilterService.getViewDetails(workspaceSlug, viewId); + let filters: IIssueFilterOptions; + let displayFilters: IIssueDisplayFilterOptions; + let displayProperties: IIssueDisplayProperties; + let kanbanFilters: TIssueKanbanFilters = { + group_by: [], + sub_group_by: [], + }; - const filters: IIssueFilterOptions = this.computedFilters(_filters?.filters); - const displayFilters: IIssueDisplayFilterOptions = this.computedDisplayFilters(_filters?.display_filters); - const displayProperties: IIssueDisplayProperties = this.computedDisplayProperties(_filters?.display_properties); + const _filters = this.handleIssuesLocalFilters.get(EIssuesStoreType.GLOBAL, workspaceSlug, undefined, viewId); + filters = this.computedFilters(_filters?.filters); + displayFilters = this.computedDisplayFilters(_filters?.displayFilters); + displayProperties = this.computedDisplayProperties(_filters?.displayProperties); + kanbanFilters = { + group_by: _filters?.kanbanFilters?.group_by || [], + sub_group_by: _filters?.kanbanFilters?.sub_group_by || [], + }; + + if (!["all-issues", "assigned", "created", "subscribed"].includes(viewId)) { + const _filters = await this.issueFilterService.getViewDetails(workspaceSlug, viewId); + filters = this.computedFilters(_filters?.filters); + displayFilters = this.computedDisplayFilters(_filters?.display_filters); + displayProperties = this.computedDisplayProperties(_filters?.display_properties); + } runInAction(() => { set(this.filters, [viewId, "filters"], filters); set(this.filters, [viewId, "displayFilters"], displayFilters); set(this.filters, [viewId, "displayProperties"], displayProperties); + set(this.filters, [viewId, "kanbanFilters"], kanbanFilters); }); } catch (error) { throw error; @@ -116,7 +135,7 @@ export class WorkspaceIssuesFilter extends IssueFilterHelperStore implements IWo workspaceSlug: string, projectId: string | undefined, type: EIssueFilterType, - filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties, + filters: IIssueFilterOptions | IIssueDisplayFilterOptions | IIssueDisplayProperties | TIssueKanbanFilters, viewId: string | undefined = undefined ) => { try { @@ -128,6 +147,7 @@ export class WorkspaceIssuesFilter extends IssueFilterHelperStore implements IWo filters: this.filters[viewId].filters as IIssueFilterOptions, displayFilters: this.filters[viewId].displayFilters as IIssueDisplayFilterOptions, displayProperties: this.filters[viewId].displayProperties as IIssueDisplayProperties, + kanbanFilters: this.filters[viewId].kanbanFilters as TIssueKanbanFilters, }; switch (type) { @@ -141,8 +161,7 @@ export class WorkspaceIssuesFilter extends IssueFilterHelperStore implements IWo }); }); - this.rootIssueStore.projectIssues.fetchIssues(workspaceSlug, viewId, "mutation"); - + this.rootIssueStore.workspaceIssues.fetchIssues(workspaceSlug, viewId, "mutation"); if (["all-issues", "assigned", "created", "subscribed"].includes(viewId)) this.handleIssuesLocalFilters.set(EIssuesStoreType.GLOBAL, type, workspaceSlug, undefined, viewId, { filters: _filters.filters, @@ -216,6 +235,28 @@ export class WorkspaceIssuesFilter extends IssueFilterHelperStore implements IWo 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, undefined, viewId, { + kanban_filters: _filters.kanbanFilters, + }); + + runInAction(() => { + Object.keys(updatedKanbanFilters).forEach((_key) => { + set( + this.filters, + [viewId, "kanbanFilters", _key], + updatedKanbanFilters[_key as keyof TIssueKanbanFilters] + ); + }); + }); + + break; default: break; } diff --git a/yarn.lock b/yarn.lock index aea383166..4318bee68 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1928,11 +1928,6 @@ "@radix-ui/react-primitive" "1.0.0" "@radix-ui/react-use-callback-ref" "1.0.0" -"@radix-ui/react-icons@^1.3.0": - version "1.3.0" - resolved "https://registry.yarnpkg.com/@radix-ui/react-icons/-/react-icons-1.3.0.tgz#c61af8f323d87682c5ca76b856d60c2312dbcb69" - integrity sha512-jQxj/0LKgp+j9BiTXz3O3sgs26RNet2iLWmsPyRz2SIcR4q/4SbazXfnYwbAr+vLYKSfc7qxzyGQA1HLlYiuNw== - "@radix-ui/react-id@1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@radix-ui/react-id/-/react-id-1.0.0.tgz#8d43224910741870a45a8c9d092f25887bb6d11e" @@ -2807,7 +2802,7 @@ dependencies: "@types/react" "*" -"@types/react@*", "@types/react@^18.2.42": +"@types/react@*", "@types/react@18.2.42", "@types/react@^18.2.42": version "18.2.42" resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.42.tgz#6f6b11a904f6d96dda3c2920328a97011a00aba7" integrity sha512-c1zEr96MjakLYus/wPnuWDo1/zErfdU9rNsIGmE+NV71nx88FG9Ttgo5dqorXTu/LImX2f63WBP986gJkMPNbA==