ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: отказ от open-closed табов и стабилизация фильтров внешних контуров

This commit is contained in:
DCCONSTRUCTIONS 2026-04-21 08:32:20 +03:00
parent c6645bb4fc
commit 91906e917e
10 changed files with 38 additions and 129 deletions

View File

@ -6,20 +6,19 @@
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
import type { TExternalContourBoardDirection, TInboxIssueCurrentTab } from "@plane/types";
import type { TExternalContourBoardDirection } 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 { direction, projectId, workspaceSlug } = props;
const { t } = useTranslation();
const { getColumnRequestIds, getColumnTotalCount, getRequestById } = useProjectExternalContoursBoard();
const requestIds = getColumnRequestIds(direction);
@ -57,7 +56,6 @@ export const ExternalContoursBoardColumn = observer(function ExternalContoursBoa
return (
<ExternalContoursBoardItem
key={requestId}
currentTab={currentTab}
direction={direction}
projectId={projectId}
request={request}

View File

@ -431,14 +431,7 @@ const buildOptionsWithSelectedFallback = (
};
visibleRequests.forEach(upsertOption);
if (selectedIds.length > 0) {
cachedRequests.forEach((request) => {
const option = getOption(request);
if (!option?.id || !selectedIds.includes(option.id) || optionMap.has(option.id)) return;
optionMap.set(option.id, option);
});
}
cachedRequests.forEach(upsertOption);
return sortFilterOptions(Array.from(optionMap.values())).map((option) => ({
data: option,
@ -526,19 +519,7 @@ const getAssigneeOptions = (
};
visibleRequests.forEach(upsertAssignees);
if (selectedIds.length > 0) {
cachedRequests.forEach((request) => {
request.issue.assignee_details?.forEach((assignee) => {
if (!assignee?.id || !selectedIds.includes(assignee.id) || assigneeMap.has(assignee.id)) return;
assigneeMap.set(assignee.id, {
id: assignee.id,
label: assignee.display_name || "NODE.DC",
avatarUrl: assignee.avatar_url || "",
});
});
});
}
cachedRequests.forEach(upsertAssignees);
return sortFilterOptions(Array.from(assigneeMap.values())).map((option) => ({ data: option, value: option.id }));
};

View File

@ -16,7 +16,6 @@ import type {
IState,
TExternalContourBoardDirection,
TExternalContourRequest,
TInboxIssueCurrentTab,
TIssue,
} from "@plane/types";
import { Avatar } from "@plane/ui";
@ -37,7 +36,6 @@ import { useUserPermissions } from "@/hooks/store/user";
import { IssueService } from "@/services/issue/issue.service";
type Props = {
currentTab: TInboxIssueCurrentTab;
direction: TExternalContourBoardDirection;
projectId: string;
request: TExternalContourRequest;
@ -78,7 +76,7 @@ const resolveRequestStatus = (issue: TExternalContourRequest["issue"], fallbackS
};
export const ExternalContoursBoardItem = observer(function ExternalContoursBoardItem(props: Props) {
const { currentTab, direction, projectId, request, workspaceSlug } = props;
const { direction, projectId, request, workspaceSlug } = props;
const router = useAppRouter();
const searchParams = useSearchParams();
const { t } = useTranslation();
@ -86,7 +84,6 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard
const { getProjectRoleByWorkspaceSlugAndProjectId } = useUserPermissions();
const { getStateById, getProjectStateIds } = useProjectState();
const {
currentTab: boardCurrentTab,
fetchBoard,
upsertBoardItems,
} = useProjectExternalContoursBoard();
@ -116,7 +113,7 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard
direction === "incoming" && !!targetProjectId && projectRole !== undefined && projectRole !== EUserPermissions.GUEST;
const canEditSourceRequest = direction === "outgoing" && !!request.capabilities?.can_edit_request && !!targetProjectId;
const canEditCard = canEditTargetIssue || canEditSourceRequest;
const requestLink = `/${workspaceSlug}/projects/${projectId}/external-contours?currentTab=${currentTab}&inboxIssueId=${request.id}`;
const requestLink = `/${workspaceSlug}/projects/${projectId}/external-contours?inboxIssueId=${request.id}`;
const targetOptions = getTargetOptionsByProjectId(targetProjectId);
const sourceStateMap = useMemo(
() => buildSourceStateMap(targetOptions?.states, targetProjectId),
@ -145,7 +142,7 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard
};
const syncBoardAfterMutation = async () => {
await fetchBoard(workspaceSlug, projectId, boardCurrentTab ?? currentTab);
await fetchBoard(workspaceSlug, projectId);
};
const ensureSourceOptions = async () => {

View File

@ -6,11 +6,8 @@
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 { ExternalContoursBoardFiltersRow } from "./board-filters-row";
import { ExternalContoursBoardColumn } from "./board-column";
@ -19,51 +16,14 @@ type Props = {
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, hasAnyItems, isFiltering, loader, tabCountMap, handleCurrentTab } = useProjectExternalContoursBoard();
const { hasAnyItems, isFiltering, loader } = 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>
<div className="shrink-0 pb-4">
<div className="shrink-0 py-4">
<ExternalContoursBoardFiltersRow workspaceSlug={workspaceSlug} projectId={projectId} />
</div>
@ -84,13 +44,11 @@ export const ExternalContoursBoardRoot = observer(function ExternalContoursBoard
)}
>
<ExternalContoursBoardColumn
currentTab={currentTab}
direction="outgoing"
projectId={projectId}
workspaceSlug={workspaceSlug}
/>
<ExternalContoursBoardColumn
currentTab={currentTab}
direction="incoming"
projectId={projectId}
workspaceSlug={workspaceSlug}

View File

@ -35,7 +35,7 @@ export const ExternalContoursContentRoot = observer(function ExternalContoursCon
const [isSubmitting, setIsSubmitting] = useState<TNameDescriptionLoader>("saved");
const [isDetailResolved, setIsDetailResolved] = useState(false);
const { data: currentUser } = useUser();
const { currentTab, fetchRequestById, getRequestById } = useProjectExternalContours();
const { fetchRequestById, getRequestById } = useProjectExternalContours();
const contourRequest = getRequestById(inboxIssueId);
const issue = contourRequest?.issue;
const targetProjectId = issue?.project_id || projectId;
@ -46,10 +46,10 @@ export const ExternalContoursContentRoot = observer(function ExternalContoursCon
useEffect(() => {
if (isDetailResolved && !contourRequest && inboxIssueId) {
router.replace(`/${workspaceSlug}/projects/${projectId}/external-contours?currentTab=${currentTab}`);
router.replace(`/${workspaceSlug}/projects/${projectId}/external-contours`);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [contourRequest, currentTab, inboxIssueId, isDetailResolved, projectId, router, workspaceSlug]);
}, [contourRequest, inboxIssueId, isDetailResolved, projectId, router, workspaceSlug]);
useSWR(
workspaceSlug && projectId && inboxIssueId
@ -96,7 +96,7 @@ export const ExternalContoursContentRoot = observer(function ExternalContoursCon
isSubmitting={isSubmitting}
embedIssue={embedIssue}
embedRemoveCurrentNotification={embedRemoveCurrentNotification}
onClose={() => router.replace(`/${workspaceSlug}/projects/${projectId}/external-contours?currentTab=${currentTab}`)}
onClose={() => router.replace(`/${workspaceSlug}/projects/${projectId}/external-contours`)}
>
<ExternalContoursIssueMainContent
workspaceSlug={workspaceSlug}

View File

@ -12,7 +12,6 @@ import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TIssue } from "@plane/types";
import { EInboxIssueCurrentTab } from "@plane/types";
import { ToggleSwitch } from "@plane/ui";
import { useProject } from "@/hooks/store/use-project";
import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours";
@ -110,7 +109,7 @@ export const ExternalContoursCreateRoot = observer(function ExternalContoursCrea
}
if (createdRequest?.id) {
router.push(`/${workspaceSlug}/projects/${projectId}/external-contours?currentTab=${EInboxIssueCurrentTab.OPEN}&inboxIssueId=${createdRequest.id}`);
router.push(`/${workspaceSlug}/projects/${projectId}/external-contours?inboxIssueId=${createdRequest.id}`);
}
} catch (error: any) {
setToast({

View File

@ -23,7 +23,6 @@ import {
} from "@plane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TExternalContourRequest, TNameDescriptionLoader } from "@plane/types";
import { EInboxIssueCurrentTab } from "@plane/types";
import { ControlLink, CustomSelect, Header, Row, Tooltip } from "@plane/ui";
import { copyUrlToClipboard, generateWorkItemLink } from "@plane/utils";
import { NameDescriptionUpdateStatus } from "@/components/issues/issue-update-status";
@ -68,7 +67,6 @@ type Props = {
removeRoutePeekId: () => void;
peekMode: TExternalContourPeekMode;
setPeekMode: (value: TExternalContourPeekMode) => void;
currentTab: TInboxIssueCurrentTab;
embedIssue?: boolean;
};
@ -82,20 +80,18 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
removeRoutePeekId,
peekMode,
setPeekMode,
currentTab,
embedIssue = false,
} = props;
const { t } = useTranslation();
const router = useAppRouter();
const [isDeclineModalOpen, setIsDeclineModalOpen] = useState(false);
const { decideRequest, filteredRequestIds, handleCurrentTab, loader } = useProjectExternalContours();
const { currentTab: boardCurrentTab, columnIdsMap } = useProjectExternalContoursBoard();
const { decideRequest, filteredRequestIds, loader } = useProjectExternalContours();
const { columnIdsMap } = useProjectExternalContoursBoard();
const { getProjectById } = useProject();
const issue = contourRequest.issue;
const currentRequestId = contourRequest.id;
const boardRequestIds =
boardCurrentTab === currentTab ? [...(columnIdsMap.outgoing ?? []), ...(columnIdsMap.incoming ?? [])] : [];
const boardRequestIds = [...(columnIdsMap.outgoing ?? []), ...(columnIdsMap.incoming ?? [])];
const relativeRequestIds = boardRequestIds.includes(currentRequestId) ? boardRequestIds : filteredRequestIds;
const currentMode = PEEK_OPTIONS.find((mode) => mode.key === peekMode);
const hasRelativeNavigation = !!currentRequestId && relativeRequestIds.includes(currentRequestId);
@ -115,9 +111,9 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
: (currentIssueIndex - 1 + relativeRequestIds.length) % relativeRequestIds.length;
const nextIssueId = relativeRequestIds[nextIssueIndex];
if (!nextIssueId) return;
router.push(`/${workspaceSlug}/projects/${sourceProjectId}/external-contours?currentTab=${currentTab}&inboxIssueId=${nextIssueId}`);
router.push(`/${workspaceSlug}/projects/${sourceProjectId}/external-contours?inboxIssueId=${nextIssueId}`);
},
[currentRequestId, currentTab, hasRelativeNavigation, relativeRequestIds, router, sourceProjectId, workspaceSlug]
[currentRequestId, hasRelativeNavigation, relativeRequestIds, router, sourceProjectId, workspaceSlug]
);
useEffect(() => {
@ -131,8 +127,7 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
}, [redirectToRelativeIssue]);
const targetProjectIdentifier = issue.project_detail?.identifier || getProjectById(issue.project_id || "")?.identifier;
const requestTab = contourRequest.status === "closed" ? EInboxIssueCurrentTab.CLOSED : EInboxIssueCurrentTab.OPEN;
const requestLink = `/${workspaceSlug}/projects/${sourceProjectId}/external-contours?currentTab=${requestTab}&inboxIssueId=${contourRequest.id}`;
const requestLink = `/${workspaceSlug}/projects/${sourceProjectId}/external-contours?inboxIssueId=${contourRequest.id}`;
const workItemLink = generateWorkItemLink({
workspaceSlug: workspaceSlug?.toString(),
projectId: issue.project_id,
@ -183,10 +178,7 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
await decideRequest(workspaceSlug, sourceProjectId, contourRequest.id, action, comment);
if (action === "decline") {
setIsDeclineModalOpen(false);
await handleCurrentTab(workspaceSlug, sourceProjectId, EInboxIssueCurrentTab.OPEN);
router.push(
`/${workspaceSlug}/projects/${sourceProjectId}/external-contours?currentTab=${EInboxIssueCurrentTab.OPEN}&inboxIssueId=${contourRequest.id}`
);
router.push(`/${workspaceSlug}/projects/${sourceProjectId}/external-contours?inboxIssueId=${contourRequest.id}`);
}
setToast({
type: TOAST_TYPE.SUCCESS,

View File

@ -9,7 +9,6 @@ import { useCallback, useEffect, useRef, useState, type MouseEvent as ReactMouse
import { observer } from "mobx-react";
import { cn } from "@plane/utils";
import type { TExternalContourRequest, TNameDescriptionLoader } from "@plane/types";
import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours";
import useKeypress from "@/hooks/use-keypress";
import usePeekOverviewOutsideClickDetector from "@/hooks/use-peek-overview-outside-click";
import { ExternalContoursIssueActionsHeader, type TExternalContourPeekMode } from "./issue-header";
@ -55,7 +54,6 @@ export const ExternalContoursPeekShell = observer(function ExternalContoursPeekS
const issuePeekOverviewRef = useRef<HTMLDivElement>(null);
const initialPeekWidthRef = useRef<number>(0);
const initialMouseXRef = useRef<number>(0);
const { currentTab } = useProjectExternalContours();
const removeRoutePeekId = useCallback(() => {
if (embedIssue) {
@ -190,7 +188,6 @@ export const ExternalContoursPeekShell = observer(function ExternalContoursPeekS
removeRoutePeekId={removeRoutePeekId}
peekMode={peekMode}
setPeekMode={setPeekMode}
currentTab={currentTab}
embedIssue={embedIssue}
/>
<div className="vertical-scrollbar relative scrollbar-md h-full w-full overflow-hidden overflow-y-auto">

View File

@ -28,9 +28,7 @@ export const ExternalContoursRoot = observer(function ExternalContoursRoot(props
const {
error: boardError,
currentProjectId: boardProjectId,
currentTab: boardCurrentTab,
fetchBoard,
handleCurrentTab: handleBoardCurrentTab,
loader: boardLoader,
} = useProjectExternalContoursBoard();
@ -61,20 +59,11 @@ export const ExternalContoursRoot = observer(function ExternalContoursRoot(props
useEffect(() => {
if (!workspaceSlug || !projectId) return;
if (boardProjectId === projectId && boardLoader === "init-loading") 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);
void fetchBoard(workspaceSlug.toString(), projectId.toString());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workspaceSlug, projectId, navigationTab]);
}, [workspaceSlug, projectId]);
if (error && error?.status === "init-error" && !!inboxIssueId) {
return (

View File

@ -59,7 +59,7 @@ export class ProjectExternalContoursBoardStore implements IProjectExternalContou
currentProjectId = "";
currentTab: TInboxIssueCurrentTab = EInboxIssueCurrentTab.OPEN;
error: { message: string; status: "init-error" } | undefined = undefined;
filters: Partial<TExternalContourBoardFilter> = { status: [EInboxIssueCurrentTab.OPEN] };
filters: Partial<TExternalContourBoardFilter> = {};
items: Record<string, TExternalContourRequest> = {};
loader: TLoader = "init-loading";
sorting: TExternalContourBoardSorting = DEFAULT_SORTING;
@ -145,10 +145,6 @@ export class ProjectExternalContoursBoardStore implements IProjectExternalContou
handleCurrentTab = async (workspaceSlug: string, projectId: string, tab: TInboxIssueCurrentTab) => {
this.currentTab = tab;
this.filters = sanitizeBoardFilters({
...this.filters,
status: [tab],
});
await this.fetchBoard(workspaceSlug, projectId, tab);
};
@ -160,30 +156,26 @@ export class ProjectExternalContoursBoardStore implements IProjectExternalContou
this.filters = sanitizeBoardFilters({
...this.filters,
...filters,
status: [this.currentTab],
});
await this.fetchBoard(workspaceSlug, projectId, this.currentTab);
await this.fetchBoard(workspaceSlug, projectId);
};
updateSorting = async (workspaceSlug: string, projectId: string, sorting: TExternalContourBoardSorting) => {
this.sorting = sorting;
await this.fetchBoard(workspaceSlug, projectId, this.currentTab);
await this.fetchBoard(workspaceSlug, projectId);
};
clearFilters = async (workspaceSlug: string, projectId: string) => {
this.filters = { status: [this.currentTab] };
this.filters = {};
this.sorting = DEFAULT_SORTING;
await this.fetchBoard(workspaceSlug, projectId, this.currentTab);
await this.fetchBoard(workspaceSlug, projectId);
};
fetchBoard = async (workspaceSlug: string, projectId: string, tab = this.currentTab) => {
const hasProjectChanged = !!this.currentProjectId && this.currentProjectId !== projectId;
const isInitialLoad = this.hydratedProjectId !== projectId;
const nextFilters = sanitizeBoardFilters({
...(hasProjectChanged ? {} : this.filters),
status: [tab],
});
const nextFilters = sanitizeBoardFilters(hasProjectChanged ? {} : this.filters);
const nextSorting = hasProjectChanged ? DEFAULT_SORTING : this.sorting;
const requestId = ++this.lastIssuedRequestId;
@ -213,16 +205,22 @@ export class ProjectExternalContoursBoardStore implements IProjectExternalContou
this.filters = sanitizeBoardFilters(response.filters || nextFilters);
this.sorting = response.sorting || nextSorting;
this.hydratedProjectId = projectId;
let openCount = 0;
let closedCount = 0;
response.columns.forEach((column) => {
this.columnIdsMap[column.key] = column.results.map((request) => request.id);
this.columnCountMap[column.key] = column.total_count;
column.results.forEach((request) => {
if (request.status === EInboxIssueCurrentTab.CLOSED) closedCount += 1;
else openCount += 1;
});
this.upsertBoardItems(column.results);
});
this.tabCountMap = {
...this.tabCountMap,
[tab]: response.columns.reduce((total, column) => total + column.total_count, 0),
[EInboxIssueCurrentTab.OPEN]: openCount,
[EInboxIssueCurrentTab.CLOSED]: closedCount,
};
this.loader = undefined;