Compare commits

..

No commits in common. "d53fa2b38c2e5e05364eba54945435f7dcf58343" and "4ba3aab02eb88ee8335d71816d53c9e81706f2af" have entirely different histories.

28 changed files with 254 additions and 1693 deletions

View File

@ -60,7 +60,6 @@ cd /Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/p
docker build -t nodedc/plane-backend:local -f Dockerfile.api . docker build -t nodedc/plane-backend:local -f Dockerfile.api .
cd /Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/plane-src cd /Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/plane-src
docker build -t nodedc/plane-live:local -f apps/live/Dockerfile.live .
docker build -t nodedc/plane-frontend:ru -f apps/web/Dockerfile.web . docker build -t nodedc/plane-frontend:ru -f apps/web/Dockerfile.web .
docker build -t nodedc/plane-admin:ru -f apps/admin/Dockerfile.admin . docker build -t nodedc/plane-admin:ru -f apps/admin/Dockerfile.admin .
docker build -t nodedc/plane-space:ru -f apps/space/Dockerfile.space . docker build -t nodedc/plane-space:ru -f apps/space/Dockerfile.space .

View File

@ -120,7 +120,6 @@ cd /Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER
```bash ```bash
cd /Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/plane-src cd /Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/plane-src
docker build -t nodedc/plane-live:local -f apps/live/Dockerfile.live .
docker build -t nodedc/plane-frontend:ru -f apps/web/Dockerfile.web . docker build -t nodedc/plane-frontend:ru -f apps/web/Dockerfile.web .
docker build -t nodedc/plane-admin:ru -f apps/admin/Dockerfile.admin . docker build -t nodedc/plane-admin:ru -f apps/admin/Dockerfile.admin .
docker build -t nodedc/plane-space:ru -f apps/space/Dockerfile.space . docker build -t nodedc/plane-space:ru -f apps/space/Dockerfile.space .

View File

@ -99,7 +99,7 @@ services:
- web - web
live: live:
image: nodedc/plane-live:local image: makeplane/plane-live:${APP_RELEASE:-v1.3.0}
environment: environment:
<<: [*live-env, *redis-env] <<: [*live-env, *redis-env]
deploy: deploy:

View File

@ -99,7 +99,7 @@ services:
- web - web
live: live:
image: nodedc/plane-live:local image: makeplane/plane-live:${APP_RELEASE:-v1.3.0}
environment: environment:
<<: [*live-env, *redis-env] <<: [*live-env, *redis-env]
deploy: deploy:

View File

