Compare commits
No commits in common. "d53fa2b38c2e5e05364eba54945435f7dcf58343" and "4ba3aab02eb88ee8335d71816d53c9e81706f2af" have entirely different histories.
d53fa2b38c
...
4ba3aab02e
|
|
@ -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 .
|
||||||
|
|
|
||||||
|
|
@ -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 .
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
|
@ -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>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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:",
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
];
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
|
||||||
};
|
|
||||||
|
|
@ -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]);
|
|
||||||
};
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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.
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue