ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: frontend read-layer и первый экран двусторонней доски внешних контуров

This commit is contained in:
DCCONSTRUCTIONS
2026-04-20 20:49:09 +03:00
parent 0184ff9a32
commit 8bf6f2a510
15 changed files with 603 additions and 69 deletions
@@ -0,0 +1,77 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
import type { TExternalContourBoardDirection, TInboxIssueCurrentTab } from "@plane/types";
import { useProjectExternalContoursBoard } from "@/hooks/store/use-project-external-contours-board";
import { ExternalContoursBoardItem } from "./board-item";
import { ExternalContoursEmptyState } from "./empty-state";
type Props = {
currentTab: TInboxIssueCurrentTab;
direction: TExternalContourBoardDirection;
projectId: string;
workspaceSlug: string;
};
export const ExternalContoursBoardColumn = observer(function ExternalContoursBoardColumn(props: Props) {
const { currentTab, direction, projectId, workspaceSlug } = props;
const { t } = useTranslation();
const { getColumnRequestIds, getColumnTotalCount, getRequestById } = useProjectExternalContoursBoard();
const requestIds = getColumnRequestIds(direction);
const totalCount = getColumnTotalCount(direction);
const title =
direction === "outgoing"
? t("external_contours_page.board.columns.outgoing")
: t("external_contours_page.board.columns.incoming");
const emptyTitle =
direction === "outgoing"
? t("external_contours_page.board.empty.outgoing_title")
: t("external_contours_page.board.empty.incoming_title");
const emptyDescription =
direction === "outgoing"
? t("external_contours_page.board.empty.outgoing_description")
: t("external_contours_page.board.empty.incoming_description");
return (
<section className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-[28px] bg-surface-2/30">
<div className="flex items-center justify-between gap-3 border-b border-subtle/40 px-5 py-4">
<div className="text-15 font-semibold text-primary">{title}</div>
<div className="rounded-full bg-white/5 px-2 py-1 text-12 font-semibold text-secondary">{totalCount}</div>
</div>
<div className="vertical-scrollbar scrollbar-md min-h-0 flex-1 overflow-y-auto p-4">
{requestIds.length > 0 ? (
<div className="space-y-4">
{requestIds.map((requestId) => {
const request = getRequestById(requestId);
if (!request) return null;
return (
<ExternalContoursBoardItem
key={requestId}
currentTab={currentTab}
direction={direction}
projectId={projectId}
request={request}
workspaceSlug={workspaceSlug}
/>
);
})}
</div>
) : (
<div className="flex h-full min-h-[18rem] items-center justify-center">
<ExternalContoursEmptyState title={emptyTitle} description={emptyDescription} />
</div>
)}
</div>
</section>
);
});
@@ -0,0 +1,92 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import Link from "next/link";
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
import { PriorityIcon } from "@plane/propel/icons";
import { Avatar } from "@plane/ui";
import { cn, renderFormattedDate } from "@plane/utils";
import type { TExternalContourBoardDirection, TExternalContourRequest, TInboxIssueCurrentTab } from "@plane/types";
import { ExternalContourStatePill } from "./state-pill";
type Props = {
currentTab: TInboxIssueCurrentTab;
direction: TExternalContourBoardDirection;
projectId: string;
request: TExternalContourRequest;
workspaceSlug: string;
};
export const ExternalContoursBoardItem = observer(function ExternalContoursBoardItem(props: Props) {
const { currentTab, direction, projectId, request, workspaceSlug } = props;
const { t } = useTranslation();
const issue = request.issue;
const requester = request.requested_by?.display_name || request.requested_by_name || issue.created_by_detail?.display_name || "NODE.DC";
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 assigneeDetails = issue.assignee_details?.slice(0, 2) ?? [];
const lastUpdatedAt = issue.updated_at || request.updated_at;
return (
<Link
href={`/${workspaceSlug}/projects/${projectId}/external-contours?currentTab=${currentTab}&inboxIssueId=${request.id}`}
className="block"
>
<div className="nodedc-external-card relative flex min-h-[13rem] flex-col gap-4 px-5 py-5 transition-all hover:bg-white/5">
<div className="space-y-1">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<Avatar src={requesterAvatar} name={requester} size="md" />
<div className="min-w-0">
<div className="truncate text-[15px] leading-5 font-semibold text-primary">{requester}</div>
</div>
</div>
<div className="flex items-center gap-2">
{request.has_unread_updates && <span className="size-2 rounded-full bg-accent-primary" />}
<ExternalContourStatePill request={request} />
</div>
</div>
<div className="truncate pl-10 text-[12px] font-medium leading-4 text-secondary">
{counterpartContourName || t("common.none")}
</div>
</div>
<div className="flex flex-1 items-center justify-center px-3 text-center">
<h3 className="line-clamp-3 w-full text-center text-16 leading-7 font-semibold text-primary">{issue.name}</h3>
</div>
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
{assigneeDetails.length > 0 ? (
assigneeDetails.map((assignee, index) => (
<div key={assignee.id} className={cn(index > 0 && "-ml-2")}>
<Avatar src={assignee.avatar_url || ""} name={assignee.display_name || "NODE.DC"} size="md" />
</div>
))
) : (
<div className="text-11 text-placeholder">{t("external_contours_page.list.unassigned")}</div>
)}
</div>
<div className="flex items-center gap-2">
<div className="rounded-full bg-white/6 px-3 py-1.5 text-12 text-secondary">{renderFormattedDate(lastUpdatedAt ?? "")}</div>
{issue.priority && issue.priority !== "none" && (
<div className="nodedc-external-priority-inline flex items-center justify-center">
<PriorityIcon priority={issue.priority} className="h-3.5 w-3.5" />
</div>
)}
</div>
</div>
</div>
</Link>
);
});
@@ -0,0 +1,85 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
import type { TInboxIssueCurrentTab } from "@plane/types";
import { EInboxIssueCurrentTab } from "@plane/types";
import { cn } from "@plane/utils";
import { useProjectExternalContoursBoard } from "@/hooks/store/use-project-external-contours-board";
import { useAppRouter } from "@/hooks/use-app-router";
import { ExternalContoursBoardColumn } from "./board-column";
type Props = {
projectId: string;
workspaceSlug: string;
};
const tabNavigationOptions: { key: TInboxIssueCurrentTab; i18nLabel: string }[] = [
{ key: EInboxIssueCurrentTab.OPEN, i18nLabel: "external_contours_page.tabs.open" },
{ key: EInboxIssueCurrentTab.CLOSED, i18nLabel: "external_contours_page.tabs.closed" },
];
export const ExternalContoursBoardRoot = observer(function ExternalContoursBoardRoot(props: Props) {
const { projectId, workspaceSlug } = props;
const { t } = useTranslation();
const router = useAppRouter();
const { currentTab, loader, tabCountMap, handleCurrentTab } = useProjectExternalContoursBoard();
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden px-8 pb-6">
<div className="flex shrink-0 items-center gap-2 py-4">
<div className="nodedc-filter-row-shell flex items-center gap-2 p-1">
{tabNavigationOptions.map((option) => {
const count = tabCountMap[option.key] ?? 0;
return (
<button
type="button"
key={option.key}
data-active={currentTab === option.key}
className={cn("nodedc-external-tab flex min-w-[10rem] items-center justify-center gap-2 text-13 font-medium transition-all")}
onClick={() => {
if (currentTab === option.key) return;
void handleCurrentTab(workspaceSlug, projectId, option.key);
router.push(`/${workspaceSlug}/projects/${projectId}/external-contours?currentTab=${option.key}`);
}}
>
<div>{t(option.i18nLabel)}</div>
<div
className={cn(
"rounded-full px-1.5 py-0.5 text-11 font-semibold",
currentTab === option.key ? "bg-accent-primary/15 text-accent-primary" : "bg-white/5 text-secondary"
)}
>
{count}
</div>
</button>
);
})}
</div>
</div>
{loader === "init-loading" ? (
<div className="flex flex-1 items-center justify-center text-13 text-secondary">{t("loading")}...</div>
) : (
<div className="grid min-h-0 flex-1 grid-cols-1 gap-5 xl:grid-cols-2">
<ExternalContoursBoardColumn
currentTab={currentTab}
direction="outgoing"
projectId={projectId}
workspaceSlug={workspaceSlug}
/>
<ExternalContoursBoardColumn
currentTab={currentTab}
direction="incoming"
projectId={projectId}
workspaceSlug={workspaceSlug}
/>
</div>
)}
</div>
);
});
@@ -20,16 +20,15 @@ type Props = {
workspaceSlug: string;
projectId: string;
inboxIssueId: string;
isMobileSidebar: boolean;
setIsMobileSidebar: (value: boolean) => void;
};
export const ExternalContoursContentRoot = observer(function ExternalContoursContentRoot(props: Props) {
const { workspaceSlug, projectId, inboxIssueId, isMobileSidebar, setIsMobileSidebar } = props;
const { workspaceSlug, projectId, inboxIssueId } = props;
const router = useAppRouter();
const [isSubmitting, setIsSubmitting] = useState<TNameDescriptionLoader>("saved");
const [isDetailResolved, setIsDetailResolved] = useState(false);
const { data: currentUser } = useUser();
const { currentTab, fetchRequestById, getRequestById, getIsRequestAvailable } = useProjectExternalContours();
const { currentTab, fetchRequestById, getRequestById } = useProjectExternalContours();
const contourRequest = getRequestById(inboxIssueId);
const issue = contourRequest?.issue;
const targetProjectId = issue?.project_id || projectId;
@@ -38,20 +37,24 @@ export const ExternalContoursContentRoot = observer(function ExternalContoursCon
targetProjectId && getProjectRoleByWorkspaceSlugAndProjectId(workspaceSlug, targetProjectId) !== undefined
);
const isIssueAvailable = getIsRequestAvailable(inboxIssueId?.toString() || "");
useEffect(() => {
if (!isIssueAvailable && inboxIssueId) {
if (isDetailResolved && !contourRequest && inboxIssueId) {
router.replace(`/${workspaceSlug}/projects/${projectId}/external-contours?currentTab=${currentTab}`);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isIssueAvailable]);
}, [contourRequest, currentTab, inboxIssueId, isDetailResolved, projectId, router, workspaceSlug]);
useSWR(
workspaceSlug && projectId && inboxIssueId
? `PROJECT_EXTERNAL_CONTOUR_DETAIL_${workspaceSlug}_${projectId}_${inboxIssueId}`
: null,
workspaceSlug && projectId && inboxIssueId ? () => fetchRequestById(workspaceSlug, projectId, inboxIssueId) : null,
workspaceSlug && projectId && inboxIssueId
? async () => {
const request = await fetchRequestById(workspaceSlug, projectId, inboxIssueId);
setIsDetailResolved(true);
return request;
}
: null,
{
revalidateOnFocus: !hasDirectTargetAccess,
revalidateIfStale: !hasDirectTargetAccess,
@@ -81,8 +84,6 @@ export const ExternalContoursContentRoot = observer(function ExternalContoursCon
<div className="relative flex h-full w-full flex-col overflow-hidden pt-6">
<div className="z-[11] min-h-[52px] flex-shrink-0">
<ExternalContoursIssueActionsHeader
setIsMobileSidebar={setIsMobileSidebar}
isMobileSidebar={isMobileSidebar}
workspaceSlug={workspaceSlug}
sourceProjectId={projectId}
contourRequest={contourRequest}
@@ -6,7 +6,6 @@
import { useCallback, useEffect, useState } from "react";
import { observer } from "mobx-react";
import { PanelLeft } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { CheckCircleFilledIcon, ChevronDownIcon, ChevronUpIcon, CloseCircleFilledIcon, LinkIcon, NewTabIcon } from "@plane/propel/icons";
@@ -28,8 +27,6 @@ type Props = {
contourRequest: TExternalContourRequest;
hasDirectTargetAccess: boolean;
isSubmitting: TNameDescriptionLoader;
isMobileSidebar: boolean;
setIsMobileSidebar: (value: boolean) => void;
};
export const ExternalContoursIssueActionsHeader = observer(function ExternalContoursIssueActionsHeader(props: Props) {
@@ -39,8 +36,6 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
contourRequest,
hasDirectTargetAccess,
isSubmitting,
isMobileSidebar,
setIsMobileSidebar,
} = props;
const { t } = useTranslation();
const router = useAppRouter();
@@ -50,13 +45,17 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
const issue = contourRequest.issue;
const currentRequestId = contourRequest.id;
const canReviewClosedRequest = contourRequest.status === "closed" && contourRequest.source_decision !== "accepted";
const hasRelativeNavigation = !!currentRequestId && filteredRequestIds.includes(currentRequestId);
const canReviewClosedRequest =
contourRequest.capabilities?.can_source_decide ??
(contourRequest.status === "closed" && contourRequest.source_decision !== "accepted");
const isSourceAccepted = contourRequest.source_decision === "accepted";
const redirectToRelativeIssue = useCallback(
(direction: "next" | "prev") => {
if (!filteredRequestIds || !currentRequestId) return;
if (!filteredRequestIds || !currentRequestId || !hasRelativeNavigation || filteredRequestIds.length <= 1) return;
const currentIssueIndex = filteredRequestIds.findIndex((requestId) => requestId === currentRequestId);
if (currentIssueIndex === -1) return;
const nextIssueIndex =
direction === "next"
? (currentIssueIndex + 1) % filteredRequestIds.length
@@ -65,7 +64,7 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
if (!nextIssueId) return;
router.push(`/${workspaceSlug}/projects/${sourceProjectId}/external-contours?currentTab=${currentTab}&inboxIssueId=${nextIssueId}`);
},
[currentRequestId, currentTab, filteredRequestIds, router, sourceProjectId, workspaceSlug]
[currentRequestId, currentTab, filteredRequestIds, hasRelativeNavigation, router, sourceProjectId, workspaceSlug]
);
useEffect(() => {
@@ -152,6 +151,7 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
type="button"
aria-label="Previous request"
onClick={() => redirectToRelativeIssue("prev")}
disabled={!hasRelativeNavigation || filteredRequestIds.length <= 1}
className="nodedc-external-icon-button"
>
<ChevronUpIcon className="size-3.5" />
@@ -160,6 +160,7 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
type="button"
aria-label="Next request"
onClick={() => redirectToRelativeIssue("next")}
disabled={!hasRelativeNavigation || filteredRequestIds.length <= 1}
className="nodedc-external-icon-button"
>
<ChevronDownIcon className="size-3.5" />
@@ -207,10 +208,6 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
</Row>
<Header className="justify-start lg:hidden">
<PanelLeft
onClick={() => setIsMobileSidebar(!isMobileSidebar)}
className={`my-auto mr-2 h-4 w-4 flex-shrink-0 ${isMobileSidebar ? "text-accent-primary" : "text-secondary"}`}
/>
<div className="flex w-full items-center gap-2">
<ExternalContourStatePill request={contourRequest} />
<div className="ml-auto flex items-center gap-2">
@@ -4,18 +4,15 @@
* See the LICENSE file for details.
*/
import { useEffect, useState } from "react";
import { useEffect } from "react";
import { observer } from "mobx-react";
import { PanelLeft } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { TransferIcon } from "@plane/propel/icons";
import type { TInboxIssueCurrentTab } from "@plane/types";
import { EInboxIssueCurrentTab } from "@plane/types";
import { cn } from "@plane/utils";
import { useProjectExternalContoursBoard } from "@/hooks/store/use-project-external-contours-board";
import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours";
import { ExternalContoursBoardRoot } from "./board-root";
import { ExternalContoursContentRoot } from "./content-root";
import { ExternalContoursEmptyState } from "./empty-state";
import { ExternalContoursSidebar } from "./sidebar";
type TExternalContoursRoot = {
workspaceSlug: string;
@@ -26,10 +23,16 @@ type TExternalContoursRoot = {
export const ExternalContoursRoot = observer(function ExternalContoursRoot(props: TExternalContoursRoot) {
const { workspaceSlug, projectId, inboxIssueId, navigationTab } = props;
const [isMobileSidebar, setIsMobileSidebar] = useState(true);
const { t } = useTranslation();
const { loader, error, currentTab, currentProjectId, requestIds, handleCurrentTab, fetchRequests } =
useProjectExternalContours();
const {
error: boardError,
currentProjectId: boardProjectId,
currentTab: boardCurrentTab,
fetchBoard,
handleCurrentTab: handleBoardCurrentTab,
loader: boardLoader,
} = useProjectExternalContoursBoard();
useEffect(() => {
if (!workspaceSlug || !projectId) return;
@@ -56,7 +59,24 @@ export const ExternalContoursRoot = observer(function ExternalContoursRoot(props
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workspaceSlug, projectId, navigationTab]);
if (error && error?.status === "init-error") {
useEffect(() => {
if (!workspaceSlug || !projectId) return;
const resolvedTab = navigationTab || EInboxIssueCurrentTab.OPEN;
const hasProjectChanged = boardProjectId && boardProjectId !== projectId;
if (boardProjectId === projectId && boardCurrentTab === resolvedTab && boardLoader === "init-loading") return;
if (hasProjectChanged || boardCurrentTab !== resolvedTab) {
void handleBoardCurrentTab(workspaceSlug, projectId, resolvedTab);
return;
}
void fetchBoard(workspaceSlug.toString(), projectId.toString(), resolvedTab);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workspaceSlug, projectId, navigationTab]);
if (error && error?.status === "init-error" && !!inboxIssueId) {
return (
<div className="relative flex h-full w-full flex-col items-center justify-center gap-3">
<TransferIcon className="size-[60px]" strokeWidth={1.5} />
@@ -65,50 +85,26 @@ export const ExternalContoursRoot = observer(function ExternalContoursRoot(props
);
}
if (boardError && boardError?.status === "init-error" && !inboxIssueId) {
return (
<div className="relative flex h-full w-full flex-col items-center justify-center gap-3">
<TransferIcon className="size-[60px]" strokeWidth={1.5} />
<div className="text-secondary">{boardError?.message}</div>
</div>
);
}
return (
<>
{!inboxIssueId && (
<div className="flex h-12 w-full items-center border-b border-subtle px-4 lg:hidden">
<PanelLeft
onClick={() => setIsMobileSidebar(!isMobileSidebar)}
className={cn("h-4 w-4", isMobileSidebar ? "text-accent-primary" : "text-secondary")}
/>
</div>
)}
<div className="flex h-full w-full overflow-hidden bg-surface-1 pt-2">
<div
className={cn(
"absolute top-[50px] bottom-0 z-10 w-full flex-shrink-0 bg-surface-1 transition-all lg:!relative lg:!top-0 lg:w-2/6",
isMobileSidebar ? "translate-x-0" : "-translate-x-full lg:!translate-x-0"
)}
>
<ExternalContoursSidebar
setIsMobileSidebar={setIsMobileSidebar}
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
inboxIssueId={inboxIssueId}
/>
</div>
{inboxIssueId ? (
<ExternalContoursContentRoot
setIsMobileSidebar={setIsMobileSidebar}
isMobileSidebar={isMobileSidebar}
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
inboxIssueId={inboxIssueId.toString()}
/>
) : (
<div className="flex h-full w-full flex-col overflow-hidden px-8">
<div className="hidden h-20 shrink-0 lg:block" />
<div className="flex min-h-0 flex-1 items-center justify-center">
<ExternalContoursEmptyState
compact
title={t("external_contours_page.empty_state.detail_title")}
description={t("external_contours_page.empty_state.detail_description")}
/>
</div>
</div>
<ExternalContoursBoardRoot workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} />
)}
</div>
</>