@ -16,6 +16,7 @@ import { NewUserPopup } from "@/components/common/new-user-popup";
import { useUser } from "@/hooks/store"; import { useUser } from "@/hooks/store";
// local components // local components
import type { Route } from "./+types/layout"; import type { Route } from "./+types/layout";
import { AdminSidebar } from "./sidebar";
function AdminLayout(_props: Route.ComponentProps) { function AdminLayout(_props: Route.ComponentProps) {
// router // router
@ -38,6 +39,7 @@ function AdminLayout(_props: Route.ComponentProps) {
if (isUserLoggedIn) { if (isUserLoggedIn) {
return ( return (
<div className="nodedc-admin-shell relative flex h-screen w-screen overflow-hidden"> <div className="nodedc-admin-shell relative flex h-screen w-screen overflow-hidden">
<AdminSidebar />
<main className="nodedc-admin-main relative flex h-full w-full flex-col overflow-hidden bg-surface-1"> <main className="nodedc-admin-main relative flex h-full w-full flex-col overflow-hidden bg-surface-1">
<AdminHeader /> <AdminHeader />
<div className="vertical-scrollbar scrollbar-md h-full w-full overflow-hidden overflow-y-scroll"> <div className="vertical-scrollbar scrollbar-md h-full w-full overflow-hidden overflow-y-scroll">

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220.82 54.55" role="img" aria-label="NODE.DC"><path fill="#e2e1e1" d="M52.8 23.61 46.92 33.76 41.05 23.61H52.8m18-10.39H23.06l23.86 41.33Z"/><path fill="#e2e1e1" d="M31.28 33.13 18.11 10.34h57.62L62.59 33.13h11.69L93.22 0H0l19.61 33.13h11.67Z"/><path fill="#dbdbdb" stroke="#dbdbdb" stroke-miterlimit="10" stroke-width=".75" d="M116.35 18.49V1h1.27l10.34 15V1h1.33v17.49H128l-10.34-15v15Zm24.08.15c-4.79 0-8.16-3.72-8.16-8.89S135.64.86 140.43.86s8.17 3.72 8.17 8.89-3.35 8.89-8.17 8.89Zm0-1.25c4 0 6.79-3.17 6.79-7.64s-2.77-7.64-6.79-7.64-6.77 3.17-6.77 7.64 2.78 7.64 6.77 7.64Zm11.17 1.1V1h5.1c5.54 0 8.79 3.42 8.79 8.74s-3.25 8.74-8.79 8.74Zm1.4-1.25h3.75c4.77 0 7.42-2.92 7.42-7.49s-2.65-7.49-7.42-7.49H153Zm15.49-16.24h10.77v1.26h-9.42v6.67h7.89v1.25h-7.89v7.06h9.74v1.25h-11.09Zm20.39 17.49V1H194c5.54 0 8.79 3.42 8.79 8.74s-3.25 8.74-8.79 8.74Zm1.35-1.25H194c4.77 0 7.41-2.92 7.41-7.49S198.75 2.26 194 2.26h-3.75Zm14.92-7.49c0-5.24 3.19-8.89 8.11-8.89a6.8 6.8 0 0 1 7.1 5.52h-1.43a5.54 5.54 0 0 0-5.74-4.27c-4.05 0-6.64 3.17-6.64 7.64s2.54 7.64 6.59 7.64a5.46 5.46 0 0 0 5.74-4.29h1.43c-.75 3.52-3.4 5.54-7.15 5.54-4.89 0-8.01-3.59-8.01-8.89Z"/></svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -4,179 +4,82 @@
* See the LICENSE file for details. * See the LICENSE file for details.
*/ */
import { Fragment, useEffect, useState } from "react"; import { Fragment } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useTheme as useNextTheme } from "next-themes";
import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { BrainCog, ChevronDown, ExternalLink, Image, LogOut, Mail, Palette, Settings, UserCog2 } from "lucide-react"; import { ChevronRight, Menu, Settings } from "lucide-react";
import { Menu, Transition } from "@headlessui/react"; // components
// plane imports import { BreadcrumbLink } from "../breadcrumb-link";
import { API_BASE_URL } from "@plane/constants";
import { LockIcon, WorkspaceIcon } from "@plane/propel/icons";
import { AuthService } from "@plane/services";
import { Avatar } from "@plane/ui";
import { cn, getFileURL } from "@plane/utils";
// assets
import NodeDcLogo from "@/app/assets/logos/nodedc-logo.svg?url";
// hooks // hooks
import { useUser } from "@/hooks/store"; import { useTheme } from "@/hooks/store";
// local imports // local imports
import { CORE_HEADER_SEGMENT_LABELS } from "./core";
import { EXTENDED_HEADER_SEGMENT_LABELS } from "./extended";
const authService = new AuthService(); export const HamburgerToggle = observer(function HamburgerToggle() {
const { isSidebarCollapsed, toggleSidebar } = useTheme();
return (
<button
className="group flex size-7 cursor-pointer items-center justify-center rounded-sm bg-layer-1 transition-all hover:bg-layer-1-hover md:hidden"
aria-label="Открыть меню"
onClick={() => toggleSidebar(!isSidebarCollapsed)}
>
<Menu size={14} className="text-secondary transition-all group-hover:text-primary" />
</button>
);
});
const PRIMARY_NAVIGATION = [ const HEADER_SEGMENT_LABELS = {
{ label: "Основное", href: "/general/", Icon: Settings }, ...CORE_HEADER_SEGMENT_LABELS,
{ label: "Почта", href: "/email/", Icon: Mail }, ...EXTENDED_HEADER_SEGMENT_LABELS,
{ label: "Аутентификация", href: "/authentication/", Icon: LockIcon }, };
{ label: "Воркспейсы", href: "/workspace/", Icon: WorkspaceIcon },
];
const FEATURE_NAVIGATION = [
{ label: "ИИ", href: "/ai/", Icon: BrainCog, description: "OpenAI модель и API-ключ" },
{ label: "Изображения", href: "/image/", Icon: Image, description: "Внешние библиотеки изображений" },
];
export const AdminHeader = observer(function AdminHeader() { export const AdminHeader = observer(function AdminHeader() {
const pathName = usePathname(); const pathName = usePathname();
const { currentUser, signOut } = useUser();
const { resolvedTheme, setTheme } = useNextTheme();
const [csrfToken, setCsrfToken] = useState<string | undefined>(undefined);
const isFeatureRoute = FEATURE_NAVIGATION.some((item) => pathName?.startsWith(item.href)); // Function to dynamically generate breadcrumb items based on pathname
const adminName = currentUser?.display_name || currentUser?.email || "Глобальный админ"; const generateBreadcrumbItems = (pathname: string) => {
const avatarName = currentUser?.display_name || currentUser?.email || "DC"; const pathSegments = pathname.split("/").slice(1); // removing the first empty string.
pathSegments.pop();
const handleThemeSwitch = () => { let currentUrl = "";
const newTheme = resolvedTheme === "dark" ? "light" : "dark"; const breadcrumbItems = pathSegments.map((segment) => {
setTheme(newTheme); currentUrl += "/" + segment;
return {
title: HEADER_SEGMENT_LABELS[segment] ?? segment.toUpperCase(),
href: currentUrl,
};
});
return breadcrumbItems;
}; };
useEffect(() => { const breadcrumbItems = generateBreadcrumbItems(pathName || "");
if (csrfToken === undefined)
void authService.requestCSRFToken().then((data) => data?.csrf_token && setCsrfToken(data.csrf_token));
}, [csrfToken]);
return ( return (
<header className="nodedc-admin-header relative z-30 flex w-full flex-shrink-0 flex-col gap-4"> <div className="nodedc-admin-header nodedc-glass-modal relative z-10 flex h-header w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 border-b border-subtle bg-surface-1 p-4">
<div className="nodedc-admin-header-top grid w-full items-center gap-4"> <div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
<a href="/" className="nodedc-admin-logo-link inline-flex w-fit items-center" aria-label="NODE.DC"> <HamburgerToggle />
<img src={NodeDcLogo} alt="NODE.DC" className="nodedc-admin-logo" /> {breadcrumbItems.length >= 0 && (
</a> <nav className="min-w-0" aria-label="Навигация God Mode">
<ol className="nodedc-admin-breadcrumbs">
<BreadcrumbLink href="/general/" label="Настройки" icon={<Settings className="h-4 w-4" />} />
{breadcrumbItems.map((item, index) => {
if (!item.title) return null;
const isCurrent = index === breadcrumbItems.length - 1;
<nav className="nodedc-admin-top-nav justify-self-center" aria-label="Основная навигация God Mode">
{PRIMARY_NAVIGATION.map((item) => {
const isActive = item.href === pathName || Boolean(pathName?.startsWith(item.href));
return ( return (
<Link key={item.href} href={item.href} className="nodedc-admin-top-nav-item" data-active={isActive}> <Fragment key={`${item.href}-${item.title}`}>
<item.Icon className="size-3.5 stroke-[1.7]" /> <li className="nodedc-admin-breadcrumb-separator" aria-hidden="true">
<span>{item.label}</span> <ChevronRight className="size-4" />
</Link> </li>
<BreadcrumbLink href={item.href} label={item.title} isCurrent={isCurrent} />
</Fragment>
); );
})} })}
</ol>
<Menu as="div" className="relative">
<Menu.Button className="nodedc-admin-top-nav-item" data-active={isFeatureRoute}>
<BrainCog className="size-3.5 stroke-[1.7]" />
<span>Возможности</span>
<ChevronDown className="size-3 stroke-[2]" />
</Menu.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="nodedc-glass-popup-surface absolute top-full left-1/2 z-[120] mt-3 flex w-64 -translate-x-1/2 flex-col gap-1 p-2 outline-none">
{FEATURE_NAVIGATION.map((item) => {
const isActive = item.href === pathName || Boolean(pathName?.startsWith(item.href));
return (
<Menu.Item key={item.href}>
{({ active }) => (
<Link
href={item.href}
className={cn("nodedc-admin-feature-menu-item", {
"is-active": isActive,
"is-hovered": active,
})}
>
<span className="grid size-9 place-items-center rounded-full bg-white/6">
<item.Icon className="size-4 stroke-[1.7]" />
</span>
<span className="min-w-0">
<span className="block truncate text-13 font-medium text-primary">{item.label}</span>
<span className="block truncate text-11 text-tertiary">{item.description}</span>
</span>
</Link>
)}
</Menu.Item>
);
})}
</Menu.Items>
</Transition>
</Menu>
<a href="/" className="nodedc-admin-top-nav-item">
<ExternalLink className="size-3.5 stroke-[1.7]" />
<span>В приложение</span>
</a>
</nav> </nav>
<Menu as="div" className="relative justify-self-end">
<Menu.Button className="nodedc-admin-user-button">
<span className="min-w-0 text-right">
<span className="block max-w-40 truncate text-14 font-medium text-primary">{adminName}</span>
</span>
<span className="grid size-10 place-items-center rounded-full bg-white/7">
{currentUser ? (
<Avatar
name={avatarName}
src={getFileURL(currentUser.avatar_url)}
size={32}
shape="circle"
className="!text-body-sm-medium"
/>
) : (
<UserCog2 className="size-5 text-[rgb(var(--nodedc-card-active-rgb))]" />
)} )}
</span>
</Menu.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="nodedc-glass-popup-surface absolute top-full right-0 z-[120] mt-3 flex w-60 flex-col divide-y divide-white/6 p-2 text-12 outline-none">
<div className="flex flex-col gap-1 px-2 pb-2">
<span className="truncate text-13 font-medium text-primary">{adminName}</span>
<span className="truncate text-11 text-tertiary">{currentUser?.email}</span>
</div> </div>
<div className="py-2">
<Menu.Item as="button" type="button" className="nodedc-admin-menu-action" onClick={handleThemeSwitch}>
<Palette className="h-4 w-4 stroke-[1.5]" />
{resolvedTheme === "dark" ? "Светлая тема" : "Темная тема"}
</Menu.Item>
</div> </div>
<div className="py-2">
<form method="POST" action={`${API_BASE_URL}/api/instances/admins/sign-out/`} onSubmit={signOut}>
<input type="hidden" name="csrfmiddlewaretoken" value={csrfToken} />
<Menu.Item as="button" type="submit" className="nodedc-admin-menu-action">
<LogOut className="h-4 w-4 stroke-[1.5]" />
Выйти
</Menu.Item>
</form>
</div>
</Menu.Items>
</Transition>
</Menu>
</div>
</header>
); );
}); });

View File

@ -126,7 +126,8 @@ body {
.nodedc-technical-confirm-modal { .nodedc-technical-confirm-modal {
background: background:
linear-gradient(180deg, rgba(255, 255, 255, 0.048) 0%, rgba(255, 255, 255, 0.014) 100%), rgba(6, 6, 8, 0.76) !important; linear-gradient(180deg, rgba(255, 255, 255, 0.048) 0%, rgba(255, 255, 255, 0.014) 100%),
rgba(6, 6, 8, 0.76) !important;
-webkit-backdrop-filter: blur(54px) saturate(130%); -webkit-backdrop-filter: blur(54px) saturate(130%);
backdrop-filter: blur(54px) saturate(130%); backdrop-filter: blur(54px) saturate(130%);
box-shadow: box-shadow:
@ -150,7 +151,8 @@ body {
border: 0 !important; border: 0 !important;
border-radius: 1.25rem !important; border-radius: 1.25rem !important;
background: background:
linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.012) 100%), rgba(8, 8, 11, 0.92) !important; linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.012) 100%),
rgba(8, 8, 11, 0.92) !important;
padding: 0.75rem !important; padding: 0.75rem !important;
-webkit-backdrop-filter: blur(44px); -webkit-backdrop-filter: blur(44px);
backdrop-filter: blur(44px); backdrop-filter: blur(44px);
@ -181,133 +183,11 @@ body {
} }
.nodedc-admin-header { .nodedc-admin-header {
min-height: 5.75rem; min-height: 4.25rem;
border: 0 !important; border: 0 !important;
background: transparent !important; border-radius: 0 0 1.35rem 1.35rem;
padding: 1.75rem clamp(1.25rem, 3vw, 2.75rem) 0.5rem !important; margin: 0.65rem 0.75rem 0;
} width: calc(100% - 1.5rem) !important;
.nodedc-admin-header-top {
grid-template-columns: minmax(11rem, 1fr) auto minmax(11rem, 1fr);
}
.nodedc-admin-logo {
display: block;
width: 9.25rem;
height: auto;
}
.nodedc-admin-top-nav {
display: inline-flex;
max-width: min(100%, 48rem);
align-items: center;
justify-content: center;
gap: 0.35rem;
border-radius: 999px;
background: transparent !important;
padding: 0;
-webkit-backdrop-filter: none;
backdrop-filter: none;
}
.nodedc-admin-top-nav-item {
display: inline-flex;
min-height: 2.35rem;
align-items: center;
justify-content: center;
gap: 0.45rem;
border: 0 !important;
border-radius: 999px !important;
background: transparent !important;
color: rgba(255, 255, 255, 0.72) !important;
padding: 0.55rem 0.85rem !important;
font-size: 0.75rem;
font-weight: 600;
line-height: 1;
white-space: nowrap;
box-shadow: none !important;
transition:
background-color 160ms ease,
color 160ms ease,
transform 160ms ease;
}
.nodedc-admin-top-nav-item:hover {
background: rgba(255, 255, 255, 0.045) !important;
color: var(--text-color-primary) !important;
}
.nodedc-admin-top-nav-item[data-active="true"] {
background: rgba(255, 255, 255, 0.94) !important;
color: #08090b !important;
}
.nodedc-admin-top-nav-item[data-active="true"] * {
color: #08090b !important;
}
.nodedc-admin-feature-menu-item {
display: flex;
width: 100%;
align-items: center;
gap: 0.75rem;
border-radius: 1rem;
padding: 0.65rem;
color: rgba(255, 255, 255, 0.74);
transition:
background-color 160ms ease,
color 160ms ease;
}
.nodedc-admin-feature-menu-item.is-hovered,
.nodedc-admin-feature-menu-item:hover {
background: rgba(255, 255, 255, 0.06);
color: var(--text-color-primary);
}
.nodedc-admin-feature-menu-item.is-active {
background: rgba(255, 255, 255, 0.09);
}
.nodedc-admin-feature-menu-item.is-active svg {
color: rgb(var(--nodedc-accent-rgb));
}
.nodedc-admin-user-button {
display: inline-flex;
min-width: 0;
align-items: center;
justify-content: flex-end;
gap: 0.75rem;
border: 0 !important;
border-radius: 999px !important;
background: transparent !important;
padding: 0 !important;
box-shadow: none !important;
}
.nodedc-admin-user-button:hover > span:last-child {
background: rgba(255, 255, 255, 0.11) !important;
}
.nodedc-admin-menu-action {
display: flex;
min-height: 2.35rem;
width: 100%;
align-items: center;
gap: 0.55rem;
border-radius: 0.9rem !important;
padding: 0.55rem 0.65rem !important;
color: rgba(255, 255, 255, 0.72) !important;
text-align: left;
transition:
background-color 160ms ease,
color 160ms ease;
}
.nodedc-admin-menu-action:hover {
background: rgba(255, 255, 255, 0.06) !important;
color: var(--text-color-primary) !important;
} }
.nodedc-admin-breadcrumbs { .nodedc-admin-breadcrumbs {
@ -318,13 +198,14 @@ body {
} }
.nodedc-admin-breadcrumb-pill { .nodedc-admin-breadcrumb-pill {
min-height: 2.2rem; min-height: 2.5rem;
border: 0 !important; border: 0 !important;
border-radius: 999px !important; border-radius: 1.25rem !important;
background: background:
linear-gradient(180deg, rgba(255, 255, 255, 0.036) 0%, rgba(255, 255, 255, 0.014) 100%), rgba(255, 255, 255, 0.04) !important; linear-gradient(180deg, rgba(255, 255, 255, 0.036) 0%, rgba(255, 255, 255, 0.014) 100%),
rgba(255, 255, 255, 0.04) !important;
color: rgba(255, 255, 255, 0.72) !important; color: rgba(255, 255, 255, 0.72) !important;
padding: 0.5rem 0.8rem !important; padding: 0.55rem 0.9rem !important;
box-shadow: none !important; box-shadow: none !important;
transition: transition:
background-color 160ms ease, background-color 160ms ease,
@ -333,7 +214,8 @@ body {
.nodedc-admin-breadcrumb-pill:hover { .nodedc-admin-breadcrumb-pill:hover {
background: background:
linear-gradient(180deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.018) 100%), rgba(255, 255, 255, 0.065) !important; linear-gradient(180deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.018) 100%),
rgba(255, 255, 255, 0.065) !important;
color: var(--text-color-primary) !important; color: var(--text-color-primary) !important;
} }
@ -350,7 +232,7 @@ body {
} }
.nodedc-page { .nodedc-page {
padding: 0.5rem 0 1.5rem; padding: 1rem 0 1.5rem;
} }
.nodedc-page-header { .nodedc-page-header {
@ -386,7 +268,8 @@ body {
border: 0 !important; border: 0 !important;
border-radius: 1.35rem !important; border-radius: 1.35rem !important;
background: background:
linear-gradient(180deg, rgba(255, 255, 255, 0.044) 0%, rgba(255, 255, 255, 0.016) 100%), rgba(255, 255, 255, 0.04) !important; linear-gradient(180deg, rgba(255, 255, 255, 0.044) 0%, rgba(255, 255, 255, 0.016) 100%),
rgba(255, 255, 255, 0.04) !important;
box-shadow: none !important; box-shadow: none !important;
-webkit-backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px);
backdrop-filter: blur(18px); backdrop-filter: blur(18px);
@ -396,7 +279,8 @@ body {
border: 0 !important; border: 0 !important;
border-radius: 0.65rem !important; border-radius: 0.65rem !important;
background: background:
linear-gradient(180deg, rgba(255, 255, 255, 0.04) 0%, rgba(255, 255, 255, 0.014) 100%), rgba(0, 0, 0, 0.32) !important; linear-gradient(180deg, rgba(255, 255, 255, 0.04) 0%, rgba(255, 255, 255, 0.014) 100%),
rgba(0, 0, 0, 0.32) !important;
color: rgb(var(--nodedc-accent-rgb)) !important; color: rgb(var(--nodedc-accent-rgb)) !important;
box-shadow: none !important; box-shadow: none !important;
} }
@ -451,7 +335,8 @@ body {
.nodedc-admin-sidebar-action:hover { .nodedc-admin-sidebar-action:hover {
background: background:
linear-gradient(180deg, rgba(255, 255, 255, 0.052) 0%, rgba(255, 255, 255, 0.018) 100%), rgba(255, 255, 255, 0.07) !important; linear-gradient(180deg, rgba(255, 255, 255, 0.052) 0%, rgba(255, 255, 255, 0.018) 100%),
rgba(255, 255, 255, 0.07) !important;
color: var(--text-color-primary) !important; color: var(--text-color-primary) !important;
} }
@ -605,7 +490,8 @@ body {
border: 0 !important; border: 0 !important;
border-radius: 1.25rem !important; border-radius: 1.25rem !important;
background: background:
linear-gradient(180deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.022) 100%), rgba(255, 255, 255, 0.055) !important; linear-gradient(180deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.022) 100%),
rgba(255, 255, 255, 0.055) !important;
color: rgba(255, 255, 255, 0.76) !important; color: rgba(255, 255, 255, 0.76) !important;
box-shadow: none !important; box-shadow: none !important;
padding-inline: 1.25rem !important; padding-inline: 1.25rem !important;
@ -682,41 +568,4 @@ body {
background: rgba(255, 255, 255, 0.08) !important; background: rgba(255, 255, 255, 0.08) !important;
color: rgb(var(--nodedc-card-active-rgb)) !important; color: rgb(var(--nodedc-card-active-rgb)) !important;
} }
@media (max-width: 1180px) {
.nodedc-admin-header {
min-height: 8.25rem;
}
.nodedc-admin-header-top {
grid-template-columns: 1fr auto;
}
.nodedc-admin-top-nav {
grid-column: 1 / -1;
grid-row: 2;
justify-self: start;
overflow-x: auto;
}
}
@media (max-width: 720px) {
.nodedc-admin-header {
min-height: 10.5rem;
padding-inline: 1rem !important;
}
.nodedc-admin-header-top {
grid-template-columns: 1fr;
}
.nodedc-admin-top-nav {
width: 100%;
justify-content: flex-start;
}
.nodedc-admin-user-button {
justify-self: start;
}
}
} }

View File

@ -45,7 +45,7 @@ urlpatterns = [
), ),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/<uuid:request_id>/", "workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/<uuid:request_id>/",
ExternalContourDetailEndpoint.as_view(http_method_names=["get", "patch", "delete"]), ExternalContourDetailEndpoint.as_view(http_method_names=["get", "patch"]),
name="external-contour-detail", name="external-contour-detail",
), ),
path( path(

View File

@ -21,7 +21,6 @@ from plane.api.serializers import (
) )
from plane.api.serializers.issue import IssueSerializer as IssueCreateSerializer from plane.api.serializers.issue import IssueSerializer as IssueCreateSerializer
from plane.app.permissions import ProjectLitePermission from plane.app.permissions import ProjectLitePermission
from plane.app.realtime.issue_events import publish_external_contour_event_on_commit, publish_issue_event_on_commit
from plane.app.views.base import BaseAPIView from plane.app.views.base import BaseAPIView
from plane.db.models import FileAsset, Intake, IntakeIssue, Label, Notification, Project, ProjectMember, State, StateGroup from plane.db.models import FileAsset, Intake, IntakeIssue, Label, Notification, Project, ProjectMember, State, StateGroup
from plane.db.models.intake import IntakeIssueStatus, SourceType from plane.db.models.intake import IntakeIssueStatus, SourceType
@ -172,29 +171,6 @@ class ExternalContourListCreateEndpoint(BaseAPIView):
issue.state = target_default_state issue.state = target_default_state
issue.save() issue.save()
publish_issue_event_on_commit(
"issue.created",
issue,
actor_id=request.user.id,
changed_fields=[
"name",
"description_html",
"priority",
"assignees",
"labels",
"target_date",
"state",
"external_contour",
],
publish_external_bridge=False,
)
publish_external_contour_event_on_commit(
"external_contour.created",
intake_issue,
actor_id=request.user.id,
changed_fields=["issue", "state", "external_contour"],
)
response_serializer = ExternalContourRequestSerializer( response_serializer = ExternalContourRequestSerializer(
IntakeIssue.objects.select_related( IntakeIssue.objects.select_related(
"issue", "issue",
@ -355,7 +331,6 @@ class ExternalContourDetailEndpoint(BaseAPIView):
serializer = ExternalContourRequestUpdateSerializer(data=request.data) serializer = ExternalContourRequestUpdateSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
issue_update_data = serializer.validated_data.copy() issue_update_data = serializer.validated_data.copy()
changed_fields = list(issue_update_data.keys())
assignee_ids = issue_update_data.pop("assignee_ids", None) assignee_ids = issue_update_data.pop("assignee_ids", None)
label_ids = issue_update_data.pop("label_ids", None) label_ids = issue_update_data.pop("label_ids", None)
if assignee_ids is not None: if assignee_ids is not None:
@ -373,24 +348,11 @@ class ExternalContourDetailEndpoint(BaseAPIView):
}, },
) )
issue_serializer.is_valid(raise_exception=True) issue_serializer.is_valid(raise_exception=True)
updated_issue = issue_serializer.save() issue_serializer.save()
publish_issue_event_on_commit(
"issue.updated",
updated_issue,
actor_id=request.user.id,
changed_fields=changed_fields,
publish_external_bridge=False,
)
contour_request.updated_at = timezone.now() contour_request.updated_at = timezone.now()
contour_request.save(update_fields=["updated_at"]) contour_request.save(update_fields=["updated_at"])
contour_request.refresh_from_db() contour_request.refresh_from_db()
publish_external_contour_event_on_commit(
"external_contour.updated",
contour_request,
actor_id=request.user.id,
changed_fields=changed_fields,
)
response_serializer = ExternalContourRequestSerializer( response_serializer = ExternalContourRequestSerializer(
contour_request, contour_request,
@ -403,37 +365,6 @@ class ExternalContourDetailEndpoint(BaseAPIView):
) )
return Response(response_serializer.data, status=status.HTTP_200_OK) return Response(response_serializer.data, status=status.HTTP_200_OK)
def delete(self, request, slug, project_id, request_id):
contour_request = get_object_or_404(self.get_queryset())
issue = contour_request.issue
if not issue:
contour_request.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
requested_by_id = contour_request.extra.get("requested_by_id") or (
str(issue.created_by_id) if issue.created_by_id else None
)
if str(request.user.id) != str(requested_by_id):
return Response({"error": "Only the sender can delete this request"}, status=status.HTTP_403_FORBIDDEN)
publish_external_contour_event_on_commit(
"external_contour.deleted",
contour_request,
actor_id=request.user.id,
changed_fields=["deleted_at"],
)
publish_issue_event_on_commit(
"issue.deleted",
issue,
actor_id=request.user.id,
changed_fields=["deleted_at", "external_contour"],
publish_external_bridge=False,
)
contour_request.delete()
issue.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
class ExternalContourDecisionEndpoint(BaseAPIView): class ExternalContourDecisionEndpoint(BaseAPIView):
permission_classes = [ProjectLitePermission] permission_classes = [ProjectLitePermission]
@ -503,13 +434,6 @@ class ExternalContourDecisionEndpoint(BaseAPIView):
issue.state = target_default_state issue.state = target_default_state
issue.save(update_fields=["state", "updated_at"]) issue.save(update_fields=["state", "updated_at"])
publish_issue_event_on_commit(
"issue.updated",
issue,
actor_id=request.user.id,
changed_fields=["state", "external_contour_reopen"],
publish_external_bridge=False,
)
extra = dict(contour_request.extra or {}) extra = dict(contour_request.extra or {})
extra.pop("source_decision", None) extra.pop("source_decision", None)
@ -521,13 +445,6 @@ class ExternalContourDecisionEndpoint(BaseAPIView):
contour_request.extra = extra contour_request.extra = extra
contour_request.save(update_fields=["extra", "updated_at"]) contour_request.save(update_fields=["extra", "updated_at"])
publish_external_contour_event_on_commit(
"external_contour.updated",
contour_request,
actor_id=request.user.id,
changed_fields=["source_decision", "state"],
)
contour_request.refresh_from_db() contour_request.refresh_from_db()
Notification.objects.filter( Notification.objects.filter(
receiver_id=request.user.id, receiver_id=request.user.id,

View File

@ -1,3 +0,0 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.

View File

@ -1,137 +0,0 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
import json
import logging
from uuid import uuid4
from django.core.serializers.json import DjangoJSONEncoder
from django.db import transaction
from django.utils import timezone
from plane.settings.redis import redis_instance
logger = logging.getLogger(__name__)
ISSUE_EVENT_CHANNEL_PREFIX = "plane:issue-events:project"
def issue_event_channel(project_id):
return f"{ISSUE_EVENT_CHANNEL_PREFIX}:{project_id}"
def _publish_payload(project_id, payload):
next_payload = {
**payload,
"project_id": str(project_id),
}
redis_instance().publish(
issue_event_channel(project_id),
json.dumps(next_payload, cls=DjangoJSONEncoder),
)
def _external_contour_project_ids(contour_request):
extra = contour_request.extra or {}
project_ids = {
str(project_id)
for project_id in [
extra.get("source_project_id"),
extra.get("target_project_id"),
contour_request.project_id,
getattr(contour_request.issue, "project_id", None),
]
if project_id
}
return sorted(project_ids)
def publish_external_contour_event_on_commit(event_type, contour_request, actor_id=None, changed_fields=None):
issue = contour_request.issue
extra = contour_request.extra or {}
payload = {
"event_id": str(uuid4()),
"type": event_type,
"workspace_id": str(contour_request.workspace_id),
"workspace_slug": contour_request.workspace.slug if getattr(contour_request, "workspace", None) else None,
"request_id": str(contour_request.id),
"issue_id": str(issue.id) if issue else None,
"sequence_id": issue.sequence_id if issue else None,
"source_project_id": str(extra.get("source_project_id")) if extra.get("source_project_id") else None,
"target_project_id": str(extra.get("target_project_id") or contour_request.project_id),
"updated_at": contour_request.updated_at or timezone.now(),
"actor_id": str(actor_id) if actor_id else None,
"changed_fields": sorted(set(changed_fields or [])),
}
def _publish():
try:
for project_id in _external_contour_project_ids(contour_request):
_publish_payload(project_id, payload)
except Exception:
logger.exception("Failed to publish external contour realtime event")
transaction.on_commit(_publish)
def publish_external_contour_issue_event_on_commit(event_type, issue, actor_id=None, changed_fields=None):
def _publish():
try:
from plane.db.models import IntakeIssue
contour_requests = (
IntakeIssue.objects.filter(issue_id=issue.id, extra__bridge="external-contours")
.select_related("issue", "workspace")
.only("id", "workspace_id", "workspace__slug", "project_id", "issue_id", "extra", "updated_at")
)
for contour_request in contour_requests:
event_name = "external_contour.deleted" if event_type == "issue.deleted" else "external_contour.updated"
payload = {
"event_id": str(uuid4()),
"type": event_name,
"workspace_id": str(contour_request.workspace_id),
"workspace_slug": contour_request.workspace.slug if getattr(contour_request, "workspace", None) else None,
"request_id": str(contour_request.id),
"issue_id": str(issue.id),
"sequence_id": issue.sequence_id,
"source_project_id": str(contour_request.extra.get("source_project_id"))
if contour_request.extra.get("source_project_id")
else None,
"target_project_id": str(contour_request.extra.get("target_project_id") or contour_request.project_id),
"updated_at": issue.updated_at or timezone.now(),
"actor_id": str(actor_id) if actor_id else None,
"changed_fields": sorted(set(changed_fields or [])),
}
for project_id in _external_contour_project_ids(contour_request):
_publish_payload(project_id, payload)
except Exception:
logger.exception("Failed to publish external contour bridge event")
transaction.on_commit(_publish)
def publish_issue_event_on_commit(event_type, issue, actor_id=None, changed_fields=None, publish_external_bridge=True):
payload = {
"event_id": str(uuid4()),
"type": event_type,
"workspace_id": str(issue.workspace_id),
"workspace_slug": issue.workspace.slug if getattr(issue, "workspace", None) else None,
"project_id": str(issue.project_id),
"issue_id": str(issue.id),
"sequence_id": issue.sequence_id,
"updated_at": issue.updated_at or timezone.now(),
"actor_id": str(actor_id) if actor_id else None,
"changed_fields": sorted(set(changed_fields or [])),
}
def _publish():
try:
_publish_payload(issue.project_id, payload)
except Exception:
logger.exception("Failed to publish issue realtime event")
transaction.on_commit(_publish)
if publish_external_bridge:
publish_external_contour_issue_event_on_commit(event_type, issue, actor_id=actor_id, changed_fields=changed_fields)

View File

@ -45,7 +45,7 @@ urlpatterns = [
), ),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/<uuid:request_id>/", "workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/<uuid:request_id>/",
ExternalContourDetailEndpoint.as_view(http_method_names=["get", "patch", "delete"]), ExternalContourDetailEndpoint.as_view(http_method_names=["get", "patch"]),
name="external-contour-detail", name="external-contour-detail",
), ),
path( path(

View File

@ -23,7 +23,6 @@ from plane.api.serializers import (
) )
from plane.api.serializers.issue import IssueSerializer as IssueCreateSerializer from plane.api.serializers.issue import IssueSerializer as IssueCreateSerializer
from plane.app.permissions import ProjectLitePermission from plane.app.permissions import ProjectLitePermission
from plane.app.realtime.issue_events import publish_external_contour_event_on_commit, publish_issue_event_on_commit
from plane.app.views.base import BaseAPIView from plane.app.views.base import BaseAPIView
from plane.db.models import FileAsset, Intake, IntakeIssue, Label, Notification, Project, ProjectMember, State, StateGroup from plane.db.models import FileAsset, Intake, IntakeIssue, Label, Notification, Project, ProjectMember, State, StateGroup
from plane.db.models.intake import IntakeIssueStatus, SourceType from plane.db.models.intake import IntakeIssueStatus, SourceType
@ -174,29 +173,6 @@ class ExternalContourListCreateEndpoint(BaseAPIView):
issue.state = target_default_state issue.state = target_default_state
issue.save() issue.save()
publish_issue_event_on_commit(
"issue.created",
issue,
actor_id=request.user.id,
changed_fields=[
"name",
"description_html",
"priority",
"assignees",
"labels",
"target_date",
"state",
"external_contour",
],
publish_external_bridge=False,
)
publish_external_contour_event_on_commit(
"external_contour.created",
intake_issue,
actor_id=request.user.id,
changed_fields=["issue", "state", "external_contour"],
)
response_serializer = ExternalContourRequestSerializer( response_serializer = ExternalContourRequestSerializer(
IntakeIssue.objects.select_related( IntakeIssue.objects.select_related(
"issue", "issue",
@ -674,7 +650,6 @@ class ExternalContourDetailEndpoint(BaseAPIView):
serializer = ExternalContourRequestUpdateSerializer(data=request.data) serializer = ExternalContourRequestUpdateSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
issue_update_data = serializer.validated_data.copy() issue_update_data = serializer.validated_data.copy()
changed_fields = list(issue_update_data.keys())
assignee_ids = issue_update_data.pop("assignee_ids", None) assignee_ids = issue_update_data.pop("assignee_ids", None)
label_ids = issue_update_data.pop("label_ids", None) label_ids = issue_update_data.pop("label_ids", None)
if assignee_ids is not None: if assignee_ids is not None:
@ -692,24 +667,11 @@ class ExternalContourDetailEndpoint(BaseAPIView):
}, },
) )
issue_serializer.is_valid(raise_exception=True) issue_serializer.is_valid(raise_exception=True)
updated_issue = issue_serializer.save() issue_serializer.save()
contour_request.updated_at = timezone.now() contour_request.updated_at = timezone.now()
contour_request.save(update_fields=["updated_at"]) contour_request.save(update_fields=["updated_at"])
contour_request.refresh_from_db() contour_request.refresh_from_db()
publish_issue_event_on_commit(
"issue.updated",
updated_issue,
actor_id=request.user.id,
changed_fields=changed_fields,
publish_external_bridge=False,
)
publish_external_contour_event_on_commit(
"external_contour.updated",
contour_request,
actor_id=request.user.id,
changed_fields=changed_fields,
)
response_serializer = ExternalContourRequestSerializer( response_serializer = ExternalContourRequestSerializer(
contour_request, contour_request,
@ -722,37 +684,6 @@ class ExternalContourDetailEndpoint(BaseAPIView):
) )
return Response(response_serializer.data, status=status.HTTP_200_OK) return Response(response_serializer.data, status=status.HTTP_200_OK)
def delete(self, request, slug, project_id, request_id):
contour_request = get_object_or_404(self.get_queryset())
issue = contour_request.issue
if not issue:
contour_request.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
requested_by_id = contour_request.extra.get("requested_by_id") or (
str(issue.created_by_id) if issue.created_by_id else None
)
if str(request.user.id) != str(requested_by_id):
return Response({"error": "Only the sender can delete this request"}, status=status.HTTP_403_FORBIDDEN)
publish_external_contour_event_on_commit(
"external_contour.deleted",
contour_request,
actor_id=request.user.id,
changed_fields=["deleted_at"],
)
publish_issue_event_on_commit(
"issue.deleted",
issue,
actor_id=request.user.id,
changed_fields=["deleted_at", "external_contour"],
publish_external_bridge=False,
)
contour_request.delete()
issue.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
class ExternalContourDecisionEndpoint(BaseAPIView): class ExternalContourDecisionEndpoint(BaseAPIView):
permission_classes = [ProjectLitePermission] permission_classes = [ProjectLitePermission]
@ -822,13 +753,6 @@ class ExternalContourDecisionEndpoint(BaseAPIView):
issue.state = target_default_state issue.state = target_default_state
issue.save(update_fields=["state", "updated_at"]) issue.save(update_fields=["state", "updated_at"])
publish_issue_event_on_commit(
"issue.updated",
issue,
actor_id=request.user.id,
changed_fields=["state", "external_contour_reopen"],
publish_external_bridge=False,
)
extra = dict(contour_request.extra or {}) extra = dict(contour_request.extra or {})
extra.pop("source_decision", None) extra.pop("source_decision", None)
@ -840,13 +764,6 @@ class ExternalContourDecisionEndpoint(BaseAPIView):
contour_request.extra = extra contour_request.extra = extra
contour_request.save(update_fields=["extra", "updated_at"]) contour_request.save(update_fields=["extra", "updated_at"])
publish_external_contour_event_on_commit(
"external_contour.updated",
contour_request,
actor_id=request.user.id,
changed_fields=["source_decision", "state"],
)
contour_request.refresh_from_db() contour_request.refresh_from_db()
Notification.objects.filter( Notification.objects.filter(
receiver_id=request.user.id, receiver_id=request.user.id,
@ -914,12 +831,6 @@ class ExternalContourReplyEndpoint(BaseAPIView):
return Response({"error": exc.message_dict if hasattr(exc, "message_dict") else str(exc)}, status=status.HTTP_400_BAD_REQUEST) return Response({"error": exc.message_dict if hasattr(exc, "message_dict") else str(exc)}, status=status.HTTP_400_BAD_REQUEST)
contour_request.refresh_from_db() contour_request.refresh_from_db()
publish_external_contour_event_on_commit(
"external_contour.updated",
contour_request,
actor_id=request.user.id,
changed_fields=["comments"],
)
Notification.objects.filter( Notification.objects.filter(
receiver_id=request.user.id, receiver_id=request.user.id,
sender__startswith="in_app:external_contours:", sender__startswith="in_app:external_contours:",

View File

@ -33,7 +33,6 @@ from rest_framework.response import Response
# Module imports # Module imports
from plane.app.permissions import ROLE, allow_permission from plane.app.permissions import ROLE, allow_permission
from plane.app.realtime.issue_events import publish_issue_event_on_commit
from plane.app.serializers import ( from plane.app.serializers import (
IssueCreateSerializer, IssueCreateSerializer,
IssueDetailSerializer, IssueDetailSerializer,
@ -430,7 +429,7 @@ class IssueViewSet(BaseViewSet):
) )
if serializer.is_valid(): if serializer.is_valid():
issue_instance = serializer.save() serializer.save()
# Track the issue # Track the issue
issue_activity.delay( issue_activity.delay(
@ -503,12 +502,6 @@ class IssueViewSet(BaseViewSet):
user_id=request.user.id, user_id=request.user.id,
is_creating=True, is_creating=True,
) )
publish_issue_event_on_commit(
"issue.created",
issue_instance,
actor_id=request.user.id,
changed_fields=request.data.keys(),
)
return Response(issue, status=status.HTTP_201_CREATED) return Response(issue, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@ -702,7 +695,7 @@ class IssueViewSet(BaseViewSet):
requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder) requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder)
serializer = IssueCreateSerializer(issue, data=request.data, partial=True, context={"project_id": project_id}) serializer = IssueCreateSerializer(issue, data=request.data, partial=True, context={"project_id": project_id})
if serializer.is_valid(): if serializer.is_valid():
updated_issue = serializer.save() serializer.save()
# Check if the update is a migration description update # Check if the update is a migration description update
is_migration_description_update = skip_activity and is_description_update is_migration_description_update = skip_activity and is_description_update
# Log all the updates # Log all the updates
@ -733,18 +726,12 @@ class IssueViewSet(BaseViewSet):
issue_id=str(serializer.data.get("id", None)), issue_id=str(serializer.data.get("id", None)),
user_id=request.user.id, user_id=request.user.id,
) )
publish_issue_event_on_commit(
"issue.updated",
updated_issue,
actor_id=request.user.id,
changed_fields=request.data.keys(),
)
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@allow_permission([ROLE.ADMIN], creator=True, model=Issue) @allow_permission([ROLE.ADMIN], creator=True, model=Issue)
def destroy(self, request, slug, project_id, pk=None): def destroy(self, request, slug, project_id, pk=None):
issue = Issue.objects.select_related("workspace").get(workspace__slug=slug, project_id=project_id, pk=pk) issue = Issue.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
issue.delete() issue.delete()
# delete the issue from recent visits # delete the issue from recent visits
@ -766,12 +753,6 @@ class IssueViewSet(BaseViewSet):
origin=base_host(request=request, is_app=True), origin=base_host(request=request, is_app=True),
subscriber=False, subscriber=False,
) )
publish_issue_event_on_commit(
"issue.deleted",
issue,
actor_id=request.user.id,
changed_fields=["deleted_at"],
)
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)

View File

@ -7,13 +7,6 @@
import { CollaborationController } from "./collaboration.controller"; import { CollaborationController } from "./collaboration.controller";
import { DocumentController } from "./document.controller"; import { DocumentController } from "./document.controller";
import { HealthController } from "./health.controller"; import { HealthController } from "./health.controller";
import { IssueStreamController } from "./issue-stream.controller";
import { PdfExportController } from "./pdf-export.controller"; import { PdfExportController } from "./pdf-export.controller";
export const CONTROLLERS = [ export const CONTROLLERS = [CollaborationController, DocumentController, HealthController, PdfExportController];
CollaborationController,
DocumentController,
HealthController,
IssueStreamController,
PdfExportController,
];

View File

@ -1,143 +0,0 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { Request } from "express";
import type Redis from "ioredis";
import type { WebSocket as WSSocket } from "ws";
// plane imports
import { Controller, WebSocket as WSDecorator } from "@plane/decorators";
import { logger } from "@plane/logger";
// redis
import { redisManager } from "@/redis";
// services
import { ProjectMemberService } from "@/services/project-member.service";
import { UserService } from "@/services/user.service";
const ISSUE_EVENT_CHANNEL_PREFIX = "plane:issue-events:project";
const HEARTBEAT_INTERVAL_MS = 25_000;
type TIssueRealtimeEvent = {
event_id?: string;
type?: string;
project_id?: string;
};
const getQueryValue = (value: unknown) => (typeof value === "string" && value.trim() ? value.trim() : undefined);
const sendJson = (ws: WSSocket, payload: Record<string, unknown>) => {
if (ws.readyState !== 1) return;
ws.send(JSON.stringify(payload));
};
@Controller("/issues")
export class IssueStreamController {
[key: string]: unknown;
@WSDecorator("/stream")
handleConnection(ws: WSSocket, req: Request) {
void this.handleIssueStream(ws, req);
}
private async handleIssueStream(ws: WSSocket, req: Request) {
const workspaceSlug = getQueryValue(req.query.workspaceSlug);
const projectId = getQueryValue(req.query.projectId);
const cookie = req.headers.cookie?.toString();
if (!workspaceSlug || !projectId || !cookie) {
ws.close(1008, "Missing issue stream credentials");
return;
}
let subscriber: Redis | undefined;
let heartbeat: NodeJS.Timeout | undefined;
const cleanup = async () => {
if (heartbeat) clearInterval(heartbeat);
if (subscriber) {
try {
await subscriber.unsubscribe();
subscriber.disconnect();
} catch (error) {
logger.error("ISSUE_STREAM_CONTROLLER: Redis cleanup failed:", error);
}
}
};
try {
const userService = new UserService();
const projectMemberService = new ProjectMemberService();
const user = await userService.currentUser(cookie);
await projectMemberService.currentProjectMember(cookie, workspaceSlug, projectId);
const redisClient = redisManager.getClient();
if (!redisClient) {
ws.close(1011, "Issue stream unavailable");
return;
}
const channel = `${ISSUE_EVENT_CHANNEL_PREFIX}:${projectId}`;
subscriber = redisClient.duplicate({ lazyConnect: true });
await subscriber.connect();
await subscriber.subscribe(channel);
subscriber.on("message", (_channel, message) => {
try {
const event = JSON.parse(message) as TIssueRealtimeEvent;
if (
event.project_id !== projectId ||
(!event.type?.startsWith("issue.") && !event.type?.startsWith("external_contour."))
)
return;
sendJson(ws, event as Record<string, unknown>);
} catch (error) {
logger.error("ISSUE_STREAM_CONTROLLER: Failed to forward issue event:", error);
}
});
subscriber.on("error", (error) => {
logger.error("ISSUE_STREAM_CONTROLLER: Redis subscriber error:", error);
ws.close(1011, "Issue stream subscriber failed");
});
heartbeat = setInterval(() => {
sendJson(ws, { type: "issue.stream.ping", server_ts: new Date().toISOString() });
}, HEARTBEAT_INTERVAL_MS);
sendJson(ws, {
type: "issue.stream.ready",
project_id: projectId,
user_id: user.id,
});
} catch (error) {
logger.error("ISSUE_STREAM_CONTROLLER: WebSocket authentication failed:", error);
ws.close(1008, "Issue stream authentication failed");
await cleanup();
return;
}
ws.on("message", (message) => {
try {
const payload = JSON.parse(message.toString()) as { type?: string };
if (payload.type === "issue.stream.pong") return;
} catch {
// Client messages are optional for this stream.
}
});
ws.on("close", () => {
void cleanup();
});
ws.on("error", (error: Error) => {
logger.error("ISSUE_STREAM_CONTROLLER: WebSocket connection error:", error);
ws.close(1011, "Issue stream connection failed");
void cleanup();
});
}
}

