ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: frontend read-layer и первый экран двусторонней доски внешних контуров
This commit is contained in:
parent
0184ff9a32
commit
8bf6f2a510
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: "Последнее изменение",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue