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