View File

@ -1,18 +0,0 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
// services
import { APIService } from "@/services/api.service";
export class ProjectMemberService extends APIService {
async currentProjectMember(cookie: string, workspaceSlug: string, projectId: string) {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/project-members/me/`, {
headers: {
Cookie: cookie,
},
}).then((response) => response?.data);
}
}

View File

@ -6,28 +6,29 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { Archive, CalendarDays, Copy, MoreHorizontal, Pencil, Trash2 } from "lucide-react"; import { CalendarDays } from "lucide-react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { ARCHIVABLE_STATE_GROUPS, EUserPermissions } from "@plane/constants"; import { EUserPermissions } from "@plane/constants";
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
import { PriorityIcon, StateGroupIcon, getStateGroupColor } from "@plane/propel/icons"; import { PriorityIcon, StateGroupIcon, getStateGroupColor } from "@plane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IState, TExternalContourBoardDirection, TExternalContourRequest, TIssue } from "@plane/types"; import type { IState, TExternalContourBoardDirection, TExternalContourRequest, TIssue } from "@plane/types";
import { ActionDropdown, Avatar } from "@plane/ui"; import { Avatar } from "@plane/ui";
import { cn, renderFormattedDate, renderFormattedPayloadDate } from "@plane/utils"; import { cn, renderFormattedDate, renderFormattedPayloadDate } from "@plane/utils";
import { DateDropdown } from "@/components/dropdowns/date"; import { DateDropdown } from "@/components/dropdowns/date";
import { ButtonAvatars } from "@/components/dropdowns/member/avatar"; import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
import { MemberDropdown } from "@/components/dropdowns/member/dropdown"; import { MemberDropdown } from "@/components/dropdowns/member/dropdown";
import { MemberDropdownBase } from "@/components/dropdowns/member/base"; import { MemberDropdownBase } from "@/components/dropdowns/member/base";
import { PriorityDropdown } from "@/components/dropdowns/priority";
import { WorkItemStateDropdownBase } from "@/components/dropdowns/state/base";
import { StateDropdown } from "@/components/dropdowns/state/dropdown";
import { useAppRouter } from "@/hooks/use-app-router"; import { useAppRouter } from "@/hooks/use-app-router";
import { useMember } from "@/hooks/store/use-member"; import { useMember } from "@/hooks/store/use-member";
import { useProjectExternalContoursBoard } from "@/hooks/store/use-project-external-contours-board"; import { useProjectExternalContoursBoard } from "@/hooks/store/use-project-external-contours-board";
import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours"; import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours";
import { useProjectState } from "@/hooks/store/use-project-state"; import { useProjectState } from "@/hooks/store/use-project-state";
import { useUserPermissions } from "@/hooks/store/user"; import { useUserPermissions } from "@/hooks/store/user";
import { IssueArchiveService } from "@/services/issue/issue_archive.service";
import { IssueService } from "@/services/issue/issue.service"; import { IssueService } from "@/services/issue/issue.service";
import { ExternalContourDeleteModal } from "./delete-modal";
type Props = { type Props = {
direction: TExternalContourBoardDirection; direction: TExternalContourBoardDirection;
@ -37,15 +38,14 @@ type Props = {
}; };
const issueService = new IssueService(); const issueService = new IssueService();
const issueArchiveService = new IssueArchiveService();
const basePillClasses = const basePillClasses =
"inline-flex min-h-8 items-center gap-1.5 rounded-full border-0 px-2.5 py-1 text-[10px] font-medium shadow-none outline-none transition-colors"; "inline-flex min-h-9 items-center gap-1.5 rounded-full border-0 px-2.5 py-1 text-[11px] font-medium shadow-none outline-none transition-colors";
const buildSourceStateMap = ( const buildSourceStateMap = (
states: { id: string; name: string; color: string; group: IState["group"] }[] | undefined, states: { id: string; name: string; color: string; group: IState["group"] }[] | undefined,
projectId: string | null projectId: string | null
): Record<string, IState> => ) =>
Object.fromEntries( Object.fromEntries(
(states ?? []).map((state, index) => [ (states ?? []).map((state, index) => [
state.id, state.id,
@ -81,12 +81,11 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard
const { getUserDetails, workspace } = useMember(); const { getUserDetails, workspace } = useMember();
const { getProjectRoleByWorkspaceSlugAndProjectId } = useUserPermissions(); const { getProjectRoleByWorkspaceSlugAndProjectId } = useUserPermissions();
const { getStateById, getProjectStateIds } = useProjectState(); const { getStateById, getProjectStateIds } = useProjectState();
const { fetchBoard, removeBoardItem, upsertBoardItems } = useProjectExternalContoursBoard(); const { fetchBoard, upsertBoardItems } = useProjectExternalContoursBoard();
const { deleteRequest, fetchTargetOptions, getTargetOptionsByProjectId, updateRequest, updateRequestIssue } = const { fetchTargetOptions, getTargetOptionsByProjectId, updateRequest, updateRequestIssue } =
useProjectExternalContours(); useProjectExternalContours();
const [isUpdating, setIsUpdating] = useState(false); const [isUpdating, setIsUpdating] = useState(false);
const [isSourceOptionsLoading, setIsSourceOptionsLoading] = useState(false); const [isSourceOptionsLoading, setIsSourceOptionsLoading] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const issue = request.issue; const issue = request.issue;
const selectedInboxIssueId = searchParams.get("inboxIssueId"); const selectedInboxIssueId = searchParams.get("inboxIssueId");
@ -121,27 +120,15 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard
); );
const sourceStateIds = useMemo(() => targetOptions?.states?.map((state) => state.id) ?? [], [targetOptions?.states]); const sourceStateIds = useMemo(() => targetOptions?.states?.map((state) => state.id) ?? [], [targetOptions?.states]);
const selectedState = canEditTargetIssue ? getStateById(issue.state_id) : sourceStateMap[issue.state_id ?? ""]; const selectedState = canEditTargetIssue ? getStateById(issue.state_id) : sourceStateMap[issue.state_id ?? ""];
const projectStateIds = issue.project_id ? (getProjectStateIds(issue.project_id) ?? []) : []; const projectStateIds = issue.project_id ? getProjectStateIds(issue.project_id) : [];
const stateOptions = canEditTargetIssue
? projectStateIds.map((stateId) => getStateById(stateId)).filter((state): state is IState => !!state)
: sourceStateIds.map((stateId) => sourceStateMap[stateId]).filter((state): state is IState => !!state);
const foregroundClasses = isActive ? "text-[rgb(var(--nodedc-on-card-active-rgb))]" : "text-white"; const foregroundClasses = isActive ? "text-[rgb(var(--nodedc-on-card-active-rgb))]" : "text-white";
const subtleTextClasses = isActive ? "text-[#2F4721]" : "text-[#B3B3B8]"; const subtleTextClasses = isActive ? "text-[#2F4721]" : "text-[#B3B3B8]";
const pillBackgroundClasses = isActive const pillBackgroundClasses = isActive
? "bg-black/10 text-[rgb(var(--nodedc-on-card-active-rgb))]" ? "bg-black/10 text-[rgb(var(--nodedc-on-card-active-rgb))]"
: "bg-[rgb(var(--nodedc-card-passive-rgb))] text-white"; : "bg-[rgb(var(--nodedc-card-passive-rgb))] text-white";
const cornerActionButtonClasses = cn( const iconBubbleClasses = isActive ? "bg-black text-[rgb(var(--nodedc-card-active-rgb))]" : "bg-[#111214] text-white";
"flex h-12 w-12 -translate-x-0.5 -translate-y-0.5 items-center justify-center rounded-full border bg-transparent shadow-none ring-0 transition-colors outline-none", const statusIconColor = getStateGroupColor(selectedState?.group, selectedState?.color);
isActive
? "border-black/25 text-black hover:bg-black/5"
: "border-white/20 text-white hover:border-white/35 hover:bg-white/5"
);
const assigneeButtonClasses = cn(
"flex h-7 min-w-7 items-center justify-center rounded-full border-0 bg-transparent p-0 shadow-none outline-none transition-colors",
isActive ? "text-[rgb(var(--nodedc-on-card-active-rgb))]" : "text-white"
);
const dueDateLabel = issue.target_date ? renderFormattedDate(issue.target_date, "d MMM, yyyy") : t("common.none"); const dueDateLabel = issue.target_date ? renderFormattedDate(issue.target_date, "d MMM, yyyy") : t("common.none");
const canArchive = canEditTargetIssue && !!selectedState && ARCHIVABLE_STATE_GROUPS.includes(selectedState.group);
if (!issue) return null; if (!issue) return null;
@ -228,65 +215,7 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard
await handleSourceRequestUpdate(data); await handleSourceRequestUpdate(data);
}; };
const handleCopyLink = async () => {
const absoluteLink = `${window.location.origin}${requestLink}`;
await navigator.clipboard?.writeText(absoluteLink);
setToast({ title: "Ссылка скопирована", type: TOAST_TYPE.SUCCESS });
};
const handleArchiveIssue = async () => {
if (!targetProjectId || !issue.id || !canArchive || isUpdating) return;
setIsUpdating(true);
try {
await issueArchiveService.archiveIssue(workspaceSlug, targetProjectId, issue.id);
await syncBoardAfterMutation();
setToast({ title: "Задача архивирована", type: TOAST_TYPE.SUCCESS });
} catch {
setToast({ title: t("error"), type: TOAST_TYPE.ERROR, message: "Не удалось архивировать задачу" });
} finally {
setIsUpdating(false);
}
};
const handleDeleteRequest = async () => {
if (direction !== "outgoing" || isUpdating) return;
setIsUpdating(true);
try {
await deleteRequest(workspaceSlug, projectId, request.id);
removeBoardItem(request.id);
if (isActive) router.push(`/${workspaceSlug}/projects/${projectId}/external-contours`);
setToast({ title: "Исходящая задача удалена", type: TOAST_TYPE.SUCCESS });
setIsDeleteModalOpen(false);
} catch {
setToast({ title: t("error"), type: TOAST_TYPE.ERROR, message: "Не удалось удалить исходящую задачу" });
} finally {
setIsUpdating(false);
}
};
const priorityOptions: NonNullable<TIssue["priority"]>[] = ["urgent", "high", "medium", "low", "none"];
const priorityLabels: Record<NonNullable<TIssue["priority"]>, string> = {
urgent: "Срочный",
high: "Высокий",
medium: "Средний",
low: "Низкий",
none: "Без приоритета",
};
const menuItemClasses =
"flex w-full items-center gap-2 rounded-[0.9rem] px-2.5 py-2 text-left text-12 text-secondary transition-colors hover:bg-white/6 disabled:cursor-not-allowed disabled:text-placeholder disabled:hover:bg-transparent";
return ( return (
<>
<ExternalContourDeleteModal
isOpen={isDeleteModalOpen}
isSubmitting={isUpdating}
issueName={issue.name}
onClose={() => setIsDeleteModalOpen(false)}
onSubmit={handleDeleteRequest}
/>
<div className="group/kanban-block relative mb-2"> <div className="group/kanban-block relative mb-2">
<div <div
data-active={isActive} data-active={isActive}
@ -303,155 +232,94 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard
}} }}
> >
<div className={cn("relative flex min-h-[220px] flex-col px-1", foregroundClasses)}> <div className={cn("relative flex min-h-[220px] flex-col px-1", foregroundClasses)}>
<div className="absolute top-0.5 left-0.5 z-20"> <div className="space-y-0.5">
<Avatar <div className="flex items-center justify-between gap-3">
src={requesterAvatar} <div className="flex min-w-0 flex-1 items-center gap-3">
name={requester} <div className="shrink-0">
size={48} <Avatar src={requesterAvatar} name={requester} size="md" />
className="border border-white/10 shadow-none ring-0 outline-none"
/>
</div> </div>
<div className="absolute top-0.5 right-0.5 z-20" onClick={stopCardPropagation}>
<ActionDropdown
placement="bottom-end"
button={
<div className={cornerActionButtonClasses}>
<MoreHorizontal className="h-4 w-4" />
</div>
}
buttonClassName="h-12 w-12"
menuClassName="min-w-[18rem]"
onOpenChange={(isOpen) => {
if (isOpen) void ensureSourceOptions();
}}
items={[]}
menuContent={({ closeDropdown }) => (
<div className="max-h-[calc(100vh-2rem)] space-y-2 overflow-y-auto" onClick={stopCardPropagation}>
<div className="space-y-1">
<div className="px-2 text-[10px] font-semibold tracking-[0.16em] text-tertiary uppercase">
Приоритет
</div>
{priorityOptions.map((priority) => (
<button
key={priority}
type="button"
className={cn(menuItemClasses, { "bg-white/7 text-primary": issue.priority === priority })}
disabled={!canEditCard || isUpdating}
onClick={() => {
void handleCardUpdate({ priority });
closeDropdown();
}}
>
<PriorityIcon priority={priority} className="h-3.5 w-3.5" />
<span>{priorityLabels[priority]}</span>
</button>
))}
</div>
<div className="space-y-1 border-t border-white/8 pt-2">
<div className="px-2 text-[10px] font-semibold tracking-[0.16em] text-tertiary uppercase">
Статус
</div>
{isSourceOptionsLoading && stateOptions.length === 0 ? (
<div className="px-2.5 py-2 text-12 text-tertiary">Загрузка статусов...</div>
) : (
stateOptions.map((state) => (
<button
key={state.id}
type="button"
className={cn(menuItemClasses, { "bg-white/7 text-primary": issue.state_id === state.id })}
disabled={!canEditCard || isUpdating}
onClick={() => {
void handleCardUpdate({ state_id: state.id });
closeDropdown();
}}
>
<StateGroupIcon
stateGroup={state.group}
color={getStateGroupColor(state.group, state.color)}
className="h-3.5 w-3.5"
percentage={state.order}
/>
<span>{state.name}</span>
</button>
))
)}
</div>
<div className="space-y-1 border-t border-white/8 pt-2">
<div className="px-2 text-[10px] font-semibold tracking-[0.16em] text-tertiary uppercase">
Быстрые действия
</div>
<button
type="button"
className={menuItemClasses}
onClick={() => {
router.push(requestLink);
closeDropdown();
}}
>
<Pencil className="h-3.5 w-3.5" />
<span>Редактировать</span>
</button>
<button
type="button"
className={menuItemClasses}
onClick={() => {
void handleCopyLink();
closeDropdown();
}}
>
<Copy className="h-3.5 w-3.5" />
<span>Копировать ссылку</span>
</button>
<button
type="button"
className={menuItemClasses}
disabled={!canArchive || isUpdating}
onClick={() => {
void handleArchiveIssue();
closeDropdown();
}}
>
<Archive className="h-3.5 w-3.5" />
<span>Архивировать</span>
</button>
<button
type="button"
className={cn(menuItemClasses, "text-red-300 hover:bg-red-500/10 disabled:text-placeholder")}
disabled={direction !== "outgoing" || isUpdating}
onClick={() => {
setIsDeleteModalOpen(true);
closeDropdown();
}}
>
<Trash2 className="h-3.5 w-3.5" />
<span>Удалить</span>
</button>
</div>
</div>
)}
/>
</div>
<div className="min-w-0 pr-[58px] pl-[58px] pt-1">
<div className="flex min-w-0 items-center gap-1.5">
<div className={cn("truncate text-body-sm-medium leading-5", foregroundClasses)}>{requester}</div> <div className={cn("truncate text-body-sm-medium leading-5", foregroundClasses)}>{requester}</div>
</div>
<div className="flex shrink-0 items-center gap-2" onClick={stopCardPropagation}>
{request.has_unread_updates && ( {request.has_unread_updates && (
<span <span
className={cn("size-2 shrink-0 rounded-full", isActive ? "bg-black/70" : "bg-accent-primary")} className={cn("size-2 rounded-full", isActive ? "bg-black/70" : "bg-accent-primary")}
title={t("external_contours_page.list.unread_updates")} title={t("external_contours_page.list.unread_updates")}
/> />
)} )}
<PriorityDropdown
value={issue.priority}
onChange={(priority) => void handleCardUpdate({ priority })}
disabled={!canEditCard || isUpdating}
buttonVariant="transparent-without-text"
button={
<div
className={cn(
"flex h-8 w-8 items-center justify-center rounded-full border-0 shadow-none outline-none",
iconBubbleClasses
)}
>
<PriorityIcon priority={issue.priority} className="h-3.5 w-3.5" />
</div> </div>
<div className={cn("truncate text-[10px] leading-3.5 font-medium", subtleTextClasses)}> }
/>
{canEditTargetIssue ? (
<StateDropdown
projectId={issue.project_id ?? undefined}
stateIds={projectStateIds ?? []}
value={issue.state_id}
onChange={(stateId) => void handleCardUpdate({ state_id: stateId })}
disabled={!canEditCard || isUpdating}
buttonVariant="transparent-without-text"
button={
<div className={cn("flex h-8 w-8 items-center justify-center rounded-full", iconBubbleClasses)}>
<StateGroupIcon
stateGroup={selectedState?.group ?? "backlog"}
color={statusIconColor}
className="h-3.5 w-3.5"
percentage={selectedState?.order}
/>
</div>
}
/>
) : (
<WorkItemStateDropdownBase
projectId={targetProjectId ?? undefined}
value={issue.state_id}
stateIds={sourceStateIds}
getStateById={(stateId) => (stateId ? sourceStateMap[stateId] : undefined)}
onChange={(stateId) => void handleCardUpdate({ state_id: stateId })}
disabled={!canEditCard || isUpdating || !targetProjectId}
isInitializing={isSourceOptionsLoading}
onDropdownOpen={() => {
void ensureSourceOptions();
}}
buttonVariant="transparent-without-text"
button={
<div className={cn("flex h-8 w-8 items-center justify-center rounded-full", iconBubbleClasses)}>
<StateGroupIcon
stateGroup={selectedState?.group ?? "backlog"}
color={statusIconColor}
className="h-3.5 w-3.5"
percentage={selectedState?.order}
/>
</div>
}
/>
)}
</div>
</div>
<div className={cn("-mt-0.5 truncate pl-8 text-[11px] leading-4 font-medium", subtleTextClasses)}>
{counterpartContourName || t("common.none")} {counterpartContourName || t("common.none")}
</div> </div>
</div> </div>
<div className="flex flex-1 items-center justify-start px-1 pt-7 pb-4 text-left"> <div className="flex flex-1 items-center justify-center px-5 py-4 text-center">
<div className="line-clamp-5 max-w-full text-[15px] leading-5 font-medium">{issue.name}</div> <div className="text-lg line-clamp-4 max-w-full leading-6 font-semibold">{issue.name}</div>
</div> </div>
<div className="flex items-center justify-between gap-3" onClick={stopCardPropagation}> <div className="flex items-center justify-between gap-3" onClick={stopCardPropagation}>
@ -464,8 +332,8 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard
disabled={!canEditCard || isUpdating} disabled={!canEditCard || isUpdating}
buttonVariant="transparent-without-text" buttonVariant="transparent-without-text"
button={ button={
<div className={assigneeButtonClasses}> <div className={cn(basePillClasses, pillBackgroundClasses, "pr-2 pl-1")}>
<ButtonAvatars showTooltip={false} userIds={issue.assignee_ids ?? []} size={26} /> <ButtonAvatars showTooltip={false} userIds={issue.assignee_ids ?? []} size="sm" />
</div> </div>
} }
/> />
@ -482,8 +350,8 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard
}} }}
buttonVariant="transparent-without-text" buttonVariant="transparent-without-text"
button={ button={
<div className={assigneeButtonClasses}> <div className={cn(basePillClasses, pillBackgroundClasses, "pr-2 pl-1")}>
<ButtonAvatars showTooltip={false} userIds={issue.assignee_ids ?? []} size={26} /> <ButtonAvatars showTooltip={false} userIds={issue.assignee_ids ?? []} size="sm" />
</div> </div>
} }
/> />
@ -504,7 +372,7 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard
buttonVariant="transparent-without-text" buttonVariant="transparent-without-text"
button={ button={
<div className={cn(basePillClasses, pillBackgroundClasses)}> <div className={cn(basePillClasses, pillBackgroundClasses)}>
<CalendarDays className="h-3 w-3" /> <CalendarDays className="h-3.5 w-3.5" />
<span className="truncate">{dueDateLabel}</span> <span className="truncate">{dueDateLabel}</span>
</div> </div>
} }
@ -513,6 +381,5 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard
</div> </div>
</div> </div>
</div> </div>
</>
); );
}); });

View File

@ -1,48 +0,0 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { Button } from "@plane/propel/button";
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
type Props = {
isOpen: boolean;
isSubmitting?: boolean;
issueName: string;
onClose: () => void;
onSubmit: () => Promise<void>;
};
export function ExternalContourDeleteModal(props: Props) {
const { isOpen, isSubmitting = false, issueName, onClose, onSubmit } = props;
return (
<ModalCore
isOpen={isOpen}
handleClose={onClose}
position={EModalPosition.CENTER}
width={EModalWidth.MD}
className="nodedc-glass-modal rounded-[1.75rem]"
>
<div className="space-y-5 p-6">
<div className="space-y-1.5">
<h3 className="text-18 font-semibold text-primary">Удалить исходящую задачу</h3>
<p className="text-13 leading-5 text-secondary">
Задача «{issueName}» будет удалена из внешнего контура и из целевого проекта. Это действие нельзя отменить.
</p>
</div>
<div className="flex justify-end gap-2">
<Button variant="primary" onClick={onClose} disabled={isSubmitting} className="nodedc-modal-primary-button">
Отмена
</Button>
<Button variant="secondary" onClick={onSubmit} disabled={isSubmitting} className="nodedc-modal-secondary-button">
{isSubmitting ? "Удаление..." : "Удалить"}
</Button>
</div>
</div>
</ModalCore>
);
}

View File

@ -10,7 +10,6 @@ import { TransferIcon } from "@plane/propel/icons";
import type { TInboxIssueCurrentTab } from "@plane/types"; import type { TInboxIssueCurrentTab } from "@plane/types";
import { EInboxIssueCurrentTab } from "@plane/types"; import { EInboxIssueCurrentTab } from "@plane/types";
import { FiltersRow } from "@/components/rich-filters/filters-row"; import { FiltersRow } from "@/components/rich-filters/filters-row";
import { useExternalContoursRealtimeEvents } from "@/hooks/use-external-contours-realtime-events";
import { useProjectExternalContoursBoard } from "@/hooks/store/use-project-external-contours-board"; import { useProjectExternalContoursBoard } from "@/hooks/store/use-project-external-contours-board";
import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours"; import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours";
import { ExternalContoursBoardRoot } from "./board-root"; import { ExternalContoursBoardRoot } from "./board-root";
@ -33,12 +32,9 @@ export const ExternalContoursRoot = observer(function ExternalContoursRoot(props
currentProjectId: boardProjectId, currentProjectId: boardProjectId,
fetchBoard, fetchBoard,
loader: boardLoader, loader: boardLoader,
syncBoard,
} = useProjectExternalContoursBoard(); } = useProjectExternalContoursBoard();
const filter = useExternalContoursFilter(); const filter = useExternalContoursFilter();
useExternalContoursRealtimeEvents(workspaceSlug?.toString(), projectId?.toString(), syncBoard);
useEffect(() => { useEffect(() => {
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !projectId) return;

View File

@ -5,7 +5,6 @@
*/ */
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports // plane imports
import { EIssueLayoutTypes } from "@plane/types"; import { EIssueLayoutTypes } from "@plane/types";
// components // components
@ -16,7 +15,6 @@ import { ListLayoutLoader } from "@/components/ui/loader/layouts/list-layout-loa
import { SpreadsheetLayoutLoader } from "@/components/ui/loader/layouts/spreadsheet-layout-loader"; import { SpreadsheetLayoutLoader } from "@/components/ui/loader/layouts/spreadsheet-layout-loader";
// hooks // hooks
import { useIssues } from "@/hooks/store/use-issues"; import { useIssues } from "@/hooks/store/use-issues";
import { useIssueRealtimeEvents } from "@/hooks/use-issue-realtime-events";
import { useIssueStoreType } from "@/hooks/use-issue-layout-store"; import { useIssueStoreType } from "@/hooks/use-issue-layout-store";
// local imports // local imports
import { IssueLayoutEmptyState } from "./empty-states"; import { IssueLayoutEmptyState } from "./empty-states";
@ -46,11 +44,9 @@ interface Props {
export const IssueLayoutHOC = observer(function IssueLayoutHOC(props: Props) { export const IssueLayoutHOC = observer(function IssueLayoutHOC(props: Props) {
const { layout } = props; const { layout } = props;
const { workspaceSlug, projectId } = useParams();
const storeType = useIssueStoreType(); const storeType = useIssueStoreType();
const { issues } = useIssues(storeType); const { issues } = useIssues(storeType);
useIssueRealtimeEvents(storeType, workspaceSlug?.toString(), projectId?.toString());
const issueCount = issues.getGroupIssueCount(undefined, undefined, false); const issueCount = issues.getGroupIssueCount(undefined, undefined, false);

View File

@ -1,137 +0,0 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useEffect, useRef } from "react";
import { LIVE_BASE_PATH, LIVE_BASE_URL } from "@plane/constants";
type TExternalContourRealtimeEvent = {
event_id?: string;
type?: string;
workspace_slug?: string;
project_id?: string;
};
const SYNC_DEBOUNCE_MS = 350;
const buildIssueStreamUrl = (workspaceSlug: string, projectId: string) => {
const liveBaseUrl = LIVE_BASE_URL?.trim() || window.location.origin;
const liveBasePath = LIVE_BASE_PATH?.trim() || "/live";
const url = new URL(liveBaseUrl);
url.protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
url.pathname = `${liveBasePath.replace(/\/$/, "")}/issues/stream`;
url.searchParams.set("workspaceSlug", workspaceSlug);
url.searchParams.set("projectId", projectId);
return url.toString();
};
export const useExternalContoursRealtimeEvents = (
workspaceSlug: string | undefined,
projectId: string | undefined,
syncBoard: (workspaceSlug: string, projectId: string) => Promise<void>
) => {
const syncBoardRef = useRef(syncBoard);
const processedEventIdsRef = useRef<string[]>([]);
const processedEventSetRef = useRef(new Set<string>());
const syncTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>();
useEffect(() => {
syncBoardRef.current = syncBoard;
}, [syncBoard]);
useEffect(() => {
if (!workspaceSlug || !projectId || typeof window === "undefined") return;
let socket: WebSocket | undefined;
let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
let cancelled = false;
let reconnectAttempt = 0;
const rememberEvent = (eventId?: string) => {
if (!eventId) return true;
if (processedEventSetRef.current.has(eventId)) return false;
processedEventIdsRef.current.push(eventId);
processedEventSetRef.current.add(eventId);
if (processedEventIdsRef.current.length > 250) {
const removedEventId = processedEventIdsRef.current.shift();
if (removedEventId) processedEventSetRef.current.delete(removedEventId);
}
return true;
};
const scheduleSync = () => {
if (cancelled) return;
if (syncTimerRef.current) clearTimeout(syncTimerRef.current);
syncTimerRef.current = setTimeout(() => {
void syncBoardRef.current(workspaceSlug, projectId);
}, SYNC_DEBOUNCE_MS);
};
const scheduleReconnect = () => {
if (cancelled) return;
const delay = Math.min(1000 * 2 ** reconnectAttempt, 15000);
reconnectAttempt += 1;
reconnectTimer = setTimeout(connect, delay);
};
const connect = () => {
try {
socket = new WebSocket(buildIssueStreamUrl(workspaceSlug, projectId));
socket.onopen = () => {
reconnectAttempt = 0;
scheduleSync();
};
socket.onmessage = (message) => {
try {
const event = JSON.parse(message.data) as TExternalContourRealtimeEvent;
if (event.type === "issue.stream.ping") {
socket?.send(JSON.stringify({ type: "issue.stream.pong" }));
return;
}
if (event.type === "issue.stream.ready") return;
if (event.workspace_slug && event.workspace_slug !== workspaceSlug) return;
if (event.project_id && event.project_id !== projectId) return;
if (!event.type?.startsWith("external_contour.") && !event.type?.startsWith("issue.")) return;
if (!rememberEvent(event.event_id)) return;
scheduleSync();
} catch (error) {
console.error("Failed to process external contour realtime event", error);
}
};
socket.onclose = () => {
scheduleReconnect();
};
socket.onerror = () => {
socket?.close();
};
} catch (error) {
console.error("Failed to connect external contour realtime stream", error);
scheduleReconnect();
}
};
connect();
return () => {
cancelled = true;
if (syncTimerRef.current) clearTimeout(syncTimerRef.current);
if (reconnectTimer) clearTimeout(reconnectTimer);
socket?.close();
};
}, [workspaceSlug, projectId]);
};

View File

@ -1,268 +0,0 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useEffect, useRef } from "react";
// plane imports
import { LIVE_BASE_PATH, LIVE_BASE_URL } from "@plane/constants";
import type { TIssue } from "@plane/types";
import { EIssuesStoreType } from "@plane/types";
// hooks
import { useIssues } from "@/hooks/store/use-issues";
// services
import { IssueService } from "@/services/issue";
type TIssueRealtimeEvent = {
event_id: string;
type: "issue.created" | "issue.updated" | "issue.deleted" | "issue.stream.ready" | "issue.stream.ping";
workspace_slug?: string;
project_id?: string;
issue_id?: string;
updated_at?: string;
};
type TRealtimeIssueStore = {
addIssue?: (issue: TIssue, shouldUpdateList?: boolean) => void;
groupedIssueIds?: Record<string, unknown>;
removeIssueFromList?: (issueId: string) => void;
updateIssueList?: (issue?: TIssue, issueBeforeUpdate?: TIssue) => void;
rootIssueStore?: {
issues?: {
removeIssue?: (issueId: string) => void;
};
};
};
type TIssueFilterSnapshot = {
appliedFilters?: Record<string, string | boolean>;
};
const REALTIME_STORE_TYPES = new Set<EIssuesStoreType>([EIssuesStoreType.PROJECT, EIssuesStoreType.PROJECT_VIEW]);
const MAX_PROCESSED_EVENTS = 250;
const INITIAL_CATCH_UP_WINDOW_MS = 5 * 60 * 1000;
const hasIssueId = (value: unknown, issueId: string): boolean => {
if (Array.isArray(value)) return value.includes(issueId);
if (!value || typeof value !== "object") return false;
return Object.values(value).some((nestedValue) => hasIssueId(nestedValue, issueId));
};
const buildIssueStreamUrl = (workspaceSlug: string, projectId: string) => {
const liveBaseUrl = LIVE_BASE_URL?.trim() || window.location.origin;
const liveBasePath = LIVE_BASE_PATH?.trim() || "/live";
const url = new URL(liveBaseUrl);
url.protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
url.pathname = `${liveBasePath.replace(/\/$/, "")}/issues/stream`;
url.searchParams.set("workspaceSlug", workspaceSlug);
url.searchParams.set("projectId", projectId);
return url.toString();
};
export const useIssueRealtimeEvents = (storeType: EIssuesStoreType, workspaceSlug?: string, projectId?: string) => {
const { issueMap, issues, issuesFilter } = useIssues(storeType);
const issueServiceRef = useRef(new IssueService());
const issueMapRef = useRef(issueMap);
const issuesRef = useRef<TRealtimeIssueStore>(issues as TRealtimeIssueStore);
const issueFilterRef = useRef<TIssueFilterSnapshot>(issuesFilter as TIssueFilterSnapshot);
const processedEventIdsRef = useRef<string[]>([]);
const processedEventSetRef = useRef(new Set<string>());
const lastSeenUpdatedAtRef = useRef<string | undefined>();
useEffect(() => {
issueMapRef.current = issueMap;
}, [issueMap]);
useEffect(() => {
issuesRef.current = issues as TRealtimeIssueStore;
}, [issues]);
useEffect(() => {
issueFilterRef.current = issuesFilter as TIssueFilterSnapshot;
}, [issuesFilter]);
useEffect(() => {
if (!workspaceSlug || !projectId || !REALTIME_STORE_TYPES.has(storeType) || typeof window === "undefined") return;
let socket: WebSocket | undefined;
let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
let cancelled = false;
let reconnectAttempt = 0;
const getFilterParams = () => {
const filters = { ...(issueFilterRef.current?.appliedFilters ?? {}) };
delete filters.cursor;
delete filters.group_by;
delete filters.per_page;
delete filters.sub_group_by;
return filters;
};
const rememberUpdatedAt = (updatedAt?: string) => {
if (!updatedAt) return;
const currentUpdatedAt = lastSeenUpdatedAtRef.current;
if (!currentUpdatedAt || Date.parse(updatedAt) > Date.parse(currentUpdatedAt)) {
lastSeenUpdatedAtRef.current = updatedAt;
}
};
const getInitialCatchUpStart = () => {
const latestKnownUpdatedAt = Object.values(issueMapRef.current ?? {}).reduce<string | undefined>(
(latestUpdatedAt, issue) => {
if (!issue?.updated_at) return latestUpdatedAt;
if (!latestUpdatedAt || Date.parse(issue.updated_at) > Date.parse(latestUpdatedAt)) return issue.updated_at;
return latestUpdatedAt;
},
undefined
);
const fallbackUpdatedAt = new Date(Date.now() - INITIAL_CATCH_UP_WINDOW_MS).toISOString();
if (!latestKnownUpdatedAt) return fallbackUpdatedAt;
return Date.parse(latestKnownUpdatedAt) < Date.parse(fallbackUpdatedAt) ? latestKnownUpdatedAt : fallbackUpdatedAt;
};
const rememberEvent = (eventId: string) => {
if (processedEventSetRef.current.has(eventId)) return false;
processedEventIdsRef.current.push(eventId);
processedEventSetRef.current.add(eventId);
if (processedEventIdsRef.current.length > MAX_PROCESSED_EVENTS) {
const removedEventId = processedEventIdsRef.current.shift();
if (removedEventId) processedEventSetRef.current.delete(removedEventId);
}
return true;
};
const applyIssue = (issue: TIssue) => {
const realtimeStore = issuesRef.current;
const issueBeforeUpdate = issueMapRef.current?.[issue.id];
if (!issueBeforeUpdate || !hasIssueId(realtimeStore.groupedIssueIds, issue.id)) {
realtimeStore.addIssue?.(issue, true);
return;
}
realtimeStore.addIssue?.(issue, false);
realtimeStore.updateIssueList?.(issue, issueBeforeUpdate);
};
const removeIssue = (issueId: string, removeFromMap = false) => {
const realtimeStore = issuesRef.current;
realtimeStore.removeIssueFromList?.(issueId);
if (removeFromMap) realtimeStore.rootIssueStore?.issues?.removeIssue?.(issueId);
};
const fetchIssue = async (issueId: string) => {
const issues = await issueServiceRef.current.retrieveIssues(workspaceSlug, projectId, [issueId], getFilterParams());
return issues?.[0];
};
const handleIssueEvent = async (event: TIssueRealtimeEvent) => {
if (!event.event_id || !event.issue_id) return;
if (!rememberEvent(event.event_id)) return;
rememberUpdatedAt(event.updated_at);
if (event.type === "issue.deleted") {
removeIssue(event.issue_id, true);
return;
}
const issue = await fetchIssue(event.issue_id);
if (!issue) {
removeIssue(event.issue_id);
return;
}
applyIssue(issue);
};
const catchUpMissedEvents = async () => {
const updatedAt = lastSeenUpdatedAtRef.current;
if (!updatedAt) return;
const response = await issueServiceRef.current.getIssues(workspaceSlug, projectId, {
...getFilterParams(),
updated_at__gt: updatedAt,
per_page: "100",
});
const results = response?.results;
if (!Array.isArray(results)) return;
results.forEach((issue) => {
rememberUpdatedAt(issue.updated_at);
applyIssue(issue);
});
};
const scheduleReconnect = () => {
if (cancelled) return;
const delay = Math.min(1000 * 2 ** reconnectAttempt, 15000);
reconnectAttempt += 1;
reconnectTimer = setTimeout(connect, delay);
};
const connect = () => {
try {
socket = new WebSocket(buildIssueStreamUrl(workspaceSlug, projectId));
socket.onopen = () => {
reconnectAttempt = 0;
lastSeenUpdatedAtRef.current = lastSeenUpdatedAtRef.current ?? getInitialCatchUpStart();
void catchUpMissedEvents();
};
socket.onmessage = (message) => {
try {
const event = JSON.parse(message.data) as TIssueRealtimeEvent;
if (event.type === "issue.stream.ping") {
socket?.send(JSON.stringify({ type: "issue.stream.pong" }));
return;
}
if (event.type === "issue.stream.ready") return;
if (!event.type?.startsWith("issue.")) return;
if (event.workspace_slug && event.workspace_slug !== workspaceSlug) return;
if (event.project_id && event.project_id !== projectId) return;
void handleIssueEvent(event);
} catch (error) {
console.error("Failed to process issue realtime event", error);
}
};
socket.onclose = () => {
scheduleReconnect();
};
socket.onerror = () => {
socket?.close();
};
} catch (error) {
console.error("Failed to connect issue realtime stream", error);
scheduleReconnect();
}
};
connect();
return () => {
cancelled = true;
if (reconnectTimer) clearTimeout(reconnectTimer);
socket?.close();
};
}, [storeType, workspaceSlug, projectId]);
};

View File

@ -80,14 +80,6 @@ export class ExternalContourService extends APIService {
}); });
} }
async deleteRequest(workspaceSlug: string, projectId: string, requestId: string): Promise<void> {
return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/external-contours/${requestId}/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async listTargetProjects(workspaceSlug: string, projectId: string): Promise<TExternalContourTargetProject[]> { async listTargetProjects(workspaceSlug: string, projectId: string): Promise<TExternalContourTargetProject[]> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/external-contours/targets/`) return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/external-contours/targets/`)
.then((response) => response?.data) .then((response) => response?.data)

View File

@ -80,7 +80,7 @@ export class IssueService extends APIService {
async getIssues( async getIssues(
workspaceSlug: string, workspaceSlug: string,
projectId: string, projectId: string,
queries?: Partial<Record<TIssueParams, string | boolean>> | Record<string, string | boolean>, queries?: Partial<Record<TIssueParams, string | boolean>>,
config = {} config = {}
): Promise<TIssuesResponse> { ): Promise<TIssuesResponse> {
return this.getIssuesFromServer(workspaceSlug, projectId, queries, config); return this.getIssuesFromServer(workspaceSlug, projectId, queries, config);
@ -126,14 +126,9 @@ export class IssueService extends APIService {
}); });
} }
async retrieveIssues( async retrieveIssues(workspaceSlug: string, projectId: string, issueIds: string[]): Promise<TIssue[]> {
workspaceSlug: string,
projectId: string,
issueIds: string[],
queries?: Record<string, string | boolean>
): Promise<TIssue[]> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/list/`, { return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/list/`, {
params: { ...queries, issues: issueIds.join(",") }, params: { issues: issueIds.join(",") },
}) })
.then(async (response) => response?.data) .then(async (response) => response?.data)
.catch((error) => { .catch((error) => {

View File

@ -53,9 +53,7 @@ export interface IProjectExternalContoursBoardStore {
replaceFilters: (workspaceSlug: string, projectId: string, filters: Partial<TExternalContourBoardFilter>) => Promise<void>; replaceFilters: (workspaceSlug: string, projectId: string, filters: Partial<TExternalContourBoardFilter>) => Promise<void>;
updateFilters: (workspaceSlug: string, projectId: string, filters: Partial<TExternalContourBoardFilter>) => Promise<void>; updateFilters: (workspaceSlug: string, projectId: string, filters: Partial<TExternalContourBoardFilter>) => Promise<void>;
updateSorting: (workspaceSlug: string, projectId: string, sorting: TExternalContourBoardSorting) => Promise<void>; updateSorting: (workspaceSlug: string, projectId: string, sorting: TExternalContourBoardSorting) => Promise<void>;
syncBoard: (workspaceSlug: string, projectId: string) => Promise<void>;
upsertBoardItems: (items: TExternalContourRequest[]) => void; upsertBoardItems: (items: TExternalContourRequest[]) => void;
removeBoardItem: (requestId: string) => void;
} }
export class ProjectExternalContoursBoardStore implements IProjectExternalContoursBoardStore { export class ProjectExternalContoursBoardStore implements IProjectExternalContoursBoardStore {
@ -103,11 +101,9 @@ export class ProjectExternalContoursBoardStore implements IProjectExternalContou
fetchBoard: action, fetchBoard: action,
handleCurrentTab: action, handleCurrentTab: action,
replaceFilters: action, replaceFilters: action,
syncBoard: action,
updateFilters: action, updateFilters: action,
updateSorting: action, updateSorting: action,
upsertBoardItems: action, upsertBoardItems: action,
removeBoardItem: action,
}); });
this.externalContourService = new ExternalContourService(); this.externalContourService = new ExternalContourService();
@ -149,19 +145,6 @@ export class ProjectExternalContoursBoardStore implements IProjectExternalContou
this.store.projectExternalContours.upsertRequests(items); this.store.projectExternalContours.upsertRequests(items);
}; };
removeBoardItem = (requestId: string) => {
delete this.items[requestId];
this.columnIdsMap = {
outgoing: this.columnIdsMap.outgoing.filter((id) => id !== requestId),
incoming: this.columnIdsMap.incoming.filter((id) => id !== requestId),
};
this.columnCountMap = {
outgoing: this.columnIdsMap.outgoing.length,
incoming: this.columnIdsMap.incoming.length,
};
this.store.projectExternalContours.removeRequest(requestId);
};
handleCurrentTab = async (workspaceSlug: string, projectId: string, tab: TInboxIssueCurrentTab) => { handleCurrentTab = async (workspaceSlug: string, projectId: string, tab: TInboxIssueCurrentTab) => {
this.currentTab = tab; this.currentTab = tab;
await this.fetchBoard(workspaceSlug, projectId, tab); await this.fetchBoard(workspaceSlug, projectId, tab);
@ -265,46 +248,4 @@ export class ProjectExternalContoursBoardStore implements IProjectExternalContou
}); });
} }
}; };
syncBoard = async (workspaceSlug: string, projectId: string) => {
if (this.currentProjectId && this.currentProjectId !== projectId) return;
const requestId = ++this.lastIssuedRequestId;
const nextFilters = sanitizeBoardFilters(this.filters);
const nextSorting = this.sorting;
try {
const response = await this.externalContourService.listBoard(workspaceSlug, projectId, nextFilters, nextSorting);
if (requestId !== this.lastIssuedRequestId) return;
runInAction(() => {
this.columnIdsMap = { outgoing: [], incoming: [] };
this.columnCountMap = { outgoing: 0, incoming: 0 };
this.filters = sanitizeBoardFilters(response.filters || nextFilters);
this.sorting = response.sorting || nextSorting;
this.currentProjectId = projectId;
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 = {
[EInboxIssueCurrentTab.OPEN]: openCount,
[EInboxIssueCurrentTab.CLOSED]: closedCount,
};
this.error = undefined;
});
} catch {
// Realtime sync is best-effort; the next explicit board fetch will surface errors.
}
};
} }

View File

@ -53,7 +53,6 @@ export interface IProjectExternalContoursStore {
requestId: string, requestId: string,
comment: string comment: string
) => Promise<TExternalContourRequest | undefined>; ) => Promise<TExternalContourRequest | undefined>;
deleteRequest: (workspaceSlug: string, projectId: string, requestId: string) => Promise<void>;
fetchTargetProjects: (workspaceSlug: string, projectId: string) => Promise<void>; fetchTargetProjects: (workspaceSlug: string, projectId: string) => Promise<void>;
fetchTargetOptions: (workspaceSlug: string, projectId: string, targetProjectId: string) => Promise<TExternalContourTargetOptions | undefined>; fetchTargetOptions: (workspaceSlug: string, projectId: string, targetProjectId: string) => Promise<TExternalContourTargetOptions | undefined>;
fetchRequestById: (workspaceSlug: string, projectId: string, requestId: string) => Promise<TExternalContourRequest | undefined>; fetchRequestById: (workspaceSlug: string, projectId: string, requestId: string) => Promise<TExternalContourRequest | undefined>;
@ -67,7 +66,6 @@ export interface IProjectExternalContoursStore {
closedRequestIds: string[]; closedRequestIds: string[];
filteredRequestIds: string[]; filteredRequestIds: string[];
upsertRequests: (requests: TExternalContourRequest[]) => void; upsertRequests: (requests: TExternalContourRequest[]) => void;
removeRequest: (requestId: string) => void;
updateRequestIssue: (requestId: string, issueData: Partial<TExternalContourRequest["issue"]>) => void; updateRequestIssue: (requestId: string, issueData: Partial<TExternalContourRequest["issue"]>) => void;
} }
@ -104,11 +102,9 @@ export class ProjectExternalContoursStore implements IProjectExternalContoursSto
fetchRequestById: action, fetchRequestById: action,
createRequest: action, createRequest: action,
updateRequest: action, updateRequest: action,
deleteRequest: action,
decideRequest: action, decideRequest: action,
replyToRequest: action, replyToRequest: action,
handleCurrentTab: action, handleCurrentTab: action,
removeRequest: action,
upsertRequests: action, upsertRequests: action,
updateRequestIssue: action, updateRequestIssue: action,
}); });
@ -147,11 +143,6 @@ export class ProjectExternalContoursStore implements IProjectExternalContoursSto
}); });
}; };
removeRequest = (requestId: string) => {
delete this.requests[requestId];
this.requestIds = this.requestIds.filter((id) => id !== requestId);
};
fetchTargetProjects = async (workspaceSlug: string, projectId: string) => { fetchTargetProjects = async (workspaceSlug: string, projectId: string) => {
try { try {
const projects = await this.externalContourService.listTargetProjects(workspaceSlug, projectId); const projects = await this.externalContourService.listTargetProjects(workspaceSlug, projectId);
@ -278,22 +269,6 @@ export class ProjectExternalContoursStore implements IProjectExternalContoursSto
} }
}; };
deleteRequest = async (workspaceSlug: string, projectId: string, requestId: string) => {
this.loader = "mutation-loading";
try {
await this.externalContourService.deleteRequest(workspaceSlug, projectId, requestId);
runInAction(() => {
this.removeRequest(requestId);
this.loader = undefined;
});
} catch (error) {
runInAction(() => {
this.loader = undefined;
});
throw error;
}
};
decideRequest = async ( decideRequest = async (
workspaceSlug: string, workspaceSlug: string,
projectId: string, projectId: string,