ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: realtime внешних контуров и действия исходящих карточек
This commit is contained in:
@@ -6,29 +6,28 @@
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { CalendarDays } from "lucide-react";
|
||||
import { Archive, CalendarDays, Copy, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
|
||||
import { observer } from "mobx-react";
|
||||
import { EUserPermissions } from "@plane/constants";
|
||||
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 { Avatar } from "@plane/ui";
|
||||
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 { PriorityDropdown } from "@/components/dropdowns/priority";
|
||||
import { WorkItemStateDropdownBase } from "@/components/dropdowns/state/base";
|
||||
import { StateDropdown } from "@/components/dropdowns/state/dropdown";
|
||||
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 { ExternalContourDeleteModal } from "./delete-modal";
|
||||
|
||||
type Props = {
|
||||
direction: TExternalContourBoardDirection;
|
||||
@@ -38,6 +37,7 @@ type Props = {
|
||||
};
|
||||
|
||||
const issueService = new IssueService();
|
||||
const issueArchiveService = new IssueArchiveService();
|
||||
|
||||
const basePillClasses =
|
||||
"inline-flex min-h-9 items-center gap-1.5 rounded-full border-0 px-2.5 py-1 text-[11px] font-medium shadow-none outline-none transition-colors";
|
||||
@@ -45,7 +45,7 @@ const basePillClasses =
|
||||
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,
|
||||
@@ -81,11 +81,12 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard
|
||||
const { getUserDetails, workspace } = useMember();
|
||||
const { getProjectRoleByWorkspaceSlugAndProjectId } = useUserPermissions();
|
||||
const { getStateById, getProjectStateIds } = useProjectState();
|
||||
const { fetchBoard, upsertBoardItems } = useProjectExternalContoursBoard();
|
||||
const { fetchTargetOptions, getTargetOptionsByProjectId, updateRequest, updateRequestIssue } =
|
||||
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");
|
||||
@@ -120,15 +121,18 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard
|
||||
);
|
||||
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 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-[#2F4721]" : "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 iconBubbleClasses = isActive ? "bg-black text-[rgb(var(--nodedc-card-active-rgb))]" : "bg-[#111214] text-white";
|
||||
const statusIconColor = getStateGroupColor(selectedState?.group, selectedState?.color);
|
||||
const dueDateLabel = issue.target_date ? renderFormattedDate(issue.target_date, "d MMM, yyyy") : t("common.none");
|
||||
const canArchive = canEditTargetIssue && !!selectedState && ARCHIVABLE_STATE_GROUPS.includes(selectedState.group);
|
||||
|
||||
if (!issue) return null;
|
||||
|
||||
@@ -215,8 +219,66 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard
|
||||
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 (
|
||||
<div className="group/kanban-block relative mb-2">
|
||||
<>
|
||||
<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"}
|
||||
@@ -249,67 +311,116 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard
|
||||
/>
|
||||
)}
|
||||
|
||||
<PriorityDropdown
|
||||
value={issue.priority}
|
||||
onChange={(priority) => void handleCardUpdate({ priority })}
|
||||
disabled={!canEditCard || isUpdating}
|
||||
buttonVariant="transparent-without-text"
|
||||
button={
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-8 w-8 items-center justify-center rounded-full border-0 shadow-none outline-none",
|
||||
iconBubbleClasses
|
||||
)}
|
||||
>
|
||||
<PriorityIcon priority={issue.priority} className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<ActionDropdown
|
||||
placement="bottom-end"
|
||||
button={<div className={cn("flex h-8 w-8 items-center justify-center rounded-full", iconBubbleClasses)}><MoreHorizontal className="h-4 w-4" /></div>}
|
||||
buttonClassName="h-8 w-8"
|
||||
menuClassName="min-w-[18rem]"
|
||||
onOpenChange={(isOpen) => {
|
||||
if (isOpen) void ensureSourceOptions();
|
||||
}}
|
||||
items={[]}
|
||||
menuContent={({ closeDropdown }) => (
|
||||
<div className="max-h-[min(75vh,34rem)] space-y-2 overflow-y-auto" onClick={stopCardPropagation}>
|
||||
<div className="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>
|
||||
|
||||
{canEditTargetIssue ? (
|
||||
<StateDropdown
|
||||
projectId={issue.project_id ?? undefined}
|
||||
stateIds={projectStateIds ?? []}
|
||||
value={issue.state_id}
|
||||
onChange={(stateId) => void handleCardUpdate({ state_id: stateId })}
|
||||
disabled={!canEditCard || isUpdating}
|
||||
buttonVariant="transparent-without-text"
|
||||
button={
|
||||
<div className={cn("flex h-8 w-8 items-center justify-center rounded-full", iconBubbleClasses)}>
|
||||
<StateGroupIcon
|
||||
stateGroup={selectedState?.group ?? "backlog"}
|
||||
color={statusIconColor}
|
||||
className="h-3.5 w-3.5"
|
||||
percentage={selectedState?.order}
|
||||
/>
|
||||
<div className="space-y-1 border-t border-white/8 pt-2">
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<WorkItemStateDropdownBase
|
||||
projectId={targetProjectId ?? undefined}
|
||||
value={issue.state_id}
|
||||
stateIds={sourceStateIds}
|
||||
getStateById={(stateId) => (stateId ? sourceStateMap[stateId] : undefined)}
|
||||
onChange={(stateId) => void handleCardUpdate({ state_id: stateId })}
|
||||
disabled={!canEditCard || isUpdating || !targetProjectId}
|
||||
isInitializing={isSourceOptionsLoading}
|
||||
onDropdownOpen={() => {
|
||||
void ensureSourceOptions();
|
||||
}}
|
||||
buttonVariant="transparent-without-text"
|
||||
button={
|
||||
<div className={cn("flex h-8 w-8 items-center justify-center rounded-full", iconBubbleClasses)}>
|
||||
<StateGroupIcon
|
||||
stateGroup={selectedState?.group ?? "backlog"}
|
||||
color={statusIconColor}
|
||||
className="h-3.5 w-3.5"
|
||||
percentage={selectedState?.order}
|
||||
/>
|
||||
|
||||
<div className="space-y-1 border-t border-white/8 pt-2">
|
||||
<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>
|
||||
|
||||
@@ -323,6 +434,29 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3" onClick={stopCardPropagation}>
|
||||
{direction === "outgoing" && (
|
||||
<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.5 w-3.5" />
|
||||
<span className="truncate">{dueDateLabel}</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{canEditTargetIssue ? (
|
||||
<MemberDropdown
|
||||
multiple
|
||||
@@ -357,29 +491,32 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard
|
||||
/>
|
||||
)}
|
||||
|
||||
<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.5 w-3.5" />
|
||||
<span className="truncate">{dueDateLabel}</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{direction !== "outgoing" && (
|
||||
<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.5 w-3.5" />
|
||||
<span className="truncate">{dueDateLabel}</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
isSubmitting?: boolean;
|
||||
issueName: string;
|
||||
onClose: () => void;
|
||||
onSubmit: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function ExternalContourDeleteModal(props: Props) {
|
||||
const { isOpen, isSubmitting = false, issueName, onClose, onSubmit } = props;
|
||||
|
||||
return (
|
||||
<ModalCore
|
||||
isOpen={isOpen}
|
||||
handleClose={onClose}
|
||||
position={EModalPosition.CENTER}
|
||||
width={EModalWidth.MD}
|
||||
className="nodedc-glass-modal rounded-[1.75rem]"
|
||||
>
|
||||
<div className="space-y-5 p-6">
|
||||
<div className="space-y-1.5">
|
||||
<h3 className="text-18 font-semibold text-primary">Удалить исходящую задачу</h3>
|
||||
<p className="text-13 leading-5 text-secondary">
|
||||
Задача «{issueName}» будет удалена из внешнего контура и из целевого проекта. Это действие нельзя отменить.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="primary" onClick={onClose} disabled={isSubmitting} className="nodedc-modal-primary-button">
|
||||
Отмена
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={onSubmit} disabled={isSubmitting} className="nodedc-modal-secondary-button">
|
||||
{isSubmitting ? "Удаление..." : "Удалить"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalCore>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { TransferIcon } from "@plane/propel/icons";
|
||||
import type { TInboxIssueCurrentTab } from "@plane/types";
|
||||
import { EInboxIssueCurrentTab } from "@plane/types";
|
||||
import { FiltersRow } from "@/components/rich-filters/filters-row";
|
||||
import { useExternalContoursRealtimeEvents } from "@/hooks/use-external-contours-realtime-events";
|
||||
import { useProjectExternalContoursBoard } from "@/hooks/store/use-project-external-contours-board";
|
||||
import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours";
|
||||
import { ExternalContoursBoardRoot } from "./board-root";
|
||||
@@ -32,9 +33,12 @@ export const ExternalContoursRoot = observer(function ExternalContoursRoot(props
|
||||
currentProjectId: boardProjectId,
|
||||
fetchBoard,
|
||||
loader: boardLoader,
|
||||
syncBoard,
|
||||
} = useProjectExternalContoursBoard();
|
||||
const filter = useExternalContoursFilter();
|
||||
|
||||
useExternalContoursRealtimeEvents(workspaceSlug?.toString(), projectId?.toString(), syncBoard);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user