ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: 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

View File

@ -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>
);
});

View File

@ -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>
);
});

View File

@ -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>
);
});

View File

@ -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}

View File

@ -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">

View File

@ -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>
</>

View File

@ -102,8 +102,6 @@ export const NotificationsRoot = observer(function NotificationsRoot({ workspace
</div>
) : (
<ExternalContoursContentRoot
setIsMobileSidebar={() => {}}
isMobileSidebar={false}
workspaceSlug={workspace_slug}
projectId={project_id}
inboxIssueId={issue_id}

View File

@ -0,0 +1,15 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useContext } from "react";
import { StoreContext } from "@/lib/store-context";
import type { IProjectExternalContoursBoardStore } from "@/store/external-contours/project-external-contours-board.store";
export const useProjectExternalContoursBoard = (): IProjectExternalContoursBoardStore => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useProjectExternalContoursBoard must be used within StoreProvider");
return context.projectExternalContoursBoard;
};

View File

@ -6,6 +6,8 @@
import { API_BASE_URL } from "@plane/constants";
import type {
TExternalContourBoardFilter,
TExternalContourBoardResponse,
TExternalContourRequest,
TExternalContourRequestResponse,
TExternalContourTargetOptions,
@ -27,6 +29,26 @@ export class ExternalContourService extends APIService {
});
}
async listBoard(
workspaceSlug: string,
projectId: string,
filters: Partial<TExternalContourBoardFilter> = {}
): Promise<TExternalContourBoardResponse> {
const params = Object.fromEntries(
Object.entries(filters).flatMap(([key, value]) => {
if (value === undefined || value === null || value === "") return [];
if (Array.isArray(value)) return [[key, value.join(",")]];
return [[key, String(value)]];
})
);
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/external-contours/board/`, { params })
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async retrieve(workspaceSlug: string, projectId: string, requestId: string): Promise<TExternalContourRequest> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/external-contours/${requestId}/`)
.then((response) => response?.data)
@ -35,6 +57,14 @@ export class ExternalContourService extends APIService {
});
}
async retrieveBoardItem(workspaceSlug: string, projectId: string, requestId: string): Promise<TExternalContourRequest> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/external-contours/board-items/${requestId}/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async updateRequest(
workspaceSlug: string,
projectId: string,

View File

@ -0,0 +1,158 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { action, computed, makeObservable, observable, runInAction } from "mobx";
import type {
TExternalContourBoardDirection,
TExternalContourBoardFilter,
TExternalContourBoardSorting,
TExternalContourRequest,
TInboxIssueCurrentTab,
} from "@plane/types";
import { EInboxIssueCurrentTab } from "@plane/types";
import { ExternalContourService } from "@/services/external-contours";
import type { CoreRootStore } from "../root.store";
type TLoader = "init-loading" | undefined;
export interface IProjectExternalContoursBoardStore {
currentProjectId: string;
currentTab: TInboxIssueCurrentTab;
error: { message: string; status: "init-error" } | undefined;
filters: Partial<TExternalContourBoardFilter>;
items: Record<string, TExternalContourRequest>;
loader: TLoader;
sorting: TExternalContourBoardSorting;
columnIdsMap: Record<TExternalContourBoardDirection, string[]>;
columnCountMap: Record<TExternalContourBoardDirection, number>;
tabCountMap: Record<TInboxIssueCurrentTab, number>;
fetchBoard: (workspaceSlug: string, projectId: string, tab?: TInboxIssueCurrentTab) => Promise<void>;
getColumnRequestIds: (direction: TExternalContourBoardDirection) => string[];
getColumnTotalCount: (direction: TExternalContourBoardDirection) => number;
getRequestById: (requestId: string) => TExternalContourRequest | undefined;
handleCurrentTab: (workspaceSlug: string, projectId: string, tab: TInboxIssueCurrentTab) => Promise<void>;
hasAnyItems: boolean;
upsertBoardItems: (items: TExternalContourRequest[]) => void;
}
export class ProjectExternalContoursBoardStore implements IProjectExternalContoursBoardStore {
currentProjectId = "";
currentTab: TInboxIssueCurrentTab = EInboxIssueCurrentTab.OPEN;
error: { message: string; status: "init-error" } | undefined = undefined;
filters: Partial<TExternalContourBoardFilter> = { status: [EInboxIssueCurrentTab.OPEN] };
items: Record<string, TExternalContourRequest> = {};
loader: TLoader = "init-loading";
sorting: TExternalContourBoardSorting = { order_by: "updated_at", sort_by: "desc" };
columnIdsMap: Record<TExternalContourBoardDirection, string[]> = {
outgoing: [],
incoming: [],
};
columnCountMap: Record<TExternalContourBoardDirection, number> = {
outgoing: 0,
incoming: 0,
};
tabCountMap: Record<TInboxIssueCurrentTab, number> = {
[EInboxIssueCurrentTab.OPEN]: 0,
[EInboxIssueCurrentTab.CLOSED]: 0,
};
externalContourService;
constructor(private store: CoreRootStore) {
makeObservable(this, {
currentProjectId: observable.ref,
currentTab: observable.ref,
error: observable.ref,
filters: observable.ref,
items: observable,
loader: observable.ref,
sorting: observable.ref,
columnIdsMap: observable,
columnCountMap: observable,
tabCountMap: observable,
hasAnyItems: computed,
fetchBoard: action,
handleCurrentTab: action,
upsertBoardItems: action,
});
this.externalContourService = new ExternalContourService();
}
get hasAnyItems() {
return this.columnIdsMap.outgoing.length > 0 || this.columnIdsMap.incoming.length > 0;
}
getRequestById = (requestId: string) => this.items[requestId];
getColumnRequestIds = (direction: TExternalContourBoardDirection) => this.columnIdsMap[direction] ?? [];
getColumnTotalCount = (direction: TExternalContourBoardDirection) => this.columnCountMap[direction] ?? 0;
upsertBoardItems = (items: TExternalContourRequest[]) => {
items.forEach((request) => {
this.items[request.id] = request;
});
this.store.projectExternalContours.upsertRequests(items);
};
handleCurrentTab = async (workspaceSlug: string, projectId: string, tab: TInboxIssueCurrentTab) => {
this.currentProjectId = projectId;
this.currentTab = tab;
this.filters = {
...this.filters,
status: [tab],
};
await this.fetchBoard(workspaceSlug, projectId, tab);
};
fetchBoard = async (workspaceSlug: string, projectId: string, tab = this.currentTab) => {
this.loader = "init-loading";
this.error = undefined;
this.currentProjectId = projectId;
this.currentTab = tab;
this.filters = {
...this.filters,
status: [tab],
};
try {
const response = await this.externalContourService.listBoard(workspaceSlug, projectId, {
status: tab,
});
runInAction(() => {
this.items = {};
this.columnIdsMap = { outgoing: [], incoming: [] };
this.columnCountMap = { outgoing: 0, incoming: 0 };
this.filters = response.filters || { status: [tab] };
this.sorting = response.sorting || { order_by: "updated_at", sort_by: "desc" };
response.columns.forEach((column) => {
this.columnIdsMap[column.key] = column.results.map((request) => request.id);
this.columnCountMap[column.key] = column.total_count;
this.upsertBoardItems(column.results);
});
this.tabCountMap = {
...this.tabCountMap,
[tab]: response.columns.reduce((total, column) => total + column.total_count, 0),
};
this.loader = undefined;
});
} catch (error: any) {
runInAction(() => {
this.loader = undefined;
this.error = {
message: error?.error || "Не удалось загрузить доску внешних контуров",
status: "init-error",
};
});
}
};
}

View File

@ -215,7 +215,7 @@ export class ProjectExternalContoursStore implements IProjectExternalContoursSto
fetchRequestById = async (workspaceSlug: string, projectId: string, requestId: string) => {
this.loader = "issue-loading";
try {
const request = await this.externalContourService.retrieve(workspaceSlug, projectId, requestId);
const request = await this.externalContourService.retrieveBoardItem(workspaceSlug, projectId, requestId);
runInAction(() => {
this.upsertRequests([request]);
this.loader = undefined;

View File

@ -32,6 +32,8 @@ import { EditorAssetStore } from "./editor/asset.store";
import type { IProjectEstimateStore } from "./estimates/project-estimate.store";
import { ProjectEstimateStore } from "./estimates/project-estimate.store";
import type { IProjectExternalContoursStore } from "./external-contours/project-external-contours.store";
import type { IProjectExternalContoursBoardStore } from "./external-contours/project-external-contours-board.store";
import { ProjectExternalContoursBoardStore } from "./external-contours/project-external-contours-board.store";
import { ProjectExternalContoursStore } from "./external-contours/project-external-contours.store";
import type { IFavoriteStore } from "./favorite.store";
import { FavoriteStore } from "./favorite.store";
@ -95,6 +97,7 @@ export class CoreRootStore {
instance: IInstanceStore;
user: IUserStore;
projectInbox: IProjectInboxStore;
projectExternalContoursBoard: IProjectExternalContoursBoardStore;
projectExternalContours: IProjectExternalContoursStore;
projectEstimate: IProjectEstimateStore;
multipleSelect: IMultipleSelectStore;
@ -127,6 +130,7 @@ export class CoreRootStore {
this.multipleSelect = new MultipleSelectStore();
this.projectInbox = new ProjectInboxStore(this);
this.projectExternalContours = new ProjectExternalContoursStore(this);
this.projectExternalContoursBoard = new ProjectExternalContoursBoardStore(this);
this.projectPages = new ProjectPageStore(this as unknown as RootStore);
this.projectEstimate = new ProjectEstimateStore(this);
this.workspaceNotification = new WorkspaceNotificationStore(this);
@ -161,6 +165,7 @@ export class CoreRootStore {
this.dashboard = new DashboardStore(this);
this.projectInbox = new ProjectInboxStore(this);
this.projectExternalContours = new ProjectExternalContoursStore(this);
this.projectExternalContoursBoard = new ProjectExternalContoursBoardStore(this);
this.projectPages = new ProjectPageStore(this as unknown as RootStore);
this.multipleSelect = new MultipleSelectStore();
this.projectEstimate = new ProjectEstimateStore(this);

View File

@ -295,6 +295,18 @@ export default {
tabs: {
open: "Open",
closed: "Closed",
},
board: {
columns: {
outgoing: "Outgoing",
incoming: "Incoming",
},
empty: {
outgoing_title: "No outgoing requests",
outgoing_description: "Requests sent from this contour to other projects will appear here.",
incoming_title: "No incoming requests",
incoming_description: "Requests routed into this contour from other projects will appear here.",
},
},
list: {
last_updated: "Last updated",

View File

@ -452,6 +452,18 @@ export default {
tabs: {
open: "Открытые",
closed: "Закрытые",
},
board: {
columns: {
outgoing: "Исходящие",
incoming: "Входящие",
},
empty: {
outgoing_title: "Нет исходящих запросов",
outgoing_description: "Здесь будут видны запросы, которые этот контур отправил в другие проекты.",
incoming_title: "Нет входящих запросов",
incoming_description: "Здесь будут видны запросы, которые пришли в этот контур из других проектов.",
},
},
list: {
last_updated: "Последнее изменение",

View File

@ -53,9 +53,28 @@ export type TExternalContourMirroredActivity = {
actor_detail?: Pick<IUser, "id" | "display_name" | "avatar_url"> | null;
};
export type TExternalContourBoardDirection = "outgoing" | "incoming";
export type TExternalContourBoardProject = Pick<IProjectLite, "id" | "identifier" | "name" | "logo_props">;
export type TExternalContourBoardRequestedBy = {
id: string | null;
display_name: string | null;
};
export type TExternalContourBoardCapabilities = {
can_open_detail: boolean;
can_open_target_issue: boolean;
can_edit_request: boolean;
can_reply: boolean;
can_source_decide: boolean;
};
export type TExternalContourRequest = {
capabilities?: TExternalContourBoardCapabilities;
created_at: string;
created_by: string | null;
direction?: TExternalContourBoardDirection;
has_unread_updates?: boolean;
id: string;
issue: TExternalContourIssue;
@ -71,8 +90,11 @@ export type TExternalContourRequest = {
target_project_name?: string | null;
requested_by_id?: string | null;
requested_by_name?: string | null;
requested_by?: TExternalContourBoardRequestedBy | null;
requested_at?: string | null;
source_project?: TExternalContourBoardProject | null;
status: "open" | "closed";
target_project?: TExternalContourBoardProject | null;
updated_at: string;
};
@ -80,6 +102,40 @@ export type TExternalContourRequestResponse = TPaginationInfo & {
results: TExternalContourRequest[];
};
export type TExternalContourBoardFilter = {
direction?: TExternalContourBoardDirection[];
status?: TExternalContourRequest["status"] | TExternalContourRequest["status"][];
state_ids?: string[];
priority?: string[];
assignee_ids?: string[];
created_by_ids?: string[];
requested_by_ids?: string[];
source_project_ids?: string[];
target_project_ids?: string[];
label_ids?: string[];
has_unread_updates?: boolean;
search?: string;
};
export type TExternalContourBoardSorting = {
order_by?: "requested_at" | "updated_at" | "issue__sequence_id" | "target_date";
sort_by?: "asc" | "desc";
};
export type TExternalContourBoardColumn = {
key: TExternalContourBoardDirection;
title: string;
total_count: number;
next_cursor?: string;
results: TExternalContourRequest[];
};
export type TExternalContourBoardResponse = {
filters: Partial<TExternalContourBoardFilter>;
sorting: TExternalContourBoardSorting;
columns: TExternalContourBoardColumn[];
};
export type TExternalContourTargetProject = IProjectLite & {
inbox_view: boolean;
};