forked from github/plane
chore: update issue detail store to handle peek overview (#2237)
* chore: dynamic position dropdown (#2138) * chore: dynamic position state dropdown for issue view * style: state select dropdown styling * fix: state icon attribute names * chore: state select dynamic dropdown * chore: member select dynamic dropdown * chore: label select dynamic dropdown * chore: priority select dynamic dropdown * chore: label select dropdown improvement * refactor: state dropdown location * chore: dropdown improvement and code refactor * chore: dynamic dropdown hook type added --------- Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com> * fix: fields not getting selected in the create issue form (#2212) * fix: hydration error and draft issue workflow * fix: build error * fix: properties getting de-selected after create, module & cycle not getting auto-select on the form * fix: display layout, props being updated directly * chore: sub issues count in individual issue (#2221) * Implemented nested issues in the sub issues section in issue detail page (#2233) * feat: subissues infinte level * feat: updated UI for sub issues * feat: subissues new ui and nested sub issues in issue detail * chore: removed repeated code * refactor: product updates modal layout (#2225) * fix: handle no issues in custom analytics (#2226) * fix: activity label color (#2227) * fix: profile issues layout switch (#2228) * chore: update service imports * chore: update issue detail store to handle peek overview --------- Co-authored-by: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com> Co-authored-by: Dakshesh Jain <65905942+dakshesh14@users.noreply.github.com> Co-authored-by: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com> Co-authored-by: guru_sainath <gurusainath007@gmail.com>
This commit is contained in:
parent
9b41b5baf5
commit
daa3094911
@ -9,7 +9,6 @@ import { findStringWithMostCharacters } from "helpers/array.helper";
|
||||
import { generateBarColor } from "helpers/analytics.helper";
|
||||
// types
|
||||
import { IAnalyticsParams, IAnalyticsResponse } from "types";
|
||||
// constants
|
||||
|
||||
type Props = {
|
||||
analytics: IAnalyticsResponse;
|
||||
|
@ -1,5 +1,9 @@
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
// services
|
||||
import issuesService from "services/issue.service";
|
||||
// icons
|
||||
import { Icon, Tooltip } from "components/ui";
|
||||
import { CopyPlus } from "lucide-react";
|
||||
@ -10,26 +14,22 @@ import { renderShortDateWithYearFormat } from "helpers/date-time.helper";
|
||||
import { capitalizeFirstLetter } from "helpers/string.helper";
|
||||
// types
|
||||
import { IIssueActivity } from "types";
|
||||
// fetch-keys
|
||||
import { WORKSPACE_LABELS } from "constants/fetch-keys";
|
||||
|
||||
const IssueLink = ({ activity }: { activity: IIssueActivity }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
tooltipContent={
|
||||
activity.issue_detail ? activity.issue_detail.name : "This issue has been deleted"
|
||||
}
|
||||
>
|
||||
<Tooltip tooltipContent={activity.issue_detail ? activity.issue_detail.name : "This issue has been deleted"}>
|
||||
<a
|
||||
href={`/${workspaceSlug}/projects/${activity.project}/issues/${activity.issue}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
|
||||
>
|
||||
{activity.issue_detail
|
||||
? `${activity.project_detail.identifier}-${activity.issue_detail.sequence_id}`
|
||||
: "Issue"}
|
||||
{activity.issue_detail ? `${activity.project_detail.identifier}-${activity.issue_detail.sequence_id}` : "Issue"}
|
||||
<Icon iconName="launch" className="!text-xs" />
|
||||
</a>
|
||||
</Tooltip>
|
||||
@ -52,13 +52,29 @@ const UserLink = ({ activity }: { activity: IIssueActivity }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const LabelPill = ({ labelId }: { labelId: string }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
const { data: labels } = useSWR(
|
||||
workspaceSlug ? WORKSPACE_LABELS(workspaceSlug.toString()) : null,
|
||||
workspaceSlug ? () => issuesService.getWorkspaceLabels(workspaceSlug.toString()) : null
|
||||
);
|
||||
|
||||
return (
|
||||
<span
|
||||
className="h-1.5 w-1.5 rounded-full"
|
||||
style={{
|
||||
backgroundColor: labels?.find((l) => l.id === labelId)?.color ?? "#000000",
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const activityDetails: {
|
||||
[key: string]: {
|
||||
message: (
|
||||
activity: IIssueActivity,
|
||||
showIssue: boolean,
|
||||
workspaceSlug: string
|
||||
) => React.ReactNode;
|
||||
message: (activity: IIssueActivity, showIssue: boolean, workspaceSlug: string) => React.ReactNode;
|
||||
icon: React.ReactNode;
|
||||
};
|
||||
} = {
|
||||
@ -151,8 +167,7 @@ const activityDetails: {
|
||||
else
|
||||
return (
|
||||
<>
|
||||
removed the blocking issue{" "}
|
||||
<span className="font-medium text-custom-text-100">{activity.old_value}</span>.
|
||||
removed the blocking issue <span className="font-medium text-custom-text-100">{activity.old_value}</span>.
|
||||
</>
|
||||
);
|
||||
},
|
||||
@ -208,8 +223,7 @@ const activityDetails: {
|
||||
else
|
||||
return (
|
||||
<>
|
||||
removed the relation from{" "}
|
||||
<span className="font-medium text-custom-text-100">{activity.old_value}</span>.
|
||||
removed the relation from <span className="font-medium text-custom-text-100">{activity.old_value}</span>.
|
||||
</>
|
||||
);
|
||||
},
|
||||
@ -298,8 +312,7 @@ const activityDetails: {
|
||||
else
|
||||
return (
|
||||
<>
|
||||
set the estimate point to{" "}
|
||||
<span className="font-medium text-custom-text-100">{activity.new_value}</span>
|
||||
set the estimate point to <span className="font-medium text-custom-text-100">{activity.new_value}</span>
|
||||
{showIssue && (
|
||||
<>
|
||||
{" "}
|
||||
@ -325,14 +338,8 @@ const activityDetails: {
|
||||
return (
|
||||
<>
|
||||
added a new label{" "}
|
||||
<span className="inline-flex items-center gap-3 rounded-full border border-custom-border-300 px-2 py-0.5 text-xs">
|
||||
<span
|
||||
className="h-1.5 w-1.5 rounded-full"
|
||||
style={{
|
||||
backgroundColor: "#000000",
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-custom-border-300 px-2 py-0.5 text-xs">
|
||||
<LabelPill labelId={activity.new_identifier ?? ""} />
|
||||
<span className="font-medium text-custom-text-100">{activity.new_value}</span>
|
||||
</span>
|
||||
{showIssue && (
|
||||
@ -348,13 +355,7 @@ const activityDetails: {
|
||||
<>
|
||||
removed the label{" "}
|
||||
<span className="inline-flex items-center gap-3 rounded-full border border-custom-border-300 px-2 py-0.5 text-xs">
|
||||
<span
|
||||
className="h-1.5 w-1.5 rounded-full"
|
||||
style={{
|
||||
backgroundColor: "#000000",
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<LabelPill labelId={activity.old_identifier ?? ""} />
|
||||
<span className="font-medium text-custom-text-100">{activity.old_value}</span>
|
||||
</span>
|
||||
{showIssue && (
|
||||
@ -509,8 +510,7 @@ const activityDetails: {
|
||||
if (!activity.new_value)
|
||||
return (
|
||||
<>
|
||||
removed the parent{" "}
|
||||
<span className="font-medium text-custom-text-100">{activity.old_value}</span>
|
||||
removed the parent <span className="font-medium text-custom-text-100">{activity.old_value}</span>
|
||||
{showIssue && (
|
||||
<>
|
||||
{" "}
|
||||
@ -523,8 +523,7 @@ const activityDetails: {
|
||||
else
|
||||
return (
|
||||
<>
|
||||
set the parent to{" "}
|
||||
<span className="font-medium text-custom-text-100">{activity.new_value}</span>
|
||||
set the parent to <span className="font-medium text-custom-text-100">{activity.new_value}</span>
|
||||
{showIssue && (
|
||||
<>
|
||||
{" "}
|
||||
@ -592,8 +591,7 @@ const activityDetails: {
|
||||
state: {
|
||||
message: (activity, showIssue) => (
|
||||
<>
|
||||
set the state to{" "}
|
||||
<span className="font-medium text-custom-text-100">{activity.new_value}</span>
|
||||
set the state to <span className="font-medium text-custom-text-100">{activity.new_value}</span>
|
||||
{showIssue && (
|
||||
<>
|
||||
{" "}
|
||||
@ -645,13 +643,7 @@ export const ActivityIcon = ({ activity }: { activity: IIssueActivity }) => (
|
||||
<>{activityDetails[activity.field as keyof typeof activityDetails]?.icon}</>
|
||||
);
|
||||
|
||||
export const ActivityMessage = ({
|
||||
activity,
|
||||
showIssue = false,
|
||||
}: {
|
||||
activity: IIssueActivity;
|
||||
showIssue?: boolean;
|
||||
}) => {
|
||||
export const ActivityMessage = ({ activity, showIssue = false }: { activity: IIssueActivity; showIssue?: boolean }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
|
@ -78,7 +78,7 @@ export const AllBoards: React.FC<Props> = ({
|
||||
readOnly={disableUserActions}
|
||||
/>
|
||||
{groupedIssues ? (
|
||||
<div className="horizontal-scroll-enable flex h-full gap-x-4 p-8">
|
||||
<div className="horizontal-scroll-enable flex h-full gap-x-4 p-8 bg-custom-background-90">
|
||||
{Object.keys(groupedIssues).map((singleGroup, index) => {
|
||||
const currentState =
|
||||
displayFilters?.group_by === "state"
|
||||
|
@ -35,9 +35,17 @@ type Props = {
|
||||
data: IIssue | null;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
onSubmit?: () => Promise<void>;
|
||||
redirection?: boolean;
|
||||
};
|
||||
|
||||
export const DeleteIssueModal: React.FC<Props> = ({ isOpen, handleClose, data, user, onSubmit }) => {
|
||||
export const DeleteIssueModal: React.FC<Props> = ({
|
||||
isOpen,
|
||||
handleClose,
|
||||
data,
|
||||
user,
|
||||
onSubmit,
|
||||
redirection = true,
|
||||
}) => {
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
@ -122,7 +130,7 @@ export const DeleteIssueModal: React.FC<Props> = ({ isOpen, handleClose, data, u
|
||||
message: "Issue deleted successfully",
|
||||
});
|
||||
|
||||
if (issueId) router.back();
|
||||
if (issueId && redirection) router.back();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
|
@ -138,8 +138,7 @@ export const CreateUpdateDraftIssueModal: React.FC<IssuesModalProps> = (props) =
|
||||
// issue. This has more priority than the project in the url.
|
||||
if (data && data.project) return setActiveProject(data.project);
|
||||
|
||||
if (prePopulateData && prePopulateData.project && !activeProject)
|
||||
return setActiveProject(prePopulateData.project);
|
||||
if (prePopulateData && prePopulateData.project && !activeProject) return setActiveProject(prePopulateData.project);
|
||||
|
||||
if (prePopulateData && prePopulateData.project) return setActiveProject(prePopulateData.project);
|
||||
|
||||
@ -349,8 +348,7 @@ export const CreateUpdateDraftIssueModal: React.FC<IssuesModalProps> = (props) =
|
||||
};
|
||||
|
||||
if (action === "createDraft") await createDraftIssue(payload);
|
||||
else if (action === "updateDraft" || action === "convertToNewIssue")
|
||||
await updateDraftIssue(payload);
|
||||
else if (action === "updateDraft" || action === "convertToNewIssue") await updateDraftIssue(payload);
|
||||
else if (action === "createNewIssue") await createIssue(payload);
|
||||
|
||||
clearDraftIssueLocalStorage();
|
||||
|
@ -12,7 +12,6 @@ export * from "./main-content";
|
||||
export * from "./modal";
|
||||
export * from "./parent-issues-list-modal";
|
||||
export * from "./sidebar";
|
||||
export * from "./sub-issues-list";
|
||||
export * from "./label";
|
||||
export * from "./issue-reaction";
|
||||
export * from "./peek-overview";
|
||||
|
@ -18,9 +18,9 @@ import {
|
||||
IssueAttachmentUpload,
|
||||
IssueAttachments,
|
||||
IssueDescriptionForm,
|
||||
SubIssuesList,
|
||||
IssueReaction,
|
||||
} from "components/issues";
|
||||
import { SubIssuesRoot } from "./sub-issues";
|
||||
// ui
|
||||
import { CustomMenu } from "components/ui";
|
||||
// icons
|
||||
@ -170,7 +170,7 @@ export const IssueMainContent: React.FC<Props> = ({ issueDetails, submitChanges,
|
||||
<IssueReaction workspaceSlug={workspaceSlug} issueId={issueId} projectId={projectId} />
|
||||
|
||||
<div className="mt-2 space-y-2">
|
||||
<SubIssuesList parentIssue={issueDetails} user={user} disabled={uneditable} />
|
||||
<SubIssuesRoot parentIssue={issueDetails} user={user} editable={uneditable} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 py-3">
|
||||
|
@ -36,7 +36,7 @@ export const IssuePeekOverview: React.FC<Props> = observer(({ handleMutation, pr
|
||||
const { peekIssue } = router.query;
|
||||
|
||||
const { issueDetail: issueDetailStore } = useMobxStore();
|
||||
const { deleteIssue, getIssueById, issues, updateIssue } = issueDetailStore;
|
||||
const { deleteIssue, fetchIssueDetails, issues, updateIssue } = issueDetailStore;
|
||||
|
||||
const issue = issues[peekIssue?.toString() ?? ""];
|
||||
|
||||
@ -72,8 +72,8 @@ export const IssuePeekOverview: React.FC<Props> = observer(({ handleMutation, pr
|
||||
useEffect(() => {
|
||||
if (!peekIssue) return;
|
||||
|
||||
getIssueById(workspaceSlug, projectId, peekIssue.toString());
|
||||
}, [getIssueById, peekIssue, projectId, workspaceSlug]);
|
||||
fetchIssueDetails(workspaceSlug, projectId, peekIssue.toString());
|
||||
}, [fetchIssueDetails, peekIssue, projectId, workspaceSlug]);
|
||||
|
||||
useEffect(() => {
|
||||
if (peekIssue) {
|
||||
|
1
web/components/issues/sub-issues/index.ts
Normal file
1
web/components/issues/sub-issues/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./root";
|
171
web/components/issues/sub-issues/issue.tsx
Normal file
171
web/components/issues/sub-issues/issue.tsx
Normal file
@ -0,0 +1,171 @@
|
||||
import React from "react";
|
||||
// next imports
|
||||
import Link from "next/link";
|
||||
// lucide icons
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
X,
|
||||
Pencil,
|
||||
Trash,
|
||||
Link as LinkIcon,
|
||||
Loader,
|
||||
} from "lucide-react";
|
||||
// components
|
||||
import { SubIssuesRootList } from "./issues-list";
|
||||
import { IssueProperty } from "./properties";
|
||||
// ui
|
||||
import { Tooltip, CustomMenu } from "components/ui";
|
||||
|
||||
// types
|
||||
import { ICurrentUserResponse, IIssue } from "types";
|
||||
|
||||
export interface ISubIssues {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
parentIssue: IIssue;
|
||||
issue: any;
|
||||
spacingLeft?: number;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
editable: boolean;
|
||||
removeIssueFromSubIssues: (parentIssueId: string, issue: IIssue) => void;
|
||||
issuesVisibility: string[];
|
||||
handleIssuesVisibility: (issueId: string) => void;
|
||||
copyText: (text: string) => void;
|
||||
handleIssueCrudOperation: (
|
||||
key: "create" | "existing" | "edit" | "delete",
|
||||
issueId: string,
|
||||
issue?: IIssue | null
|
||||
) => void;
|
||||
}
|
||||
|
||||
export const SubIssues: React.FC<ISubIssues> = ({
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
parentIssue,
|
||||
issue,
|
||||
spacingLeft = 0,
|
||||
user,
|
||||
editable,
|
||||
removeIssueFromSubIssues,
|
||||
issuesVisibility,
|
||||
handleIssuesVisibility,
|
||||
copyText,
|
||||
handleIssueCrudOperation,
|
||||
}) => (
|
||||
<div>
|
||||
{issue && (
|
||||
<div
|
||||
className="relative flex items-center gap-2 py-1 px-2 w-full h-full hover:bg-custom-background-90 group transition-all border-b border-custom-border-100"
|
||||
style={{ paddingLeft: `${spacingLeft}px` }}
|
||||
>
|
||||
<div className="flex-shrink-0 w-[22px] h-[22px]">
|
||||
{issue?.sub_issues_count > 0 && (
|
||||
<>
|
||||
{true ? (
|
||||
<div
|
||||
className="w-full h-full flex justify-center items-center rounded-sm hover:bg-custom-background-80 transition-all cursor-pointer"
|
||||
onClick={() => handleIssuesVisibility(issue?.id)}
|
||||
>
|
||||
{issuesVisibility && issuesVisibility.includes(issue?.id) ? (
|
||||
<ChevronDown width={14} strokeWidth={2} />
|
||||
) : (
|
||||
<ChevronRight width={14} strokeWidth={2} />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Loader width={14} strokeWidth={2} className="animate-spin" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Link href={`/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`}>
|
||||
<a className="w-full flex items-center gap-2">
|
||||
<div
|
||||
className="flex-shrink-0 w-[6px] h-[6px] rounded-full"
|
||||
style={{
|
||||
backgroundColor: issue.state_detail.color,
|
||||
}}
|
||||
/>
|
||||
<div className="flex-shrink-0 text-xs text-custom-text-200">
|
||||
{issue.project_detail.identifier}-{issue?.sequence_id}
|
||||
</div>
|
||||
<Tooltip tooltipHeading="Title" tooltipContent={`${issue?.name}`}>
|
||||
<div className="line-clamp-1 text-xs text-custom-text-100">{issue?.name}</div>
|
||||
</Tooltip>
|
||||
</a>
|
||||
</Link>
|
||||
|
||||
<div className="flex-shrink-0 text-sm">
|
||||
<IssueProperty
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
parentIssue={parentIssue}
|
||||
issue={issue}
|
||||
user={user}
|
||||
editable={editable}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0 text-sm">
|
||||
<CustomMenu width="auto" ellipsis>
|
||||
{editable && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => handleIssueCrudOperation("edit", parentIssue?.id, issue)}
|
||||
>
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
<Pencil width={14} strokeWidth={2} />
|
||||
<span>Edit issue</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
|
||||
{editable && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => handleIssueCrudOperation("delete", parentIssue?.id, issue)}
|
||||
>
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
<Trash width={14} strokeWidth={2} />
|
||||
<span>Delete issue</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
|
||||
<CustomMenu.MenuItem onClick={copyText}>
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
<LinkIcon width={14} strokeWidth={2} />
|
||||
<span>Copy issue link</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
</div>
|
||||
|
||||
{editable && (
|
||||
<div
|
||||
className="flex-shrink-0 invisible group-hover:visible w-[22px] h-[22px] flex justify-center items-center rounded-sm hover:bg-custom-background-80 transition-all cursor-pointer overflow-hidden"
|
||||
onClick={() => removeIssueFromSubIssues(parentIssue?.id, issue)}
|
||||
>
|
||||
<X width={14} strokeWidth={2} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{issuesVisibility.includes(issue?.id) && issue?.sub_issues_count > 0 && (
|
||||
<SubIssuesRootList
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
parentIssue={issue}
|
||||
spacingLeft={spacingLeft + 22}
|
||||
user={user}
|
||||
editable={editable}
|
||||
removeIssueFromSubIssues={removeIssueFromSubIssues}
|
||||
issuesVisibility={issuesVisibility}
|
||||
handleIssuesVisibility={handleIssuesVisibility}
|
||||
copyText={copyText}
|
||||
handleIssueCrudOperation={handleIssueCrudOperation}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
80
web/components/issues/sub-issues/issues-list.tsx
Normal file
80
web/components/issues/sub-issues/issues-list.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
import React from "react";
|
||||
// swr
|
||||
import useSWR from "swr";
|
||||
// components
|
||||
import { SubIssues } from "./issue";
|
||||
// types
|
||||
import { ICurrentUserResponse, IIssue } from "types";
|
||||
// services
|
||||
import issuesService from "services/issue.service";
|
||||
// fetch keys
|
||||
import { SUB_ISSUES } from "constants/fetch-keys";
|
||||
|
||||
export interface ISubIssuesRootList {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
parentIssue: IIssue;
|
||||
spacingLeft?: number;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
editable: boolean;
|
||||
removeIssueFromSubIssues: (parentIssueId: string, issue: IIssue) => void;
|
||||
issuesVisibility: string[];
|
||||
handleIssuesVisibility: (issueId: string) => void;
|
||||
copyText: (text: string) => void;
|
||||
handleIssueCrudOperation: (
|
||||
key: "create" | "existing" | "edit" | "delete",
|
||||
issueId: string,
|
||||
issue?: IIssue | null
|
||||
) => void;
|
||||
}
|
||||
|
||||
export const SubIssuesRootList: React.FC<ISubIssuesRootList> = ({
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
parentIssue,
|
||||
spacingLeft = 10,
|
||||
user,
|
||||
editable,
|
||||
removeIssueFromSubIssues,
|
||||
issuesVisibility,
|
||||
handleIssuesVisibility,
|
||||
copyText,
|
||||
handleIssueCrudOperation,
|
||||
}) => {
|
||||
const { data: issues, isLoading } = useSWR(
|
||||
workspaceSlug && projectId && parentIssue && parentIssue?.id ? SUB_ISSUES(parentIssue?.id) : null,
|
||||
workspaceSlug && projectId && parentIssue && parentIssue?.id
|
||||
? () => issuesService.subIssues(workspaceSlug, projectId, parentIssue.id)
|
||||
: null
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{issues &&
|
||||
issues.sub_issues &&
|
||||
issues.sub_issues.length > 0 &&
|
||||
issues.sub_issues.map((issue: IIssue) => (
|
||||
<SubIssues
|
||||
key={`${issue?.id}`}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
parentIssue={parentIssue}
|
||||
issue={issue}
|
||||
spacingLeft={spacingLeft}
|
||||
user={user}
|
||||
editable={editable}
|
||||
removeIssueFromSubIssues={removeIssueFromSubIssues}
|
||||
issuesVisibility={issuesVisibility}
|
||||
handleIssuesVisibility={handleIssuesVisibility}
|
||||
copyText={copyText}
|
||||
handleIssueCrudOperation={handleIssueCrudOperation}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div
|
||||
className={`absolute top-0 bottom-0 ${spacingLeft > 10 ? `border-l border-custom-border-100` : ``}`}
|
||||
style={{ left: `${spacingLeft - 12}px` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
25
web/components/issues/sub-issues/progressbar.tsx
Normal file
25
web/components/issues/sub-issues/progressbar.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
export interface IProgressBar {
|
||||
total: number;
|
||||
done: number;
|
||||
}
|
||||
|
||||
export const ProgressBar = ({ total = 0, done = 0 }: IProgressBar) => {
|
||||
const calPercentage = (doneValue: number, totalValue: number): string => {
|
||||
if (doneValue === 0 || totalValue === 0) return (0).toFixed(0);
|
||||
return ((100 * doneValue) / totalValue).toFixed(0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center gap-2">
|
||||
<div className="w-full">
|
||||
<div className="w-full rounded-full bg-custom-background-80 overflow-hidden shadow">
|
||||
<div
|
||||
className="bg-green-500 h-[6px] rounded-full"
|
||||
style={{ width: `${calPercentage(done, total)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0 text-xs font-medium">{calPercentage(done, total)}% Done</div>
|
||||
</div>
|
||||
);
|
||||
};
|
193
web/components/issues/sub-issues/properties.tsx
Normal file
193
web/components/issues/sub-issues/properties.tsx
Normal file
@ -0,0 +1,193 @@
|
||||
import React from "react";
|
||||
// swr
|
||||
import { mutate } from "swr";
|
||||
// services
|
||||
import issuesService from "services/issue.service";
|
||||
import trackEventServices from "services/track_event.service";
|
||||
// components
|
||||
import { ViewDueDateSelect, ViewStartDateSelect } from "components/issues";
|
||||
import { MembersSelect, PrioritySelect } from "components/project";
|
||||
import { StateSelect } from "components/states";
|
||||
// hooks
|
||||
import useIssuesProperties from "hooks/use-issue-properties";
|
||||
// types
|
||||
import { ICurrentUserResponse, IIssue, IState } from "types";
|
||||
// fetch-keys
|
||||
import { SUB_ISSUES } from "constants/fetch-keys";
|
||||
|
||||
export interface IIssueProperty {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
parentIssue: IIssue;
|
||||
issue: IIssue;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
editable: boolean;
|
||||
}
|
||||
|
||||
export const IssueProperty: React.FC<IIssueProperty> = ({
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
parentIssue,
|
||||
issue,
|
||||
user,
|
||||
editable,
|
||||
}) => {
|
||||
const [properties] = useIssuesProperties(workspaceSlug, projectId);
|
||||
|
||||
const handlePriorityChange = (data: any) => {
|
||||
partialUpdateIssue({ priority: data });
|
||||
trackEventServices.trackIssuePartialPropertyUpdateEvent(
|
||||
{
|
||||
workspaceSlug,
|
||||
workspaceId: issue.workspace,
|
||||
projectId: issue.project_detail.id,
|
||||
projectIdentifier: issue.project_detail.identifier,
|
||||
projectName: issue.project_detail.name,
|
||||
issueId: issue.id,
|
||||
},
|
||||
"ISSUE_PROPERTY_UPDATE_PRIORITY",
|
||||
user
|
||||
);
|
||||
};
|
||||
|
||||
const handleStateChange = (data: string, states: IState[] | undefined) => {
|
||||
const oldState = states?.find((s) => s.id === issue.state);
|
||||
const newState = states?.find((s) => s.id === data);
|
||||
|
||||
partialUpdateIssue({
|
||||
state: data,
|
||||
state_detail: newState,
|
||||
});
|
||||
trackEventServices.trackIssuePartialPropertyUpdateEvent(
|
||||
{
|
||||
workspaceSlug,
|
||||
workspaceId: issue.workspace,
|
||||
projectId: issue.project_detail.id,
|
||||
projectIdentifier: issue.project_detail.identifier,
|
||||
projectName: issue.project_detail.name,
|
||||
issueId: issue.id,
|
||||
},
|
||||
"ISSUE_PROPERTY_UPDATE_STATE",
|
||||
user
|
||||
);
|
||||
if (oldState?.group !== "completed" && newState?.group !== "completed") {
|
||||
trackEventServices.trackIssueMarkedAsDoneEvent(
|
||||
{
|
||||
workspaceSlug: issue.workspace_detail.slug,
|
||||
workspaceId: issue.workspace_detail.id,
|
||||
projectId: issue.project_detail.id,
|
||||
projectIdentifier: issue.project_detail.identifier,
|
||||
projectName: issue.project_detail.name,
|
||||
issueId: issue.id,
|
||||
},
|
||||
user
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssigneeChange = (data: any) => {
|
||||
const newData = issue.assignees ?? [];
|
||||
|
||||
if (newData.includes(data)) newData.splice(newData.indexOf(data), 1);
|
||||
else newData.push(data);
|
||||
|
||||
partialUpdateIssue({ assignees_list: data });
|
||||
|
||||
trackEventServices.trackIssuePartialPropertyUpdateEvent(
|
||||
{
|
||||
workspaceSlug,
|
||||
workspaceId: issue.workspace,
|
||||
projectId: issue.project_detail.id,
|
||||
projectIdentifier: issue.project_detail.identifier,
|
||||
projectName: issue.project_detail.name,
|
||||
issueId: issue.id,
|
||||
},
|
||||
"ISSUE_PROPERTY_UPDATE_ASSIGNEE",
|
||||
user
|
||||
);
|
||||
};
|
||||
|
||||
const partialUpdateIssue = async (data: Partial<IIssue>) => {
|
||||
mutate(
|
||||
workspaceSlug && parentIssue ? SUB_ISSUES(parentIssue.id) : null,
|
||||
(elements: any) => {
|
||||
const _elements = { ...elements };
|
||||
const _issues = _elements.sub_issues.map((element: IIssue) =>
|
||||
element.id === issue.id ? { ...element, ...data } : element
|
||||
);
|
||||
_elements["sub_issues"] = [..._issues];
|
||||
return _elements;
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
const issueResponse = await issuesService.patchIssue(workspaceSlug as string, issue.project, issue.id, data, user);
|
||||
|
||||
mutate(
|
||||
SUB_ISSUES(parentIssue.id),
|
||||
(elements: any) => {
|
||||
const _elements = elements.sub_issues.map((element: IIssue) =>
|
||||
element.id === issue.id ? issueResponse : element
|
||||
);
|
||||
elements["sub_issues"] = _elements;
|
||||
return elements;
|
||||
},
|
||||
true
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center gap-1">
|
||||
{properties.priority && (
|
||||
<div className="flex-shrink-0">
|
||||
<PrioritySelect
|
||||
value={issue.priority}
|
||||
onChange={handlePriorityChange}
|
||||
hideDropdownArrow
|
||||
disabled={!editable}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{properties.state && (
|
||||
<div className="flex-shrink-0">
|
||||
<StateSelect value={issue.state_detail} onChange={handleStateChange} hideDropdownArrow disabled={!editable} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{properties.start_date && issue.start_date && (
|
||||
<div className="flex-shrink-0 w-[104px]">
|
||||
<ViewStartDateSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
user={user}
|
||||
isNotAllowed={!editable}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{properties.due_date && issue.target_date && (
|
||||
<div className="flex-shrink-0 w-[104px]">
|
||||
<ViewDueDateSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
user={user}
|
||||
isNotAllowed={!editable}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{properties.assignee && (
|
||||
<div className="flex-shrink-0">
|
||||
<MembersSelect
|
||||
value={issue.assignees}
|
||||
onChange={handleAssigneeChange}
|
||||
membersDetails={issue.assignee_details}
|
||||
hideDropdownArrow
|
||||
disabled={!editable}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
269
web/components/issues/sub-issues/root.tsx
Normal file
269
web/components/issues/sub-issues/root.tsx
Normal file
@ -0,0 +1,269 @@
|
||||
import React from "react";
|
||||
// next imports
|
||||
import { useRouter } from "next/router";
|
||||
// swr
|
||||
import useSWR, { mutate } from "swr";
|
||||
// lucide icons
|
||||
import { Plus, ChevronRight, ChevronDown } from "lucide-react";
|
||||
// components
|
||||
import { ExistingIssuesListModal } from "components/core";
|
||||
import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
|
||||
import { SubIssuesRootList } from "./issues-list";
|
||||
import { ProgressBar } from "./progressbar";
|
||||
// ui
|
||||
import { CustomMenu } from "components/ui";
|
||||
// hooks
|
||||
import { useProjectMyMembership } from "contexts/project-member.context";
|
||||
// helpers
|
||||
import { copyTextToClipboard } from "helpers/string.helper";
|
||||
// types
|
||||
import { ICurrentUserResponse, IIssue, ISearchIssueResponse } from "types";
|
||||
// services
|
||||
import issuesService from "services/issue.service";
|
||||
// fetch keys
|
||||
import { SUB_ISSUES } from "constants/fetch-keys";
|
||||
|
||||
export interface ISubIssuesRoot {
|
||||
parentIssue: IIssue;
|
||||
|
||||
user: ICurrentUserResponse | undefined;
|
||||
editable: boolean;
|
||||
}
|
||||
|
||||
export const SubIssuesRoot: React.FC<ISubIssuesRoot> = ({ parentIssue, user, editable }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query as { workspaceSlug: string; projectId: string };
|
||||
|
||||
const { memberRole } = useProjectMyMembership();
|
||||
|
||||
const { data: issues } = useSWR(
|
||||
workspaceSlug && projectId && parentIssue && parentIssue?.id ? SUB_ISSUES(parentIssue?.id) : null,
|
||||
workspaceSlug && projectId && parentIssue && parentIssue?.id
|
||||
? () => issuesService.subIssues(workspaceSlug, projectId, parentIssue.id)
|
||||
: null
|
||||
);
|
||||
|
||||
const [issuesVisibility, setIssuesVisibility] = React.useState<string[]>([parentIssue?.id]);
|
||||
const handleIssuesVisibility = (issueId: string) => {
|
||||
if (issuesVisibility.includes(issueId)) {
|
||||
setIssuesVisibility(issuesVisibility.filter((i: string) => i !== issueId));
|
||||
} else {
|
||||
setIssuesVisibility([...issuesVisibility, issueId]);
|
||||
}
|
||||
};
|
||||
|
||||
const [issueCrudOperation, setIssueCrudOperation] = React.useState<{
|
||||
create: { toggle: boolean; issueId: string | null };
|
||||
existing: { toggle: boolean; issueId: string | null };
|
||||
edit: { toggle: boolean; issueId: string | null; issue: IIssue | null };
|
||||
delete: { toggle: boolean; issueId: string | null; issue: IIssue | null };
|
||||
}>({
|
||||
create: {
|
||||
toggle: false,
|
||||
issueId: null,
|
||||
},
|
||||
existing: {
|
||||
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",
|
||||
issueId: string | null,
|
||||
issue: IIssue | null = null
|
||||
) => {
|
||||
setIssueCrudOperation({
|
||||
...issueCrudOperation,
|
||||
[key]: {
|
||||
toggle: !issueCrudOperation[key].toggle,
|
||||
issueId: issueId,
|
||||
issue: issue,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const addAsSubIssueFromExistingIssues = async (data: ISearchIssueResponse[]) => {
|
||||
if (!workspaceSlug || !parentIssue || issueCrudOperation?.existing?.issueId === null) return;
|
||||
const issueId = issueCrudOperation?.existing?.issueId;
|
||||
const payload = {
|
||||
sub_issue_ids: data.map((i) => i.id),
|
||||
};
|
||||
|
||||
await issuesService.addSubIssues(workspaceSlug, projectId, issueId, payload).finally(() => {
|
||||
if (issueId) mutate(SUB_ISSUES(issueId));
|
||||
});
|
||||
};
|
||||
|
||||
const removeIssueFromSubIssues = async (parentIssueId: string, issue: IIssue) => {
|
||||
if (!workspaceSlug || !parentIssue || !issue?.id) return;
|
||||
issuesService.patchIssue(workspaceSlug, projectId, issue.id, { parent: null }, user).finally(() => {
|
||||
if (parentIssueId) mutate(SUB_ISSUES(parentIssueId));
|
||||
});
|
||||
};
|
||||
|
||||
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 isEditable = memberRole?.isGuest || memberRole?.isViewer ? false : true;
|
||||
|
||||
const mutateSubIssues = (parentIssueId: string | null) => {
|
||||
if (parentIssueId) mutate(SUB_ISSUES(parentIssueId));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-full space-y-2">
|
||||
{parentIssue && parentIssue?.sub_issues_count > 0 ? (
|
||||
<>
|
||||
{/* header */}
|
||||
<div className="relative flex items-center gap-4 text-xs">
|
||||
<div
|
||||
className="rounded border border-custom-border-100 shadow p-1.5 px-2 flex items-center gap-1 hover:bg-custom-background-80 transition-all cursor-pointer select-none"
|
||||
onClick={() => handleIssuesVisibility(parentIssue?.id)}
|
||||
>
|
||||
<div className="flex-shrink-0 w-[16px] h-[16px] flex justify-center items-center">
|
||||
{issuesVisibility.includes(parentIssue?.id) ? (
|
||||
<ChevronDown width={16} strokeWidth={2} />
|
||||
) : (
|
||||
<ChevronRight width={14} strokeWidth={2} />
|
||||
)}
|
||||
</div>
|
||||
<div>Sub-issues</div>
|
||||
<div>({parentIssue?.sub_issues_count})</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-[250px] select-none">
|
||||
<ProgressBar
|
||||
total={parentIssue?.sub_issues_count}
|
||||
done={(issues?.state_distribution?.cancelled || 0) + (issues?.state_distribution?.completed || 0)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isEditable && issuesVisibility.includes(parentIssue?.id) && (
|
||||
<div className="ml-auto flex-shrink-0 flex items-center gap-2 select-none">
|
||||
<div
|
||||
className="hover:bg-custom-background-80 transition-all cursor-pointer p-1.5 px-2 rounded border border-custom-border-100 shadow"
|
||||
onClick={() => handleIssueCrudOperation("create", parentIssue?.id)}
|
||||
>
|
||||
Add sub-issue
|
||||
</div>
|
||||
<div
|
||||
className="hover:bg-custom-background-80 transition-all cursor-pointer p-1.5 px-2 rounded border border-custom-border-100 shadow"
|
||||
onClick={() => handleIssueCrudOperation("existing", parentIssue?.id)}
|
||||
>
|
||||
Add an existing issue
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* issues */}
|
||||
{issuesVisibility.includes(parentIssue?.id) && (
|
||||
<div className="border border-b-0 border-custom-border-100">
|
||||
<SubIssuesRootList
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
parentIssue={parentIssue}
|
||||
user={undefined}
|
||||
editable={isEditable}
|
||||
removeIssueFromSubIssues={removeIssueFromSubIssues}
|
||||
issuesVisibility={issuesVisibility}
|
||||
handleIssuesVisibility={handleIssuesVisibility}
|
||||
copyText={copyText}
|
||||
handleIssueCrudOperation={handleIssueCrudOperation}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
isEditable && (
|
||||
<div className="text-xs py-2 text-custom-text-300 font-medium">
|
||||
<div className="py-3 text-center">No sub issues are available</div>
|
||||
<>
|
||||
<CustomMenu
|
||||
label={
|
||||
<>
|
||||
<Plus className="h-3 w-3" />
|
||||
Add sub-issue
|
||||
</>
|
||||
}
|
||||
buttonClassName="whitespace-nowrap"
|
||||
position="left"
|
||||
noBorder
|
||||
noChevron
|
||||
>
|
||||
<CustomMenu.MenuItem onClick={() => handleIssueCrudOperation("create", parentIssue?.id)}>
|
||||
Create new
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={() => handleIssueCrudOperation("existing", parentIssue?.id)}>
|
||||
Add an existing issue
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{isEditable && issueCrudOperation?.create?.toggle && (
|
||||
<CreateUpdateIssueModal
|
||||
isOpen={issueCrudOperation?.create?.toggle}
|
||||
prePopulateData={{
|
||||
parent: issueCrudOperation?.create?.issueId,
|
||||
}}
|
||||
handleClose={() => handleIssueCrudOperation("create", null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isEditable && issueCrudOperation?.existing?.toggle && issueCrudOperation?.existing?.issueId && (
|
||||
<ExistingIssuesListModal
|
||||
isOpen={issueCrudOperation?.existing?.toggle}
|
||||
handleClose={() => handleIssueCrudOperation("existing", null)}
|
||||
searchParams={{ sub_issue: true, issue_id: issueCrudOperation?.existing?.issueId }}
|
||||
handleOnSubmit={addAsSubIssueFromExistingIssues}
|
||||
workspaceLevelToggle
|
||||
/>
|
||||
)}
|
||||
|
||||
{isEditable && issueCrudOperation?.edit?.toggle && issueCrudOperation?.edit?.issueId && (
|
||||
<CreateUpdateIssueModal
|
||||
isOpen={issueCrudOperation?.edit?.toggle}
|
||||
handleClose={() => {
|
||||
mutateSubIssues(issueCrudOperation?.edit?.issueId);
|
||||
handleIssueCrudOperation("edit", null, null);
|
||||
}}
|
||||
data={issueCrudOperation?.edit?.issue}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isEditable && issueCrudOperation?.delete?.toggle && issueCrudOperation?.delete?.issueId && (
|
||||
<DeleteIssueModal
|
||||
isOpen={issueCrudOperation?.delete?.toggle}
|
||||
handleClose={() => {
|
||||
mutateSubIssues(issueCrudOperation?.delete?.issueId);
|
||||
handleIssueCrudOperation("delete", null, null);
|
||||
}}
|
||||
data={issueCrudOperation?.delete?.issue}
|
||||
user={user}
|
||||
redirection={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
@ -49,7 +49,6 @@ export const ProfileIssuesView = () => {
|
||||
groupedIssues,
|
||||
mutateProfileIssues,
|
||||
displayFilters,
|
||||
setDisplayFilters,
|
||||
isEmpty,
|
||||
filters,
|
||||
setFilters,
|
||||
|
@ -74,15 +74,13 @@ export const MembersSelect: React.FC<Props> = ({
|
||||
}));
|
||||
|
||||
const filteredOptions =
|
||||
query === ""
|
||||
? options
|
||||
: options?.filter((option) => option.query.toLowerCase().includes(query.toLowerCase()));
|
||||
query === "" ? options : options?.filter((option) => option.query.toLowerCase().includes(query.toLowerCase()));
|
||||
|
||||
const label = (
|
||||
<Tooltip
|
||||
tooltipHeading="Assignee"
|
||||
tooltipContent={
|
||||
membersDetails.length > 0
|
||||
membersDetails && membersDetails.length > 0
|
||||
? membersDetails.map((assignee) => assignee?.display_name).join(", ")
|
||||
: "No Assignee"
|
||||
}
|
||||
@ -126,15 +124,11 @@ export const MembersSelect: React.FC<Props> = ({
|
||||
ref={dropdownBtn}
|
||||
type="button"
|
||||
className={`flex items-center justify-between gap-1 w-full text-xs ${
|
||||
disabled
|
||||
? "cursor-not-allowed text-custom-text-200"
|
||||
: "cursor-pointer hover:bg-custom-background-80"
|
||||
disabled ? "cursor-not-allowed text-custom-text-200" : "cursor-pointer hover:bg-custom-background-80"
|
||||
} ${buttonClassName}`}
|
||||
>
|
||||
{label}
|
||||
{!hideDropdownArrow && !disabled && (
|
||||
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
|
||||
)}
|
||||
{!hideDropdownArrow && !disabled && <ChevronDownIcon className="h-3 w-3" aria-hidden="true" />}
|
||||
</Combobox.Button>
|
||||
<div className={`${open ? "fixed z-20 top-0 left-0 h-full w-full cursor-auto" : ""}`}>
|
||||
<Combobox.Options
|
||||
|
@ -1,15 +1,16 @@
|
||||
import React from "react";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
// headless ui
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// component
|
||||
import { MarkdownRenderer, Spinner } from "components/ui";
|
||||
// icons
|
||||
import { XMarkIcon } from "@heroicons/react/20/solid";
|
||||
// services
|
||||
import workspaceService from "services/workspace.service";
|
||||
// helper
|
||||
// components
|
||||
import { Loader, MarkdownRenderer } from "components/ui";
|
||||
// icons
|
||||
import { XMarkIcon } from "@heroicons/react/20/solid";
|
||||
// helpers
|
||||
import { renderLongDateFormat } from "helpers/date-time.helper";
|
||||
|
||||
type Props = {
|
||||
@ -34,8 +35,8 @@ export const ProductUpdatesModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
|
||||
<div className="fixed inset-0 bg-custom-backdrop bg-opacity-50 transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-20 overflow-y-auto">
|
||||
<div className="grid place-items-center min-h-full text-center p-4">
|
||||
<div className="fixed inset-0 z-20 h-full w-full">
|
||||
<div className="grid place-items-center h-full w-full p-4">
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
@ -45,49 +46,62 @@ export const ProductUpdatesModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-custom-background-100 border border-custom-border-100 text-left shadow-xl transition-all grid place-items-center sm:w-full sm:max-w-2xl">
|
||||
<div className="max-h-[90vh] overflow-y-auto p-5">
|
||||
<div className="sm:flex sm:items-start">
|
||||
<div className="flex w-full flex-col gap-y-4 text-center sm:text-left">
|
||||
<Dialog.Title
|
||||
as="h3"
|
||||
className="flex justify-between text-lg font-medium leading-6 text-custom-text-100"
|
||||
>
|
||||
<span>Product Updates</span>
|
||||
<span>
|
||||
<button type="button" onClick={() => setIsOpen(false)}>
|
||||
<XMarkIcon
|
||||
className="h-6 w-6 text-custom-text-200 hover:text-custom-text-100"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</span>
|
||||
</Dialog.Title>
|
||||
{updates && updates.length > 0 ? (
|
||||
updates.map((item, index) => (
|
||||
<React.Fragment key={item.id}>
|
||||
<div className="flex items-center gap-3 text-xs text-custom-text-200">
|
||||
<span className="flex items-center rounded-full border border-custom-border-200 bg-custom-background-90 px-3 py-1.5 text-xs">
|
||||
{item.tag_name}
|
||||
<Dialog.Panel className="relative overflow-hidden rounded-lg bg-custom-background-100 border border-custom-border-100 shadow-custom-shadow-rg] min-w-[100%] sm:min-w-[50%] sm:max-w-[50%]">
|
||||
<div className="flex flex-col p-4 max-h-[90vh] w-full">
|
||||
<Dialog.Title
|
||||
as="h3"
|
||||
className="flex items-center justify-between text-lg font-semibold"
|
||||
>
|
||||
<span>Product Updates</span>
|
||||
<span>
|
||||
<button type="button" onClick={() => setIsOpen(false)}>
|
||||
<XMarkIcon
|
||||
className="h-6 w-6 text-custom-text-200 hover:text-custom-text-100"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</span>
|
||||
</Dialog.Title>
|
||||
{updates && updates.length > 0 ? (
|
||||
<div className="h-full overflow-y-auto mt-4 space-y-4">
|
||||
{updates.map((item, index) => (
|
||||
<React.Fragment key={item.id}>
|
||||
<div className="flex items-center gap-3 text-xs text-custom-text-200">
|
||||
<span className="flex items-center rounded-full border border-custom-border-200 bg-custom-background-90 px-3 py-1.5 text-xs">
|
||||
{item.tag_name}
|
||||
</span>
|
||||
<span>{renderLongDateFormat(item.published_at)}</span>
|
||||
{index === 0 && (
|
||||
<span className="flex items-center rounded-full border border-custom-border-200 bg-custom-primary px-3 py-1.5 text-xs text-white">
|
||||
New
|
||||
</span>
|
||||
<span>{renderLongDateFormat(item.published_at)}</span>
|
||||
{index === 0 && (
|
||||
<span className="flex items-center rounded-full border border-custom-border-200 bg-custom-primary px-3 py-1.5 text-xs text-white">
|
||||
New
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<MarkdownRenderer markdown={item.body} />
|
||||
</React.Fragment>
|
||||
))
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Spinner />
|
||||
Loading...
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
<MarkdownRenderer markdown={item.body} />
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid place-items-center w-full mt-4">
|
||||
<Loader className="space-y-6 w-full">
|
||||
<div className="space-y-3">
|
||||
<Loader.Item height="30px" />
|
||||
<Loader.Item height="20px" width="80%" />
|
||||
<Loader.Item height="20px" width="80%" />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Loader.Item height="30px" />
|
||||
<Loader.Item height="20px" width="80%" />
|
||||
<Loader.Item height="20px" width="80%" />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Loader.Item height="30px" />
|
||||
<Loader.Item height="20px" width="80%" />
|
||||
<Loader.Item height="20px" width="80%" />
|
||||
</div>
|
||||
</Loader>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
|
@ -71,9 +71,8 @@ export const reducer: ReducerFunctionType = (state, action) => {
|
||||
...state,
|
||||
display_filters: {
|
||||
...state.display_filters,
|
||||
...payload,
|
||||
...payload?.display_filters,
|
||||
},
|
||||
issueView: payload?.display_filters?.layout || "list",
|
||||
};
|
||||
|
||||
return {
|
||||
@ -100,7 +99,7 @@ export const reducer: ReducerFunctionType = (state, action) => {
|
||||
case "SET_PROPERTIES": {
|
||||
const newState = {
|
||||
...state,
|
||||
properties: {
|
||||
display_properties: {
|
||||
...state.display_properties,
|
||||
...payload?.display_properties,
|
||||
},
|
||||
@ -129,7 +128,6 @@ export const ProfileIssuesContextProvider: React.FC<{ children: React.ReactNode
|
||||
type: "SET_DISPLAY_FILTERS",
|
||||
payload: {
|
||||
display_filters: {
|
||||
...state.display_filters,
|
||||
...displayFilter,
|
||||
},
|
||||
},
|
||||
@ -179,7 +177,6 @@ export const ProfileIssuesContextProvider: React.FC<{ children: React.ReactNode
|
||||
type: "SET_PROPERTIES",
|
||||
payload: {
|
||||
display_properties: {
|
||||
...state.display_properties,
|
||||
[key]: !state.display_properties[key],
|
||||
},
|
||||
},
|
||||
|
@ -38,10 +38,13 @@ export const orderArrayBy = (
|
||||
|
||||
export const checkDuplicates = (array: any[]) => new Set(array).size !== array.length;
|
||||
|
||||
export const findStringWithMostCharacters = (strings: string[]) =>
|
||||
strings.reduce((longestString, currentString) =>
|
||||
export const findStringWithMostCharacters = (strings: string[]): string => {
|
||||
if (!strings || strings.length === 0) return "";
|
||||
|
||||
return strings.reduce((longestString, currentString) =>
|
||||
currentString.length > longestString.length ? currentString : longestString
|
||||
);
|
||||
};
|
||||
|
||||
export const checkIfArraysHaveSameElements = (arr1: any[] | null, arr2: any[] | null): boolean => {
|
||||
if (!arr1 || !arr2) return false;
|
||||
|
@ -5,6 +5,19 @@ import { RootStore } from "./root";
|
||||
export interface IIssueStore {
|
||||
loader: boolean;
|
||||
error: any | null;
|
||||
|
||||
issues: {
|
||||
[project_id: string]: {
|
||||
grouped: {
|
||||
[issueId: string]: IIssue[];
|
||||
};
|
||||
ungrouped: IIssue[];
|
||||
};
|
||||
};
|
||||
|
||||
addIssueToIssuesStore: (projectId: string, issue: IIssue) => void;
|
||||
updateIssueInIssuesStore: (projectId: string, issue: IIssue) => void;
|
||||
deleteIssueFromIssuesStore: (projectId: string, issueId: string) => void;
|
||||
}
|
||||
|
||||
class IssueStore implements IIssueStore {
|
||||
@ -12,7 +25,9 @@ class IssueStore implements IIssueStore {
|
||||
error: any | null = null;
|
||||
issues: {
|
||||
[project_id: string]: {
|
||||
grouped: any;
|
||||
grouped: {
|
||||
[issueId: string]: IIssue[];
|
||||
};
|
||||
ungrouped: IIssue[];
|
||||
};
|
||||
} = {};
|
||||
@ -25,11 +40,56 @@ class IssueStore implements IIssueStore {
|
||||
loader: observable.ref,
|
||||
error: observable.ref,
|
||||
issues: observable.ref,
|
||||
|
||||
addIssueToIssuesStore: action,
|
||||
updateIssueInIssuesStore: action,
|
||||
deleteIssueFromIssuesStore: action,
|
||||
});
|
||||
this.rootStore = _rootStore;
|
||||
}
|
||||
|
||||
fetchIssuesWithParams() {}
|
||||
addIssueToIssuesStore = (projectId: string, issue: IIssue) => {
|
||||
runInAction(() => {
|
||||
this.rootStore.issue.issues = {
|
||||
...this.rootStore.issue.issues,
|
||||
[projectId]: {
|
||||
...this.rootStore.issue.issues[projectId],
|
||||
ungrouped: [...this.rootStore.issue.issues[projectId].ungrouped, issue],
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
updateIssueInIssuesStore = (projectId: string, issue: IIssue) => {
|
||||
const newUngroupedIssues = this.rootStore.issue.issues[projectId].ungrouped.map((i) => ({
|
||||
...i,
|
||||
...(i.id === issue.id ? issue : {}),
|
||||
}));
|
||||
|
||||
runInAction(() => {
|
||||
this.rootStore.issue.issues = {
|
||||
...this.rootStore.issue.issues,
|
||||
[projectId]: {
|
||||
...this.rootStore.issue.issues[projectId],
|
||||
ungrouped: newUngroupedIssues,
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
deleteIssueFromIssuesStore = (projectId: string, issueId: string) => {
|
||||
const newUngroupedIssues = this.rootStore.issue.issues[projectId].ungrouped.filter((i) => i.id !== issueId);
|
||||
|
||||
runInAction(() => {
|
||||
this.rootStore.issue.issues = {
|
||||
...this.rootStore.issue.issues,
|
||||
[projectId]: {
|
||||
...this.rootStore.issue.issues[projectId],
|
||||
ungrouped: newUngroupedIssues,
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export default IssueStore;
|
||||
|
@ -1,9 +1,10 @@
|
||||
import { observable, action, makeObservable, runInAction } from "mobx";
|
||||
// types
|
||||
import { RootStore } from "./root";
|
||||
|
||||
// services
|
||||
import { IssueService } from "services/issue.service";
|
||||
import { IIssue } from "types";
|
||||
// types
|
||||
import { RootStore } from "./root";
|
||||
import { ICurrentUserResponse, IIssue } from "types";
|
||||
|
||||
export type IPeekMode = "side" | "modal" | "full";
|
||||
|
||||
@ -15,7 +16,7 @@ export interface IIssueDetailStore {
|
||||
peekMode: IPeekMode | null;
|
||||
|
||||
issues: {
|
||||
[key: string]: IIssue;
|
||||
[issueId: string]: IIssue;
|
||||
};
|
||||
|
||||
setPeekId: (issueId: string | null) => void;
|
||||
@ -23,11 +24,17 @@ export interface IIssueDetailStore {
|
||||
// fetch issue details
|
||||
fetchIssueDetails: (workspaceId: string, projectId: string, issueId: string) => void;
|
||||
// creating issue
|
||||
createIssue: (workspaceId: string, projectId: string, issueId: string, data: any) => void;
|
||||
createIssue: (workspaceId: string, projectId: string, data: Partial<IIssue>, user: ICurrentUserResponse) => void;
|
||||
// updating issue
|
||||
updateIssue: (workspaceId: string, projectId: string, issueId: string, data: any) => void;
|
||||
updateIssue: (
|
||||
workspaceId: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
data: Partial<IIssue>,
|
||||
user: ICurrentUserResponse
|
||||
) => void;
|
||||
// deleting issue
|
||||
deleteIssue: (workspaceId: string, projectId: string, issueId: string) => void;
|
||||
deleteIssue: (workspaceId: string, projectId: string, issueId: string, user: ICurrentUserResponse) => void;
|
||||
}
|
||||
|
||||
class IssueDetailStore implements IIssueDetailStore {
|
||||
@ -38,7 +45,7 @@ class IssueDetailStore implements IIssueDetailStore {
|
||||
peekMode: IPeekMode | null = null;
|
||||
|
||||
issues: {
|
||||
[key: string]: IIssue;
|
||||
[issueId: string]: IIssue;
|
||||
} = {};
|
||||
|
||||
// root store
|
||||
@ -85,88 +92,120 @@ class IssueDetailStore implements IIssueDetailStore {
|
||||
this.loader = false;
|
||||
this.error = null;
|
||||
this.issues = {
|
||||
...this.issues,
|
||||
[issueId]: issueDetailsResponse,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("error in fetching issue details", error);
|
||||
runInAction(() => {
|
||||
this.loader = false;
|
||||
this.error = error;
|
||||
});
|
||||
|
||||
return error;
|
||||
}
|
||||
};
|
||||
|
||||
createIssue = async (workspaceId: string, projectId: string, data: Partial<IIssue>, user: ICurrentUserResponse) => {
|
||||
try {
|
||||
runInAction(() => {
|
||||
this.loader = true;
|
||||
this.error = null;
|
||||
});
|
||||
|
||||
const response = await this.issueService.createIssues(workspaceId, projectId, data, user);
|
||||
|
||||
if (response) this.rootStore.issue.addIssueToIssuesStore(projectId, response);
|
||||
|
||||
runInAction(() => {
|
||||
this.loader = false;
|
||||
this.error = null;
|
||||
this.issues = {
|
||||
...this.issues,
|
||||
[response.id]: response,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
this.loader = false;
|
||||
this.error = error;
|
||||
return error;
|
||||
}
|
||||
};
|
||||
|
||||
createIssue = async (workspaceId: string, projectId: string, issueId: string, data: any) => {
|
||||
try {
|
||||
this.loader = true;
|
||||
this.error = null;
|
||||
updateIssue = async (
|
||||
workspaceId: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
data: Partial<IIssue>,
|
||||
user: ICurrentUserResponse
|
||||
) => {
|
||||
const newIssues = { ...this.issues };
|
||||
newIssues[issueId] = {
|
||||
...newIssues[issueId],
|
||||
...data,
|
||||
};
|
||||
|
||||
console.log("workspaceId", workspaceId);
|
||||
console.log("projectId", projectId);
|
||||
console.log("issueId", issueId);
|
||||
console.log("data", data);
|
||||
try {
|
||||
runInAction(() => {
|
||||
this.loader = true;
|
||||
this.error = null;
|
||||
this.issues = newIssues;
|
||||
});
|
||||
|
||||
const response = await this.issueService.patchIssue(workspaceId, projectId, issueId, data, user);
|
||||
|
||||
if (response) this.rootStore.issue.updateIssueInIssuesStore(projectId, response);
|
||||
|
||||
runInAction(() => {
|
||||
this.loader = false;
|
||||
this.error = null;
|
||||
this.issues = {
|
||||
...this.issues,
|
||||
[issueId]: {
|
||||
...this.issues[issueId],
|
||||
...response,
|
||||
},
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
this.fetchIssueDetails(workspaceId, projectId, issueId);
|
||||
|
||||
runInAction(() => {
|
||||
this.loader = false;
|
||||
this.error = error;
|
||||
});
|
||||
|
||||
return error;
|
||||
}
|
||||
};
|
||||
|
||||
deleteIssue = async (workspaceId: string, projectId: string, issueId: string, user: ICurrentUserResponse) => {
|
||||
const newIssues = { ...this.issues };
|
||||
delete newIssues[issueId];
|
||||
|
||||
try {
|
||||
runInAction(() => {
|
||||
this.loader = true;
|
||||
this.error = null;
|
||||
this.issues = newIssues;
|
||||
});
|
||||
|
||||
await this.issueService.deleteIssue(workspaceId, projectId, issueId, user);
|
||||
|
||||
this.rootStore.issue.deleteIssueFromIssuesStore(projectId, issueId);
|
||||
|
||||
runInAction(() => {
|
||||
this.loader = false;
|
||||
this.error = null;
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("error in fetching issue details", error);
|
||||
this.loader = false;
|
||||
this.error = error;
|
||||
return error;
|
||||
}
|
||||
};
|
||||
|
||||
updateIssue = async (workspaceId: string, projectId: string, issueId: string, data: any) => {
|
||||
try {
|
||||
this.loader = true;
|
||||
this.error = null;
|
||||
|
||||
const filteredParams = this.rootStore.issueFilter.getComputedFilters(
|
||||
workspaceId,
|
||||
projectId,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
this.rootStore.issueFilters.issueView || "issues"
|
||||
);
|
||||
const issueResponse = await this.issueService.patchIssue(workspaceId, projectId, issueId, data, undefined);
|
||||
const issueList = (await this.issueService.getIssuesWithParams(workspaceId, projectId, filteredParams)) as any;
|
||||
console.log("issueList", issueList);
|
||||
|
||||
if (issueResponse) {
|
||||
runInAction(() => {
|
||||
this.loader = false;
|
||||
this.error = null;
|
||||
this.rootStore.issueView.issues[workspaceId].project_issues[projectId].issues.list = issueList;
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("error in fetching issue details", error);
|
||||
this.loader = false;
|
||||
this.error = error;
|
||||
return error;
|
||||
}
|
||||
};
|
||||
|
||||
deleteIssue = async (workspaceId: string, projectId: string, issueId: string) => {
|
||||
try {
|
||||
this.loader = true;
|
||||
this.error = null;
|
||||
|
||||
console.log("workspaceId", workspaceId);
|
||||
console.log("projectId", projectId);
|
||||
console.log("issueId", issueId);
|
||||
this.fetchIssueDetails(workspaceId, projectId, issueId);
|
||||
|
||||
runInAction(() => {
|
||||
this.loader = false;
|
||||
this.error = null;
|
||||
this.error = error;
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("error in fetching issue details", error);
|
||||
this.loader = false;
|
||||
this.error = error;
|
||||
|
||||
return error;
|
||||
}
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user