NODEDC_TASKMANAGER/plane-src/apps/web/ce/components/projects/external-contours/board-item.tsx

551 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import { Archive, CalendarDays, Copy, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
import { observer } from "mobx-react";
import { ARCHIVABLE_STATE_GROUPS, EUserPermissions } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { PriorityIcon, StateGroupIcon, getStateGroupColor } from "@plane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IState, TExternalContourBoardDirection, TExternalContourRequest, TIssue } from "@plane/types";
import { ActionDropdown, Avatar } from "@plane/ui";
import { cn, renderFormattedDate, renderFormattedPayloadDate } from "@plane/utils";
import { DateDropdown } from "@/components/dropdowns/date";
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
import { MemberDropdown } from "@/components/dropdowns/member/dropdown";
import { MemberDropdownBase } from "@/components/dropdowns/member/base";
import { PresenceDot } from "@/components/presence/presence-dot";
import { useAppRouter } from "@/hooks/use-app-router";
import { useMember } from "@/hooks/store/use-member";
import { useProjectExternalContoursBoard } from "@/hooks/store/use-project-external-contours-board";
import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours";
import { useProjectState } from "@/hooks/store/use-project-state";
import { useUserPermissions } from "@/hooks/store/user";
import { IssueArchiveService } from "@/services/issue/issue_archive.service";
import { IssueService } from "@/services/issue/issue.service";
import { NodedcWorkItemProgress } from "@/components/issues/issue-layouts/shared/nodedc-work-item-card";
import { ExternalContourDeleteModal } from "./delete-modal";
type Props = {
direction: TExternalContourBoardDirection;
projectId: string;
request: TExternalContourRequest;
workspaceSlug: string;
};
const issueService = new IssueService();
const issueArchiveService = new IssueArchiveService();
const basePillClasses =
"inline-flex min-h-8 items-center gap-1.5 rounded-full border-0 px-2.5 py-1 text-[10px] font-medium shadow-none outline-none transition-colors";
const buildSourceStateMap = (
states: { id: string; name: string; color: string; group: IState["group"] }[] | undefined,
projectId: string | null
): Record<string, IState> =>
Object.fromEntries(
(states ?? []).map((state, index) => [
state.id,
{
id: state.id,
color: getStateGroupColor(state.group, state.color),
default: false,
description: "",
group: state.group,
name: state.name,
order: index + 1,
project_id: projectId ?? "",
sequence: index + 1,
workspace_id: "",
} satisfies IState,
])
);
const resolveRequestStatus = (
issue: TExternalContourRequest["issue"],
fallbackStatus: TExternalContourRequest["status"]
) => {
const stateGroup = issue.state_detail?.group;
if (!stateGroup) return fallbackStatus;
return stateGroup === "completed" || stateGroup === "cancelled" ? "closed" : "open";
};
export const ExternalContoursBoardItem = observer(function ExternalContoursBoardItem(props: Props) {
const { direction, projectId, request, workspaceSlug } = props;
const router = useAppRouter();
const searchParams = useSearchParams();
const { t } = useTranslation();
const { getUserDetails, workspace } = useMember();
const { getProjectRoleByWorkspaceSlugAndProjectId } = useUserPermissions();
const { getStateById, getProjectStateIds } = useProjectState();
const { fetchBoard, removeBoardItem, upsertBoardItems } = useProjectExternalContoursBoard();
const { deleteRequest, fetchTargetOptions, getTargetOptionsByProjectId, updateRequest, updateRequestIssue } =
useProjectExternalContours();
const [isUpdating, setIsUpdating] = useState(false);
const [isSourceOptionsLoading, setIsSourceOptionsLoading] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const issue = request.issue;
const selectedInboxIssueId = searchParams.get("inboxIssueId");
const isActive = selectedInboxIssueId === request.id;
const requester =
request.requested_by?.display_name ||
request.requested_by_name ||
issue.created_by_detail?.display_name ||
"NODE.DC";
const requesterId = request.requested_by?.id || request.requested_by_id || issue.created_by_detail?.id || issue.created_by;
const requesterAvatar = issue.created_by_detail?.avatar_url || "";
const counterpartContourName =
direction === "outgoing"
? request.target_project?.name || request.target_project_name || issue.project_detail?.name
: request.source_project?.name || request.source_project_name;
const targetProjectId = issue.project_id || request.target_project?.id || request.target_project_id || null;
const projectRole = targetProjectId
? getProjectRoleByWorkspaceSlugAndProjectId(workspaceSlug, targetProjectId)
: undefined;
const canEditTargetIssue =
direction === "incoming" &&
!!targetProjectId &&
projectRole !== undefined &&
projectRole !== EUserPermissions.GUEST;
const canEditSourceRequest =
direction === "outgoing" && !!request.capabilities?.can_edit_request && !!targetProjectId;
const canEditCard = canEditTargetIssue || canEditSourceRequest;
const requestLink = `/${workspaceSlug}/projects/${projectId}/external-contours?inboxIssueId=${request.id}`;
const targetOptions = getTargetOptionsByProjectId(targetProjectId);
const sourceStateMap = useMemo(
() => buildSourceStateMap(targetOptions?.states, targetProjectId),
[targetOptions?.states, targetProjectId]
);
const sourceStateIds = useMemo(() => targetOptions?.states?.map((state) => state.id) ?? [], [targetOptions?.states]);
const selectedState = canEditTargetIssue ? getStateById(issue.state_id) : sourceStateMap[issue.state_id ?? ""];
const projectStateIds = issue.project_id ? (getProjectStateIds(issue.project_id) ?? []) : [];
const stateOptions = canEditTargetIssue
? projectStateIds.map((stateId) => getStateById(stateId)).filter((state): state is IState => !!state)
: sourceStateIds.map((stateId) => sourceStateMap[stateId]).filter((state): state is IState => !!state);
const foregroundClasses = isActive ? "text-[rgb(var(--nodedc-on-card-active-rgb))]" : "text-white";
const subtleTextClasses = isActive ? "text-[rgb(var(--nodedc-on-card-active-rgb))]/70" : "text-[#B3B3B8]";
const pillBackgroundClasses = isActive
? "bg-black/10 text-[rgb(var(--nodedc-on-card-active-rgb))]"
: "bg-[rgb(var(--nodedc-card-passive-rgb))] text-white";
const cornerActionButtonClasses = cn(
"flex h-12 w-12 -translate-x-0.5 -translate-y-0.5 items-center justify-center rounded-full border bg-transparent shadow-none ring-0 transition-colors outline-none",
isActive
? "border-black/25 text-black hover:bg-black/5"
: "border-white/20 text-white hover:border-white/35 hover:bg-white/5"
);
const assigneeButtonClasses = cn(
"flex h-7 min-w-7 items-center justify-center rounded-full border-0 bg-transparent p-0 shadow-none outline-none transition-colors",
isActive ? "text-[rgb(var(--nodedc-on-card-active-rgb))]" : "text-white"
);
const dueDateLabel = issue.target_date ? renderFormattedDate(issue.target_date, "d MMM, yyyy") : t("common.none");
const checkerBlocksTotal = issue.checker_blocks_count ?? 0;
const checkerItemsTotal = issue.checker_items_count ?? 0;
const checkerItemsCompleted = issue.checker_items_completed_count ?? 0;
const hasCheckerProgress = checkerBlocksTotal > 0;
const canArchive = canEditTargetIssue && !!selectedState && ARCHIVABLE_STATE_GROUPS.includes(selectedState.group);
if (!issue) return null;
const stopCardPropagation = (event: React.MouseEvent) => {
event.stopPropagation();
};
const openDetail = () => {
if (isActive) return;
router.push(requestLink);
};
const syncBoardAfterMutation = async () => {
await fetchBoard(workspaceSlug, projectId);
};
const ensureSourceOptions = async () => {
if (!canEditSourceRequest || !targetProjectId) return;
const tasks: Promise<unknown>[] = [];
if (!targetOptions) {
setIsSourceOptionsLoading(true);
tasks.push(fetchTargetOptions(workspaceSlug, projectId, targetProjectId));
}
if (!workspace.workspaceMemberIds) {
tasks.push(workspace.fetchWorkspaceMembers(workspaceSlug));
}
if (tasks.length === 0) return;
try {
await Promise.all(tasks);
} finally {
setIsSourceOptionsLoading(false);
}
};
const handleTargetIssueUpdate = async (data: Partial<TIssue>) => {
if (!targetProjectId || !issue.id || isUpdating) return;
setIsUpdating(true);
try {
const updatedIssue = await issueService.patchIssue(workspaceSlug, targetProjectId, issue.id, data);
const nextIssue = { ...issue, ...updatedIssue };
const nextRequest = {
...request,
issue: nextIssue,
status: resolveRequestStatus(nextIssue, request.status),
};
updateRequestIssue(request.id, nextIssue);
upsertBoardItems([nextRequest]);
await syncBoardAfterMutation();
} catch {
setToast({ title: t("error"), type: TOAST_TYPE.ERROR, message: t("issue_could_not_be_updated") });
} finally {
setIsUpdating(false);
}
};
const handleSourceRequestUpdate = async (data: Partial<TIssue>) => {
if (!canEditSourceRequest || isUpdating) return;
setIsUpdating(true);
try {
const updatedRequest = await updateRequest(workspaceSlug, projectId, request.id, data);
if (updatedRequest) {
upsertBoardItems([updatedRequest]);
}
await syncBoardAfterMutation();
} catch {
setToast({ title: t("error"), type: TOAST_TYPE.ERROR, message: t("issue_could_not_be_updated") });
} finally {
setIsUpdating(false);
}
};
const handleCardUpdate = async (data: Partial<TIssue>) => {
if (canEditTargetIssue) {
await handleTargetIssueUpdate(data);
return;
}
await handleSourceRequestUpdate(data);
};
const handleCopyLink = async () => {
const absoluteLink = `${window.location.origin}${requestLink}`;
await navigator.clipboard?.writeText(absoluteLink);
setToast({ title: "Ссылка скопирована", type: TOAST_TYPE.SUCCESS });
};
const handleArchiveIssue = async () => {
if (!targetProjectId || !issue.id || !canArchive || isUpdating) return;
setIsUpdating(true);
try {
await issueArchiveService.archiveIssue(workspaceSlug, targetProjectId, issue.id);
await syncBoardAfterMutation();
setToast({ title: "Задача архивирована", type: TOAST_TYPE.SUCCESS });
} catch {
setToast({ title: t("error"), type: TOAST_TYPE.ERROR, message: "Не удалось архивировать задачу" });
} finally {
setIsUpdating(false);
}
};
const handleDeleteRequest = async () => {
if (direction !== "outgoing" || isUpdating) return;
setIsUpdating(true);
try {
await deleteRequest(workspaceSlug, projectId, request.id);
removeBoardItem(request.id);
if (isActive) router.push(`/${workspaceSlug}/projects/${projectId}/external-contours`);
setToast({ title: "Исходящая задача удалена", type: TOAST_TYPE.SUCCESS });
setIsDeleteModalOpen(false);
} catch {
setToast({ title: t("error"), type: TOAST_TYPE.ERROR, message: "Не удалось удалить исходящую задачу" });
} finally {
setIsUpdating(false);
}
};
const priorityOptions: NonNullable<TIssue["priority"]>[] = ["urgent", "high", "medium", "low", "none"];
const priorityLabels: Record<NonNullable<TIssue["priority"]>, string> = {
urgent: "Срочный",
high: "Высокий",
medium: "Средний",
low: "Низкий",
none: "Без приоритета",
};
const menuItemClasses =
"flex w-full items-center gap-2 rounded-[0.9rem] px-2.5 py-2 text-left text-12 text-secondary transition-colors hover:bg-white/6 disabled:cursor-not-allowed disabled:text-placeholder disabled:hover:bg-transparent";
return (
<>
<ExternalContourDeleteModal
isOpen={isDeleteModalOpen}
isSubmitting={isUpdating}
issueName={issue.name}
onClose={() => setIsDeleteModalOpen(false)}
onSubmit={handleDeleteRequest}
/>
<div className="group/kanban-block relative mb-2">
<div
data-active={isActive}
data-priority={issue.priority ?? "none"}
className="nodedc-external-card relative flex min-h-[220px] w-full cursor-pointer flex-col p-4 transition-all hover:bg-white/5"
role="button"
tabIndex={0}
onClick={openDetail}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
openDetail();
}
}}
>
<div className={cn("relative flex min-h-[220px] flex-col px-1", foregroundClasses)}>
<div className="absolute top-0.5 left-0.5 z-20">
<div className="relative h-12 w-12">
<Avatar
src={requesterAvatar}
name={requester}
size={48}
className="border border-white/10 shadow-none ring-0 outline-none"
/>
<PresenceDot
workspaceSlug={workspaceSlug}
userId={requesterId}
className={isActive ? "border-[rgb(var(--nodedc-card-active-rgb))]" : undefined}
/>
</div>
</div>
<div className="absolute top-0.5 right-0.5 z-20" onClick={stopCardPropagation}>
<ActionDropdown
placement="bottom-end"
button={
<div className={cornerActionButtonClasses}>
<MoreHorizontal className="h-4 w-4" />
</div>
}
buttonClassName="h-12 w-12"
menuClassName="nodedc-work-item-action-menu"
onOpenChange={(isOpen) => {
if (isOpen) void ensureSourceOptions();
}}
items={[]}
menuContent={({ closeDropdown }) => (
<div className="nodedc-work-item-action-grid" onClick={stopCardPropagation}>
<div className="nodedc-work-item-action-section space-y-1">
<div className="px-2 text-[10px] font-semibold tracking-[0.16em] text-tertiary uppercase">
Приоритет
</div>
{priorityOptions.map((priority) => (
<button
key={priority}
type="button"
className={cn(menuItemClasses, { "bg-white/7 text-primary": issue.priority === priority })}
disabled={!canEditCard || isUpdating}
onClick={() => {
void handleCardUpdate({ priority });
closeDropdown();
}}
>
<PriorityIcon priority={priority} className="h-3.5 w-3.5" />
<span>{priorityLabels[priority]}</span>
</button>
))}
</div>
<div className="nodedc-work-item-action-section space-y-1">
<div className="px-2 text-[10px] font-semibold tracking-[0.16em] text-tertiary uppercase">
Статус
</div>
{isSourceOptionsLoading && stateOptions.length === 0 ? (
<div className="px-2.5 py-2 text-12 text-tertiary">Загрузка статусов...</div>
) : (
stateOptions.map((state) => (
<button
key={state.id}
type="button"
className={cn(menuItemClasses, { "bg-white/7 text-primary": issue.state_id === state.id })}
disabled={!canEditCard || isUpdating}
onClick={() => {
void handleCardUpdate({ state_id: state.id });
closeDropdown();
}}
>
<StateGroupIcon
stateGroup={state.group}
color={getStateGroupColor(state.group, state.color)}
className="h-3.5 w-3.5"
percentage={state.order}
/>
<span>{state.name}</span>
</button>
))
)}
</div>
<div className="nodedc-work-item-action-section space-y-1">
<div className="px-2 text-[10px] font-semibold tracking-[0.16em] text-tertiary uppercase">
Быстрые действия
</div>
<button
type="button"
className={menuItemClasses}
onClick={() => {
router.push(requestLink);
closeDropdown();
}}
>
<Pencil className="h-3.5 w-3.5" />
<span>Редактировать</span>
</button>
<button
type="button"
className={menuItemClasses}
onClick={() => {
void handleCopyLink();
closeDropdown();
}}
>
<Copy className="h-3.5 w-3.5" />
<span>Копировать ссылку</span>
</button>
<button
type="button"
className={menuItemClasses}
disabled={!canArchive || isUpdating}
onClick={() => {
void handleArchiveIssue();
closeDropdown();
}}
>
<Archive className="h-3.5 w-3.5" />
<span>Архивировать</span>
</button>
<button
type="button"
className={cn(menuItemClasses, "text-red-300 hover:bg-red-500/10 disabled:text-placeholder")}
disabled={direction !== "outgoing" || isUpdating}
onClick={() => {
setIsDeleteModalOpen(true);
closeDropdown();
}}
>
<Trash2 className="h-3.5 w-3.5" />
<span>Удалить</span>
</button>
</div>
</div>
)}
/>
</div>
<div className="min-w-0 pr-[58px] pl-[58px] pt-1">
<div className="flex min-w-0 items-center gap-1.5">
<div className={cn("truncate text-body-sm-medium leading-5", foregroundClasses)}>{requester}</div>
{request.has_unread_updates && (
<span
className={cn("size-2 shrink-0 rounded-full", isActive ? "bg-black/70" : "bg-accent-primary")}
title={t("external_contours_page.list.unread_updates")}
/>
)}
</div>
<div className={cn("truncate text-[10px] leading-3.5 font-medium", subtleTextClasses)}>
{counterpartContourName || t("common.none")}
</div>
</div>
<div
className={cn(
"flex flex-1 items-center pb-4",
hasCheckerProgress ? "justify-center px-0.5 pt-4 text-left" : "justify-start px-1 pt-7 text-left"
)}
>
{hasCheckerProgress ? (
<div className="flex w-full flex-col items-stretch gap-3">
<NodedcWorkItemProgress
completedCount={checkerItemsCompleted}
totalCount={checkerItemsTotal}
isActive={isActive}
/>
<div className="line-clamp-4 w-full text-left text-[15px] leading-5 font-medium">
{issue.name}
</div>
</div>
) : (
<div className="line-clamp-5 max-w-full text-[15px] leading-5 font-medium">{issue.name}</div>
)}
</div>
<div className="flex items-center justify-between gap-3" onClick={stopCardPropagation}>
{canEditTargetIssue ? (
<MemberDropdown
multiple
projectId={issue.project_id ?? undefined}
value={issue.assignee_ids ?? []}
onChange={(assigneeIds) => void handleCardUpdate({ assignee_ids: assigneeIds })}
disabled={!canEditCard || isUpdating}
buttonVariant="transparent-without-text"
button={
<div className={assigneeButtonClasses}>
<ButtonAvatars showTooltip={false} userIds={issue.assignee_ids ?? []} size={26} />
</div>
}
/>
) : (
<MemberDropdownBase
multiple
getUserDetails={getUserDetails}
memberIds={targetOptions?.member_ids ?? []}
value={issue.assignee_ids ?? []}
onChange={(assigneeIds) => void handleCardUpdate({ assignee_ids: assigneeIds })}
disabled={!canEditCard || isUpdating || !targetProjectId}
onDropdownOpen={() => {
void ensureSourceOptions();
}}
buttonVariant="transparent-without-text"
button={
<div className={assigneeButtonClasses}>
<ButtonAvatars showTooltip={false} userIds={issue.assignee_ids ?? []} size={26} />
</div>
}
/>
)}
<DateDropdown
value={issue.target_date}
rangePreview={{
from: issue.start_date,
to: issue.target_date,
}}
onChange={(targetDate) =>
void handleCardUpdate({
target_date: targetDate ? renderFormattedPayloadDate(targetDate) : null,
})
}
disabled={!canEditCard || isUpdating}
buttonVariant="transparent-without-text"
button={
<div className={cn(basePillClasses, pillBackgroundClasses)}>
<CalendarDays className="h-3 w-3" />
<span className="truncate">{dueDateLabel}</span>
</div>
}
/>
</div>
</div>
</div>
</div>
</>
);
});