From 8bf6f2a510a2cb6d943d698e3b78dc4bde8a8a76 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Mon, 20 Apr 2026 20:49:09 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A4=D0=A3=D0=9D=D0=9A=D0=A6=D0=98=D0=98=20-?= =?UTF-8?q?=20=D0=9C=D0=95=D0=96=D0=9F=D0=A0=D0=9E=D0=95=D0=9A=D0=A2=D0=9D?= =?UTF-8?q?=D0=90=D0=AF=20=D0=9A=D0=9E=D0=9C=D0=9C=D0=A3=D0=9D=D0=98=D0=9A?= =?UTF-8?q?=D0=90=D0=A6=D0=98=D0=AF:=20frontend=20read-layer=20=D0=B8=20?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B2=D1=8B=D0=B9=20=D1=8D=D0=BA=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=20=D0=B4=D0=B2=D1=83=D1=81=D1=82=D0=BE=D1=80=D0=BE=D0=BD?= =?UTF-8?q?=D0=BD=D0=B5=D0=B9=20=D0=B4=D0=BE=D1=81=D0=BA=D0=B8=20=D0=B2?= =?UTF-8?q?=D0=BD=D0=B5=D1=88=D0=BD=D0=B8=D1=85=20=D0=BA=D0=BE=D0=BD=D1=82?= =?UTF-8?q?=D1=83=D1=80=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../external-contours/board-column.tsx | 77 +++++++++ .../projects/external-contours/board-item.tsx | 92 ++++++++++ .../projects/external-contours/board-root.tsx | 85 ++++++++++ .../external-contours/content-root.tsx | 23 +-- .../external-contours/issue-header.tsx | 21 +-- .../projects/external-contours/root.tsx | 82 +++++---- .../workspace-notifications/root.tsx | 2 - .../use-project-external-contours-board.ts | 15 ++ .../external-contour.service.ts | 30 ++++ .../project-external-contours-board.store.ts | 158 ++++++++++++++++++ .../project-external-contours.store.ts | 2 +- plane-src/apps/web/core/store/root.store.ts | 5 + .../i18n/src/locales/en/translations.ts | 12 ++ .../i18n/src/locales/ru/translations.ts | 12 ++ .../packages/types/src/external-contours.ts | 56 +++++++ 15 files changed, 603 insertions(+), 69 deletions(-) create mode 100644 plane-src/apps/web/ce/components/projects/external-contours/board-column.tsx create mode 100644 plane-src/apps/web/ce/components/projects/external-contours/board-item.tsx create mode 100644 plane-src/apps/web/ce/components/projects/external-contours/board-root.tsx create mode 100644 plane-src/apps/web/core/hooks/store/use-project-external-contours-board.ts create mode 100644 plane-src/apps/web/core/store/external-contours/project-external-contours-board.store.ts diff --git a/plane-src/apps/web/ce/components/projects/external-contours/board-column.tsx b/plane-src/apps/web/ce/components/projects/external-contours/board-column.tsx new file mode 100644 index 0000000..ee1d15f --- /dev/null +++ b/plane-src/apps/web/ce/components/projects/external-contours/board-column.tsx @@ -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 ( +
+
+
{title}
+
{totalCount}
+
+ +
+ {requestIds.length > 0 ? ( +
+ {requestIds.map((requestId) => { + const request = getRequestById(requestId); + if (!request) return null; + + return ( + + ); + })} +
+ ) : ( +
+ +
+ )} +
+
+ ); +}); diff --git a/plane-src/apps/web/ce/components/projects/external-contours/board-item.tsx b/plane-src/apps/web/ce/components/projects/external-contours/board-item.tsx new file mode 100644 index 0000000..ffcaf7e --- /dev/null +++ b/plane-src/apps/web/ce/components/projects/external-contours/board-item.tsx @@ -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 ( + +
+
+
+
+ +
+
{requester}
+
+
+ +
+ {request.has_unread_updates && } + +
+
+ +
+ {counterpartContourName || t("common.none")} +
+
+ +
+

{issue.name}

+
+ +
+
+ {assigneeDetails.length > 0 ? ( + assigneeDetails.map((assignee, index) => ( +
0 && "-ml-2")}> + +
+ )) + ) : ( +
{t("external_contours_page.list.unassigned")}
+ )} +
+ +
+
{renderFormattedDate(lastUpdatedAt ?? "")}
+ {issue.priority && issue.priority !== "none" && ( +
+ +
+ )} +
+
+
+ + ); +}); diff --git a/plane-src/apps/web/ce/components/projects/external-contours/board-root.tsx b/plane-src/apps/web/ce/components/projects/external-contours/board-root.tsx new file mode 100644 index 0000000..88862f3 --- /dev/null +++ b/plane-src/apps/web/ce/components/projects/external-contours/board-root.tsx @@ -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 ( +
+
+
+ {tabNavigationOptions.map((option) => { + const count = tabCountMap[option.key] ?? 0; + return ( + + ); + })} +
+
+ + {loader === "init-loading" ? ( +
{t("loading")}...
+ ) : ( +
+ + +
+ )} +
+ ); +}); diff --git a/plane-src/apps/web/ce/components/projects/external-contours/content-root.tsx b/plane-src/apps/web/ce/components/projects/external-contours/content-root.tsx index 0c6e711..fbe9c7b 100644 --- a/plane-src/apps/web/ce/components/projects/external-contours/content-root.tsx +++ b/plane-src/apps/web/ce/components/projects/external-contours/content-root.tsx @@ -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("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
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" > @@ -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" > @@ -207,10 +208,6 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
- setIsMobileSidebar(!isMobileSidebar)} - className={`my-auto mr-2 h-4 w-4 flex-shrink-0 ${isMobileSidebar ? "text-accent-primary" : "text-secondary"}`} - />
diff --git a/plane-src/apps/web/ce/components/projects/external-contours/root.tsx b/plane-src/apps/web/ce/components/projects/external-contours/root.tsx index d9c0d4c..1299840 100644 --- a/plane-src/apps/web/ce/components/projects/external-contours/root.tsx +++ b/plane-src/apps/web/ce/components/projects/external-contours/root.tsx @@ -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 (
@@ -65,50 +85,26 @@ export const ExternalContoursRoot = observer(function ExternalContoursRoot(props ); } + if (boardError && boardError?.status === "init-error" && !inboxIssueId) { + return ( +
+ +
{boardError?.message}
+
+ ); + } + return ( <> - {!inboxIssueId && ( -
- setIsMobileSidebar(!isMobileSidebar)} - className={cn("h-4 w-4", isMobileSidebar ? "text-accent-primary" : "text-secondary")} - /> -
- )}
-
- -
- {inboxIssueId ? ( ) : ( -
-
-
- -
-
+ )}
diff --git a/plane-src/apps/web/core/components/workspace-notifications/root.tsx b/plane-src/apps/web/core/components/workspace-notifications/root.tsx index 9d4b9c0..562171d 100644 --- a/plane-src/apps/web/core/components/workspace-notifications/root.tsx +++ b/plane-src/apps/web/core/components/workspace-notifications/root.tsx @@ -102,8 +102,6 @@ export const NotificationsRoot = observer(function NotificationsRoot({ workspace
) : ( {}} - isMobileSidebar={false} workspaceSlug={workspace_slug} projectId={project_id} inboxIssueId={issue_id} diff --git a/plane-src/apps/web/core/hooks/store/use-project-external-contours-board.ts b/plane-src/apps/web/core/hooks/store/use-project-external-contours-board.ts new file mode 100644 index 0000000..1756bf8 --- /dev/null +++ b/plane-src/apps/web/core/hooks/store/use-project-external-contours-board.ts @@ -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; +}; diff --git a/plane-src/apps/web/core/services/external-contours/external-contour.service.ts b/plane-src/apps/web/core/services/external-contours/external-contour.service.ts index eee31c9..6ce3d99 100644 --- a/plane-src/apps/web/core/services/external-contours/external-contour.service.ts +++ b/plane-src/apps/web/core/services/external-contours/external-contour.service.ts @@ -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 = {} + ): Promise { + 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 { 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 { + 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, diff --git a/plane-src/apps/web/core/store/external-contours/project-external-contours-board.store.ts b/plane-src/apps/web/core/store/external-contours/project-external-contours-board.store.ts new file mode 100644 index 0000000..ba4d423 --- /dev/null +++ b/plane-src/apps/web/core/store/external-contours/project-external-contours-board.store.ts @@ -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; + items: Record; + loader: TLoader; + sorting: TExternalContourBoardSorting; + columnIdsMap: Record; + columnCountMap: Record; + tabCountMap: Record; + fetchBoard: (workspaceSlug: string, projectId: string, tab?: TInboxIssueCurrentTab) => Promise; + getColumnRequestIds: (direction: TExternalContourBoardDirection) => string[]; + getColumnTotalCount: (direction: TExternalContourBoardDirection) => number; + getRequestById: (requestId: string) => TExternalContourRequest | undefined; + handleCurrentTab: (workspaceSlug: string, projectId: string, tab: TInboxIssueCurrentTab) => Promise; + 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 = { status: [EInboxIssueCurrentTab.OPEN] }; + items: Record = {}; + loader: TLoader = "init-loading"; + sorting: TExternalContourBoardSorting = { order_by: "updated_at", sort_by: "desc" }; + columnIdsMap: Record = { + outgoing: [], + incoming: [], + }; + columnCountMap: Record = { + outgoing: 0, + incoming: 0, + }; + tabCountMap: Record = { + [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", + }; + }); + } + }; +} diff --git a/plane-src/apps/web/core/store/external-contours/project-external-contours.store.ts b/plane-src/apps/web/core/store/external-contours/project-external-contours.store.ts index f56e724..a0d6cdc 100644 --- a/plane-src/apps/web/core/store/external-contours/project-external-contours.store.ts +++ b/plane-src/apps/web/core/store/external-contours/project-external-contours.store.ts @@ -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; diff --git a/plane-src/apps/web/core/store/root.store.ts b/plane-src/apps/web/core/store/root.store.ts index 527c90a..c02e23e 100644 --- a/plane-src/apps/web/core/store/root.store.ts +++ b/plane-src/apps/web/core/store/root.store.ts @@ -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); diff --git a/plane-src/packages/i18n/src/locales/en/translations.ts b/plane-src/packages/i18n/src/locales/en/translations.ts index c1e4dd1..b9dfea3 100644 --- a/plane-src/packages/i18n/src/locales/en/translations.ts +++ b/plane-src/packages/i18n/src/locales/en/translations.ts @@ -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", diff --git a/plane-src/packages/i18n/src/locales/ru/translations.ts b/plane-src/packages/i18n/src/locales/ru/translations.ts index 6e90aeb..f010117 100644 --- a/plane-src/packages/i18n/src/locales/ru/translations.ts +++ b/plane-src/packages/i18n/src/locales/ru/translations.ts @@ -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: "Последнее изменение", diff --git a/plane-src/packages/types/src/external-contours.ts b/plane-src/packages/types/src/external-contours.ts index 7e01f3a..ce4ac4c 100644 --- a/plane-src/packages/types/src/external-contours.ts +++ b/plane-src/packages/types/src/external-contours.ts @@ -53,9 +53,28 @@ export type TExternalContourMirroredActivity = { actor_detail?: Pick | null; }; +export type TExternalContourBoardDirection = "outgoing" | "incoming"; + +export type TExternalContourBoardProject = Pick; + +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; + sorting: TExternalContourBoardSorting; + columns: TExternalContourBoardColumn[]; +}; + export type TExternalContourTargetProject = IProjectLite & { inbox_view: boolean; };