Compare commits

..

3 Commits

Author SHA1 Message Date
DCCONSTRUCTIONS 83ea515962 perf: optimize Tasker boot and kanban load 2026-05-15 19:18:25 +03:00
DCCONSTRUCTIONS 44d2c5dd27 CHORE - NAS DEPLOY: stabilize legacy Tasker rebuild flow 2026-05-15 14:25:36 +03:00
DCCONSTRUCTIONS 6962642614 FEAT - TASKER: support Codex agent labels and profiles 2026-05-15 14:25:36 +03:00
33 changed files with 1201 additions and 275 deletions

View File

@ -1,7 +1,8 @@
import re
from html import escape from html import escape
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import transaction from django.db import IntegrityError, transaction
from django.http import JsonResponse from django.http import JsonResponse
from django.utils import timezone from django.utils import timezone
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
@ -30,6 +31,11 @@ NODEDC_STRUCTURED_BLOCKS_KEY = "nodedc_structured_blocks"
AGENT_EMAIL_DOMAIN = "agents.nodedc.local" AGENT_EMAIL_DOMAIN = "agents.nodedc.local"
AGENT_BOT_TYPE = "nodedc_codex_agent" AGENT_BOT_TYPE = "nodedc_codex_agent"
ALLOWED_PRIORITIES = ["none", "low", "medium", "high", "urgent"] ALLOWED_PRIORITIES = ["none", "low", "medium", "high", "urgent"]
DEFAULT_AGENT_LABEL_COLOR = "#4E5355"
MAX_AGENT_AVATAR_URL_LENGTH = 2_000_000
MARKDOWN_HEADING_PATTERN = re.compile(r"^\s{0,3}#{1,6}\s+(.+?)\s*$")
HEX_COLOR_PATTERN = re.compile(r"^#[0-9a-fA-F]{6}$")
DATA_IMAGE_PATTERN = re.compile(r"^data:image/(png|jpeg|jpg|webp|gif);base64,[A-Za-z0-9+/=]+$")
def unauthorized_response(): def unauthorized_response():
@ -169,24 +175,80 @@ def get_agent_identity(request):
} }
def ensure_agent_actor(request, workspace, project=None): def get_agent_payload_metadata(payload):
if not isinstance(payload, dict):
return {}
metadata = payload.get("_agent")
return metadata if isinstance(metadata, dict) else {}
def normalize_agent_display_name(value, fallback):
if isinstance(value, str):
display_name = value.strip()
if display_name:
return display_name[:120]
return fallback
def normalize_agent_avatar_url(value):
if not isinstance(value, str):
return ""
avatar_url = value.strip()
if not avatar_url or len(avatar_url) > MAX_AGENT_AVATAR_URL_LENGTH:
return ""
if avatar_url.startswith("data:image/"):
return avatar_url if DATA_IMAGE_PATTERN.match(avatar_url) else ""
if avatar_url.startswith("http://") or avatar_url.startswith("https://"):
return avatar_url
return ""
def get_agent_profile(request, payload=None):
identity = get_agent_identity(request)
if identity is None:
return None
short_id = identity["agent_id"].replace("-", "")[:12]
fallback_display_name = f"Codex Agent {short_id}"
metadata = get_agent_payload_metadata(payload)
return {
"display_name": normalize_agent_display_name(metadata.get("display_name"), fallback_display_name),
"avatar_url": normalize_agent_avatar_url(metadata.get("avatar_url")),
"short_id": short_id,
}
def ensure_agent_actor(request, workspace, project=None, payload=None):
identity = get_agent_identity(request) identity = get_agent_identity(request)
if identity is None: if identity is None:
return None return None
agent_id = identity["agent_id"] agent_id = identity["agent_id"]
short_id = agent_id.replace("-", "")[:12] profile = get_agent_profile(request, payload)
if profile is None:
return None
short_id = profile["short_id"]
email = f"agent+{agent_id}@{AGENT_EMAIL_DOMAIN}" email = f"agent+{agent_id}@{AGENT_EMAIL_DOMAIN}"
username = f"nodedc_agent_{short_id}" username = f"nodedc_agent_{short_id}"
display_name = f"Codex Agent {short_id}" display_name = profile["display_name"]
avatar_url = profile["avatar_url"]
user, _ = User.objects.get_or_create( user, _ = User.objects.get_or_create(
email=email, email=email,
defaults={ defaults={
"username": username, "username": username,
"display_name": display_name, "display_name": display_name,
"first_name": "Codex", "first_name": display_name,
"last_name": f"Agent {short_id}", "last_name": "",
"avatar": avatar_url,
"is_bot": True, "is_bot": True,
"bot_type": AGENT_BOT_TYPE, "bot_type": AGENT_BOT_TYPE,
"is_active": True, "is_active": True,
@ -206,6 +268,18 @@ def ensure_agent_actor(request, workspace, project=None):
if user.display_name != display_name: if user.display_name != display_name:
user.display_name = display_name user.display_name = display_name
update_fields.append("display_name") update_fields.append("display_name")
if user.first_name != display_name:
user.first_name = display_name
update_fields.append("first_name")
if user.last_name:
user.last_name = ""
update_fields.append("last_name")
if avatar_url and user.avatar != avatar_url:
user.avatar = avatar_url
update_fields.append("avatar")
if avatar_url and user.avatar_asset_id:
user.avatar_asset = None
update_fields.append("avatar_asset")
if update_fields: if update_fields:
update_fields.append("updated_at") update_fields.append("updated_at")
@ -278,6 +352,100 @@ def html_from_text(value):
return f"<p>{escape(text)}</p>" if text else "<p></p>" return f"<p>{escape(text)}</p>" if text else "<p></p>"
def normalize_structured_title(value, fallback):
title = value.strip() if isinstance(value, str) else ""
if not title:
return fallback
heading_match = MARKDOWN_HEADING_PATTERN.match(title)
if heading_match:
return heading_match.group(1).strip() or fallback
return title
def split_leading_markdown_heading(value):
if not isinstance(value, str):
return None, ""
lines = value.splitlines()
leading_blank_count = 0
for line in lines:
if line.strip():
break
leading_blank_count += 1
if leading_blank_count >= len(lines):
return None, ""
first_content_line = lines[leading_blank_count]
heading_match = MARKDOWN_HEADING_PATTERN.match(first_content_line)
if not heading_match:
return None, value.strip()
body_lines = lines[leading_blank_count + 1 :]
while body_lines and not body_lines[0].strip():
body_lines.pop(0)
return heading_match.group(1).strip(), "\n".join(body_lines).strip()
def normalize_structured_block(block):
if not isinstance(block, dict):
return None
block_type = block.get("type")
if block_type == "text":
extracted_title, body = split_leading_markdown_heading(block.get("body"))
title = normalize_structured_title(block.get("title") or extracted_title, "Раздел")
return {
"id": str(block.get("id") or f"text-{title.lower().replace(' ', '-')}"),
"type": "text",
"title": title,
"body": body,
}
if block_type == "checker":
title = normalize_structured_title(block.get("title"), "Чеклист")
raw_items = block.get("items")
items = []
if isinstance(raw_items, list):
for item in raw_items:
if not isinstance(item, dict):
continue
text = item.get("text")
if not isinstance(text, str) or not text.strip():
continue
items.append(
{
"id": str(item.get("id") or f"item-{len(items) + 1}"),
"text": text.strip(),
"checked": item.get("checked") is True,
}
)
return {
"id": str(block.get("id") or f"checker-{title.lower().replace(' ', '-')}"),
"type": "checker",
"title": title,
"items": items,
}
return None
def normalize_structured_blocks(structured_blocks):
normalized_blocks = []
for block in structured_blocks:
normalized_block = normalize_structured_block(block)
if normalized_block is None:
return None
normalized_blocks.append(normalized_block)
return normalized_blocks
def merge_structured_blocks(detail_layout, structured_blocks): def merge_structured_blocks(detail_layout, structured_blocks):
if structured_blocks is None: if structured_blocks is None:
return detail_layout or {} return detail_layout or {}
@ -285,8 +453,12 @@ def merge_structured_blocks(detail_layout, structured_blocks):
if not isinstance(structured_blocks, list): if not isinstance(structured_blocks, list):
return None return None
normalized_blocks = normalize_structured_blocks(structured_blocks)
if normalized_blocks is None:
return None
updated_layout = dict(detail_layout or {}) updated_layout = dict(detail_layout or {})
updated_layout[NODEDC_STRUCTURED_BLOCKS_KEY] = structured_blocks updated_layout[NODEDC_STRUCTURED_BLOCKS_KEY] = normalized_blocks
return updated_layout return updated_layout
@ -301,6 +473,72 @@ def update_issue_labels(issue, label_ids):
return True return True
def normalize_label_color(value):
if isinstance(value, str) and HEX_COLOR_PATTERN.match(value.strip()):
return value.strip()
return DEFAULT_AGENT_LABEL_COLOR
def normalize_label_input(raw_label):
if isinstance(raw_label, str):
name = raw_label.strip()
color = DEFAULT_AGENT_LABEL_COLOR
elif isinstance(raw_label, dict):
name = raw_label.get("name").strip() if isinstance(raw_label.get("name"), str) else ""
color = normalize_label_color(raw_label.get("color"))
else:
return None
if not name:
return None
return {
"name": name[:255],
"color": color,
}
def ensure_project_labels(project, raw_labels, actor, agent_id):
if not isinstance(raw_labels, list) or not raw_labels or len(raw_labels) > 50:
return None
ensured_labels = []
for raw_label in raw_labels:
label_input = normalize_label_input(raw_label)
if label_input is None:
return None
label = Label.objects.filter(
project=project,
name__iexact=label_input["name"],
deleted_at__isnull=True,
).first()
if label is None:
try:
label = Label(
project=project,
workspace=project.workspace,
name=label_input["name"],
color=label_input["color"],
external_source=AGENT_BOT_TYPE,
external_id=agent_id,
)
label.save(created_by_id=actor.id)
except IntegrityError:
label = Label.objects.filter(
project=project,
name__iexact=label_input["name"],
deleted_at__isnull=True,
).first()
if label is None:
return None
ensured_labels.append(label)
return ensured_labels
def update_issue_assignees(issue, member_ids): def update_issue_assignees(issue, member_ids):
project_members = list( project_members = list(
ProjectMember.objects.filter( ProjectMember.objects.filter(
@ -413,6 +651,42 @@ class NodeDCAgentProjectContextEndpoint(View):
) )
@method_decorator(csrf_exempt, name="dispatch")
class NodeDCAgentProjectLabelsEnsureEndpoint(View):
def post(self, request, project_id):
error_response = validate_internal_request(request)
if error_response is not None:
return error_response
payload = parse_json_body(request)
if payload is None:
return invalid_json_response()
project = resolve_project(project_id, payload.get("workspace_slug"))
if project is None:
return validation_error("project_not_found", status=404)
entitlement_error = validate_agent_workspace_entitlement(request, project.workspace.slug)
if entitlement_error is not None:
return entitlement_error
actor = ensure_agent_actor(request, project.workspace, project, payload)
if actor is None:
return validation_error("missing_agent_headers")
identity = get_agent_identity(request)
ensured_labels = ensure_project_labels(
project,
payload.get("labels"),
actor,
identity["agent_id"] if identity else "",
)
if ensured_labels is None:
return validation_error("invalid_labels")
return JsonResponse({"ok": True, "labels": [serialize_label(label) for label in ensured_labels]}, status=201)
@method_decorator(csrf_exempt, name="dispatch") @method_decorator(csrf_exempt, name="dispatch")
class NodeDCAgentIssueListEndpoint(View): class NodeDCAgentIssueListEndpoint(View):
def get(self, request): def get(self, request):
@ -473,7 +747,7 @@ class NodeDCAgentIssueListEndpoint(View):
return validation_error("invalid_structured_blocks") return validation_error("invalid_structured_blocks")
with transaction.atomic(): with transaction.atomic():
actor = ensure_agent_actor(request, project.workspace, project) actor = ensure_agent_actor(request, project.workspace, project, payload)
if actor is None: if actor is None:
return validation_error("missing_agent_headers") return validation_error("missing_agent_headers")
@ -539,7 +813,7 @@ class NodeDCAgentIssueUpdateEndpoint(View):
update_fields.append("detail_layout") update_fields.append("detail_layout")
if update_fields: if update_fields:
actor = ensure_agent_actor(request, project.workspace, project) actor = ensure_agent_actor(request, project.workspace, project, payload)
if actor is None: if actor is None:
return validation_error("missing_agent_headers") return validation_error("missing_agent_headers")
issue.updated_by = actor issue.updated_by = actor
@ -575,7 +849,7 @@ class NodeDCAgentIssueMoveEndpoint(View):
if state is None: if state is None:
return validation_error("state_not_found", status=404) return validation_error("state_not_found", status=404)
actor = ensure_agent_actor(request, project.workspace, project) actor = ensure_agent_actor(request, project.workspace, project, payload)
if actor is None: if actor is None:
return validation_error("missing_agent_headers") return validation_error("missing_agent_headers")
@ -615,7 +889,7 @@ class NodeDCAgentIssueCommentEndpoint(View):
if not isinstance(body, str) or not body.strip(): if not isinstance(body, str) or not body.strip():
return validation_error("body_required") return validation_error("body_required")
actor = ensure_agent_actor(request, project.workspace, project) actor = ensure_agent_actor(request, project.workspace, project, payload)
if actor is None: if actor is None:
return validation_error("missing_agent_headers") return validation_error("missing_agent_headers")
@ -659,7 +933,7 @@ class NodeDCAgentIssueLabelsEndpoint(View):
if not update_issue_labels(issue, label_ids): if not update_issue_labels(issue, label_ids):
return validation_error("label_not_found", status=404) return validation_error("label_not_found", status=404)
actor = ensure_agent_actor(request, project.workspace, project) actor = ensure_agent_actor(request, project.workspace, project, payload)
if actor is not None: if actor is not None:
issue.updated_by = actor issue.updated_by = actor
issue.save(update_fields=["updated_by", "updated_at"], disable_auto_set_user=True) issue.save(update_fields=["updated_by", "updated_at"], disable_auto_set_user=True)
@ -696,7 +970,7 @@ class NodeDCAgentIssueAssigneesEndpoint(View):
if not update_issue_assignees(issue, member_ids): if not update_issue_assignees(issue, member_ids):
return validation_error("member_not_found", status=404) return validation_error("member_not_found", status=404)
actor = ensure_agent_actor(request, project.workspace, project) actor = ensure_agent_actor(request, project.workspace, project, payload)
if actor is not None: if actor is not None:
issue.updated_by = actor issue.updated_by = actor
issue.save(update_fields=["updated_by", "updated_at"], disable_auto_set_user=True) issue.save(update_fields=["updated_by", "updated_at"], disable_auto_set_user=True)

View File

@ -23,6 +23,7 @@ from plane.authentication.views.nodedc_agent_adapter import (
NodeDCAgentIssueMoveEndpoint, NodeDCAgentIssueMoveEndpoint,
NodeDCAgentIssueUpdateEndpoint, NodeDCAgentIssueUpdateEndpoint,
NodeDCAgentProjectContextEndpoint, NodeDCAgentProjectContextEndpoint,
NodeDCAgentProjectLabelsEnsureEndpoint,
NodeDCAgentProjectResolveEndpoint, NodeDCAgentProjectResolveEndpoint,
) )
from plane.authentication.views.nodedc_workspace_adapter import ( from plane.authentication.views.nodedc_workspace_adapter import (
@ -94,6 +95,11 @@ urlpatterns = [
NodeDCAgentProjectContextEndpoint.as_view(), NodeDCAgentProjectContextEndpoint.as_view(),
name="nodedc-agent-project-context", name="nodedc-agent-project-context",
), ),
path(
"api/internal/nodedc/agent/projects/<uuid:project_id>/labels/ensure",
NodeDCAgentProjectLabelsEnsureEndpoint.as_view(),
name="nodedc-agent-project-labels-ensure",
),
path( path(
"api/internal/nodedc/agent/issues", "api/internal/nodedc/agent/issues",
NodeDCAgentIssueListEndpoint.as_view(), NodeDCAgentIssueListEndpoint.as_view(),

View File

@ -4,7 +4,7 @@
* See the LICENSE file for details. * See the LICENSE file for details.
*/ */
import { useCallback, useState } from "react"; import { lazy, Suspense, useCallback, useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
// plane imports // plane imports
@ -14,7 +14,6 @@ import { ChevronDownIcon } from "@plane/propel/icons";
import type { IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types"; import type { IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types";
import { EIssuesStoreType, EIssueLayoutTypes } from "@plane/types"; import { EIssuesStoreType, EIssueLayoutTypes } from "@plane/types";
// components // components
import { WorkItemsModal } from "@/components/analytics/work-items/modal";
import { import {
DisplayFiltersSelection, DisplayFiltersSelection,
FiltersDropdown, FiltersDropdown,
@ -24,6 +23,12 @@ import {
import { useIssues } from "@/hooks/store/use-issues"; import { useIssues } from "@/hooks/store/use-issues";
import { useProject } from "@/hooks/store/use-project"; import { useProject } from "@/hooks/store/use-project";
const WorkItemsModal = lazy(() =>
import("@/components/analytics/work-items/modal").then((module) => ({
default: module.WorkItemsModal,
}))
);
export const ProjectIssuesMobileHeader = observer(function ProjectIssuesMobileHeader() { export const ProjectIssuesMobileHeader = observer(function ProjectIssuesMobileHeader() {
// i18n // i18n
const { t } = useTranslation(); const { t } = useTranslation();
@ -63,11 +68,15 @@ export const ProjectIssuesMobileHeader = observer(function ProjectIssuesMobileHe
return ( return (
<> <>
<WorkItemsModal {analyticsModal && (
isOpen={analyticsModal} <Suspense fallback={null}>
onClose={() => setAnalyticsModal(false)} <WorkItemsModal
projectDetails={currentProjectDetails ?? undefined} isOpen={analyticsModal}
/> onClose={() => setAnalyticsModal(false)}
projectDetails={currentProjectDetails ?? undefined}
/>
</Suspense>
)}
<div className="z-[13] flex justify-evenly border-b border-subtle bg-surface-1 py-2 md:hidden"> <div className="z-[13] flex justify-evenly border-b border-subtle bg-surface-1 py-2 md:hidden">
<LayoutSelection <LayoutSelection
layouts={[ layouts={[

View File

@ -10,7 +10,7 @@ import { useTranslation } from "@plane/i18n";
import { PlusIcon } from "@plane/propel/icons"; import { PlusIcon } from "@plane/propel/icons";
import { cn } from "@plane/utils"; import { cn } from "@plane/utils";
// components // components
import { TopNavPowerK } from "@/components/navigation"; import { DeferredTopNavPowerK } from "@/components/navigation/deferred-top-nav-power-k";
import { UserMenuRoot } from "@/components/workspace/sidebar/user-menu-root"; import { UserMenuRoot } from "@/components/workspace/sidebar/user-menu-root";
import { WorkspaceMenuRoot } from "@/components/workspace/sidebar/workspace-menu-root"; import { WorkspaceMenuRoot } from "@/components/workspace/sidebar/workspace-menu-root";
import { ProjectsToolbarMenu } from "./projects-toolbar-menu"; import { ProjectsToolbarMenu } from "./projects-toolbar-menu";
@ -40,7 +40,7 @@ export const CompactProjectShellToolbarLayout = ({
<div className="flex min-w-0 items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
<div className="nodedc-toolbar-group relative flex items-center gap-1 overflow-visible"> <div className="nodedc-toolbar-group relative flex items-center gap-1 overflow-visible">
<WorkspaceMenuRoot variant="toolbar" /> <WorkspaceMenuRoot variant="toolbar" />
<TopNavPowerK variant="sidebar" /> <DeferredTopNavPowerK variant="sidebar" />
<UserMenuRoot variant="toolbar" /> <UserMenuRoot variant="toolbar" />
<ToolbarNotificationsButton <ToolbarNotificationsButton
label={t("notification.label")} label={t("notification.label")}

View File

@ -10,7 +10,7 @@ import { useTranslation } from "@plane/i18n";
import { Shapes } from "lucide-react"; import { Shapes } from "lucide-react";
import { cn } from "@plane/utils"; import { cn } from "@plane/utils";
// components // components
import { TopNavPowerK } from "@/components/navigation"; import { DeferredTopNavPowerK } from "@/components/navigation/deferred-top-nav-power-k";
import { useNodeDCBrandLinkUrl } from "@/hooks/use-nodedc-brand-link-url"; import { useNodeDCBrandLinkUrl } from "@/hooks/use-nodedc-brand-link-url";
import { UserMenuRoot } from "@/components/workspace/sidebar/user-menu-root"; import { UserMenuRoot } from "@/components/workspace/sidebar/user-menu-root";
import { WorkspaceMenuRoot } from "@/components/workspace/sidebar/workspace-menu-root"; import { WorkspaceMenuRoot } from "@/components/workspace/sidebar/workspace-menu-root";
@ -71,7 +71,7 @@ export const ExpandedProjectShellToolbarLayout = ({
<div className="nodedc-expanded-breadcrumbs-slot" data-nodedc-expanded-breadcrumbs-slot /> <div className="nodedc-expanded-breadcrumbs-slot" data-nodedc-expanded-breadcrumbs-slot />
{!isWorkspaceHome && ( {!isWorkspaceHome && (
<div className="nodedc-expanded-main-tool-cluster"> <div className="nodedc-expanded-main-tool-cluster">
<TopNavPowerK variant="expanded-toolbar" /> <DeferredTopNavPowerK variant="expanded-toolbar" />
<div className="nodedc-expanded-header-filters-slot" data-nodedc-expanded-header-filters-slot /> <div className="nodedc-expanded-header-filters-slot" data-nodedc-expanded-header-filters-slot />
</div> </div>
)} )}

View File

@ -10,9 +10,7 @@ import { WorkspaceContentWrapper } from "@/plane-web/components/workspace/conten
import { AppRailVisibilityProvider } from "@/plane-web/hooks/app-rail"; import { AppRailVisibilityProvider } from "@/plane-web/hooks/app-rail";
import { GlobalModals } from "@/plane-web/components/common/modal/global"; import { GlobalModals } from "@/plane-web/components/common/modal/global";
import { WorkspaceAuthWrapper } from "@/layouts/auth-layout/workspace-wrapper"; import { WorkspaceAuthWrapper } from "@/layouts/auth-layout/workspace-wrapper";
import { ProjectSettingsModal } from "@/components/project/settings/project-settings-modal"; import { LazyWorkspaceModals } from "@/components/common/modal/lazy-workspace-modals";
import { WorkspaceSettingsModal } from "@/components/workspace/settings/workspace-settings-modal";
import { WorkspaceNotificationsModal } from "@/components/workspace-notifications/notifications-modal";
import type { Route } from "./+types/layout"; import type { Route } from "./+types/layout";
export default function WorkspaceLayout(props: Route.ComponentProps) { export default function WorkspaceLayout(props: Route.ComponentProps) {
@ -24,9 +22,7 @@ export default function WorkspaceLayout(props: Route.ComponentProps) {
<AppRailVisibilityProvider> <AppRailVisibilityProvider>
<WorkspaceContentWrapper workspaceSlug={workspaceSlug}> <WorkspaceContentWrapper workspaceSlug={workspaceSlug}>
<GlobalModals workspaceSlug={workspaceSlug} /> <GlobalModals workspaceSlug={workspaceSlug} />
<WorkspaceSettingsModal /> <LazyWorkspaceModals />
<ProjectSettingsModal />
<WorkspaceNotificationsModal />
<Outlet /> <Outlet />
</WorkspaceContentWrapper> </WorkspaceContentWrapper>
</AppRailVisibilityProvider> </AppRailVisibilityProvider>

View File

@ -6,6 +6,7 @@
import { lazy, Suspense } from "react"; import { lazy, Suspense } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useCommandPalette } from "@/hooks/store/use-command-palette";
const ProfileSettingsModal = lazy(() => const ProfileSettingsModal = lazy(() =>
import("@/components/settings/profile/modal").then((module) => ({ import("@/components/settings/profile/modal").then((module) => ({
@ -24,6 +25,10 @@ type TGlobalModalsProps = {
* - Profile settings modal * - Profile settings modal
*/ */
export const GlobalModals = observer(function GlobalModals(_props: TGlobalModalsProps) { export const GlobalModals = observer(function GlobalModals(_props: TGlobalModalsProps) {
const { profileSettingsModal } = useCommandPalette();
if (!profileSettingsModal.isOpen) return null;
return ( return (
<Suspense fallback={null}> <Suspense fallback={null}>
<ProfileSettingsModal /> <ProfileSettingsModal />

View File

@ -8,7 +8,7 @@
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useParams, usePathname } from "next/navigation"; import { useParams, usePathname } from "next/navigation";
import { cn } from "@plane/utils"; import { cn } from "@plane/utils";
import { TopNavPowerK } from "@/components/navigation"; import { DeferredTopNavPowerK } from "@/components/navigation/deferred-top-nav-power-k";
import { UserMenuRoot } from "@/components/workspace/sidebar/user-menu-root"; import { UserMenuRoot } from "@/components/workspace/sidebar/user-menu-root";
import { WorkspaceMenuRoot } from "@/components/workspace/sidebar/workspace-menu-root"; import { WorkspaceMenuRoot } from "@/components/workspace/sidebar/workspace-menu-root";
import { useAppRailPreferences } from "@/hooks/use-navigation-preferences"; import { useAppRailPreferences } from "@/hooks/use-navigation-preferences";
@ -57,7 +57,7 @@ export const TopNavigationRoot = observer(function TopNavigationRoot() {
</div> </div>
{/* Power K Search */} {/* Power K Search */}
<div className="shrink-0"> <div className="shrink-0">
<TopNavPowerK /> <DeferredTopNavPowerK />
</div> </div>
{/* Additional Actions */} {/* Additional Actions */}
<div className="flex flex-1 shrink-0 items-center justify-end gap-1"> <div className="flex flex-1 shrink-0 items-center justify-end gap-1">

View File

@ -7,14 +7,21 @@
// store // store
import { CoreRootStore } from "@/store/root.store"; import { CoreRootStore } from "@/store/root.store";
import type { ITimelineStore } from "./timeline"; import type { ITimelineStore } from "./timeline";
import { TimeLineStore } from "./timeline";
export class RootStore extends CoreRootStore { export class RootStore extends CoreRootStore {
timelineStore: ITimelineStore; timelineStore: ITimelineStore | undefined;
private timelineStorePromise: Promise<ITimelineStore> | undefined;
constructor() { loadTimelineStore = async (): Promise<ITimelineStore> => {
super(); if (this.timelineStore) return this.timelineStore;
this.timelineStore = new TimeLineStore(this); if (!this.timelineStorePromise) {
this.timelineStorePromise = import("./timeline").then(({ TimeLineStore }) => {
this.timelineStore = new TimeLineStore(this);
return this.timelineStore;
});
}
return this.timelineStorePromise;
} }
} }

View File

@ -0,0 +1,94 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { lazy, Suspense, useEffect, useState } from "react";
import {
getProjectSettingsModalProjectIdFromSearch,
getProjectSettingsModalTabFromSearch,
PROJECT_SETTINGS_MODAL_EVENT,
} from "@/components/project/settings/project-settings-modal.utils";
import {
getWorkspaceSettingsModalTabFromSearch,
WORKSPACE_SETTINGS_MODAL_EVENT,
} from "@/components/workspace/settings/workspace-settings-modal.utils";
import {
getWorkspaceNotificationsModalOpenFromSearch,
WORKSPACE_NOTIFICATIONS_MODAL_EVENT,
} from "@/components/workspace-notifications/notifications-modal.utils";
const WorkspaceSettingsModal = lazy(() =>
import("@/components/workspace/settings/workspace-settings-modal").then((module) => ({
default: module.WorkspaceSettingsModal,
}))
);
const ProjectSettingsModal = lazy(() =>
import("@/components/project/settings/project-settings-modal").then((module) => ({
default: module.ProjectSettingsModal,
}))
);
const WorkspaceNotificationsModal = lazy(() =>
import("@/components/workspace-notifications/notifications-modal").then((module) => ({
default: module.WorkspaceNotificationsModal,
}))
);
const hasWorkspaceSettingsSearch = () =>
typeof window !== "undefined" && Boolean(getWorkspaceSettingsModalTabFromSearch(window.location.search));
const hasProjectSettingsSearch = () =>
typeof window !== "undefined" &&
Boolean(getProjectSettingsModalTabFromSearch(window.location.search) && getProjectSettingsModalProjectIdFromSearch(window.location.search));
const hasWorkspaceNotificationsSearch = () =>
typeof window !== "undefined" && getWorkspaceNotificationsModalOpenFromSearch(window.location.search);
export function LazyWorkspaceModals() {
const [shouldLoadWorkspaceSettings, setShouldLoadWorkspaceSettings] = useState(hasWorkspaceSettingsSearch);
const [shouldLoadProjectSettings, setShouldLoadProjectSettings] = useState(hasProjectSettingsSearch);
const [shouldLoadWorkspaceNotifications, setShouldLoadWorkspaceNotifications] = useState(hasWorkspaceNotificationsSearch);
useEffect(() => {
const syncFromLocation = () => {
if (hasWorkspaceSettingsSearch()) setShouldLoadWorkspaceSettings(true);
if (hasProjectSettingsSearch()) setShouldLoadProjectSettings(true);
if (hasWorkspaceNotificationsSearch()) setShouldLoadWorkspaceNotifications(true);
};
const handleWorkspaceSettingsEvent = (event: Event) => {
if ((event as CustomEvent<{ isOpen: boolean }>).detail?.isOpen) setShouldLoadWorkspaceSettings(true);
};
const handleProjectSettingsEvent = (event: Event) => {
if ((event as CustomEvent<{ isOpen: boolean }>).detail?.isOpen) setShouldLoadProjectSettings(true);
};
const handleWorkspaceNotificationsEvent = (event: Event) => {
if ((event as CustomEvent<{ isOpen: boolean }>).detail?.isOpen) setShouldLoadWorkspaceNotifications(true);
};
window.addEventListener("popstate", syncFromLocation);
window.addEventListener(WORKSPACE_SETTINGS_MODAL_EVENT, handleWorkspaceSettingsEvent);
window.addEventListener(PROJECT_SETTINGS_MODAL_EVENT, handleProjectSettingsEvent);
window.addEventListener(WORKSPACE_NOTIFICATIONS_MODAL_EVENT, handleWorkspaceNotificationsEvent);
return () => {
window.removeEventListener("popstate", syncFromLocation);
window.removeEventListener(WORKSPACE_SETTINGS_MODAL_EVENT, handleWorkspaceSettingsEvent);
window.removeEventListener(PROJECT_SETTINGS_MODAL_EVENT, handleProjectSettingsEvent);
window.removeEventListener(WORKSPACE_NOTIFICATIONS_MODAL_EVENT, handleWorkspaceNotificationsEvent);
};
}, []);
return (
<Suspense fallback={null}>
{shouldLoadWorkspaceSettings && <WorkspaceSettingsModal />}
{shouldLoadProjectSettings && <ProjectSettingsModal />}
{shouldLoadWorkspaceNotifications && <WorkspaceNotificationsModal />}
</Suspense>
);
}

View File

@ -50,15 +50,17 @@ export const IssueLayoutHOC = observer(function IssueLayoutHOC(props: Props) {
const storeType = useIssueStoreType(); const storeType = useIssueStoreType();
const { issues } = useIssues(storeType); const { issues } = useIssues(storeType);
useIssueRealtimeEvents(storeType, workspaceSlug?.toString(), projectId?.toString()); const issueLoader = issues?.getIssueLoader();
const issueCount = issues?.getGroupIssueCount(undefined, undefined, false);
const isInitialLoadComplete = !!issues && issueLoader !== "init-loader" && issueCount !== undefined;
useIssueRealtimeEvents(storeType, workspaceSlug?.toString(), projectId?.toString(), isInitialLoadComplete);
if (!issues) { if (!issues) {
return <ActiveLoader layout={layout} />; return <ActiveLoader layout={layout} />;
} }
const issueCount = issues.getGroupIssueCount(undefined, undefined, false); if (issueLoader === "init-loader" || issueCount === undefined) {
if (issues?.getIssueLoader() === "init-loader" || issueCount === undefined) {
return <ActiveLoader layout={layout} />; return <ActiveLoader layout={layout} />;
} }

View File

@ -341,6 +341,7 @@ export const KanbanIssueBlock = observer(function KanbanIssueBlock(props: IssueB
horizontalOffset={100} horizontalOffset={100}
verticalOffset={200} verticalOffset={200}
defaultValue={shouldRenderByDefault} defaultValue={shouldRenderByDefault}
useIdletime
> >
<KanbanIssueDetailsBlock <KanbanIssueDetailsBlock
cardRef={cardRef} cardRef={cardRef}

View File

@ -67,7 +67,7 @@ export const KanbanIssueBlocksList = observer(function KanbanIssueBlocksList(pro
issueId={issueId} issueId={issueId}
groupId={groupId} groupId={groupId}
subGroupId={sub_group_id} subGroupId={sub_group_id}
shouldRenderByDefault={index <= 10} shouldRenderByDefault={index < 3}
issuesMap={issuesMap} issuesMap={issuesMap}
displayProperties={displayProperties} displayProperties={displayProperties}
updateIssue={updateIssue} updateIssue={updateIssue}

View File

@ -210,7 +210,7 @@ export const KanBan = observer(function KanBan(props: IKanBan) {
shouldAnimate={false} shouldAnimate={false}
/> />
} }
defaultValue={groupIndex < 5 && subGroupIndex < 2} defaultValue={groupIndex < 3 && subGroupIndex < 2}
useIdletime useIdletime
> >
<KanbanGroup <KanbanGroup

View File

@ -4,7 +4,7 @@
* See the LICENSE file for details. * See the LICENSE file for details.
*/ */
import React, { useState } from "react"; import { lazy, Suspense, useState } from "react";
import { isEmpty } from "lodash-es"; import { isEmpty } from "lodash-es";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
@ -19,15 +19,26 @@ import { TransferIssuesModal } from "@/components/cycles/transfer-issues-modal";
import { ProjectLevelWorkItemFiltersHOC } from "@/components/work-item-filters/filters-hoc/project-level"; import { ProjectLevelWorkItemFiltersHOC } from "@/components/work-item-filters/filters-hoc/project-level";
import { WorkItemFiltersRow } from "@/components/work-item-filters/filters-row"; import { WorkItemFiltersRow } from "@/components/work-item-filters/filters-row";
import { useCycle } from "@/hooks/store/use-cycle"; import { useCycle } from "@/hooks/store/use-cycle";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useIssues } from "@/hooks/store/use-issues"; import { useIssues } from "@/hooks/store/use-issues";
import { IssuesStoreContext } from "@/hooks/use-issue-layout-store"; import { IssuesStoreContext } from "@/hooks/use-issue-layout-store";
// local imports
import { IssuePeekOverview } from "../../peek-overview"; const IssuePeekOverview = lazy(() =>
import { CycleCalendarLayout } from "../calendar/roots/cycle-root"; import("../../peek-overview/root").then((module) => ({ default: module.IssuePeekOverview }))
import { BaseGanttRoot } from "../gantt"; );
import { CycleKanBanLayout } from "../kanban/roots/cycle-root"; const CycleCalendarLayout = lazy(() =>
import { CycleListLayout } from "../list/roots/cycle-root"; import("../calendar/roots/cycle-root").then((module) => ({ default: module.CycleCalendarLayout }))
import { CycleSpreadsheetLayout } from "../spreadsheet/roots/cycle-root"; );
const BaseGanttRoot = lazy(() => import("../gantt").then((module) => ({ default: module.BaseGanttRoot })));
const CycleKanBanLayout = lazy(() =>
import("../kanban/roots/cycle-root").then((module) => ({ default: module.CycleKanBanLayout }))
);
const CycleListLayout = lazy(() =>
import("../list/roots/cycle-root").then((module) => ({ default: module.CycleListLayout }))
);
const CycleSpreadsheetLayout = lazy(() =>
import("../spreadsheet/roots/cycle-root").then((module) => ({ default: module.CycleSpreadsheetLayout }))
);
function CycleIssueLayout(props: { function CycleIssueLayout(props: {
activeLayout: EIssueLayoutTypes | undefined; activeLayout: EIssueLayoutTypes | undefined;
@ -58,6 +69,7 @@ export const CycleLayoutRoot = observer(function CycleLayoutRoot() {
// store hooks // store hooks
const { issuesFilter } = useIssues(EIssuesStoreType.CYCLE); const { issuesFilter } = useIssues(EIssuesStoreType.CYCLE);
const { getCycleById } = useCycle(); const { getCycleById } = useCycle();
const { peekIssue } = useIssueDetail();
// state // state
const [transferIssuesModal, setTransferIssuesModal] = useState(false); const [transferIssuesModal, setTransferIssuesModal] = useState(false);
// derived values // derived values
@ -82,6 +94,7 @@ export const CycleLayoutRoot = observer(function CycleLayoutRoot() {
? cycleDetails.backlog_issues + cycleDetails.unstarted_issues + cycleDetails.started_issues ? cycleDetails.backlog_issues + cycleDetails.unstarted_issues + cycleDetails.started_issues
: 0; : 0;
const canTransferIssues = isProgressSnapshotEmpty && transferableIssuesCount > 0; const canTransferIssues = isProgressSnapshotEmpty && transferableIssuesCount > 0;
const shouldRenderPeekOverview = !!peekIssue?.workspaceSlug && !!peekIssue?.projectId && !!peekIssue?.issueId;
if (!workspaceSlug || !projectId || !cycleId || !workItemFilters) return <></>; if (!workspaceSlug || !projectId || !cycleId || !workItemFilters) return <></>;
return ( return (
@ -120,10 +133,19 @@ export const CycleLayoutRoot = observer(function CycleLayoutRoot() {
/> />
)} )}
<div className="h-full w-full overflow-auto"> <div className="h-full w-full overflow-auto">
<CycleIssueLayout activeLayout={activeLayout} cycleId={cycleId} isCompletedCycle={isCompletedCycle} /> <Suspense fallback={null}>
<CycleIssueLayout
activeLayout={activeLayout}
cycleId={cycleId}
isCompletedCycle={isCompletedCycle}
/>
</Suspense>
</div> </div>
{/* peek overview */} {shouldRenderPeekOverview && (
<IssuePeekOverview /> <Suspense fallback={null}>
<IssuePeekOverview />
</Suspense>
)}
</div> </div>
</> </>
)} )}

View File

@ -4,7 +4,7 @@
* See the LICENSE file for details. * See the LICENSE file for details.
*/ */
import React from "react"; import { lazy, Suspense } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import useSWR from "swr"; import useSWR from "swr";
@ -15,15 +15,26 @@ import { Row, ERowVariant } from "@plane/ui";
// hooks // hooks
import { ProjectLevelWorkItemFiltersHOC } from "@/components/work-item-filters/filters-hoc/project-level"; import { ProjectLevelWorkItemFiltersHOC } from "@/components/work-item-filters/filters-hoc/project-level";
import { WorkItemFiltersRow } from "@/components/work-item-filters/filters-row"; import { WorkItemFiltersRow } from "@/components/work-item-filters/filters-row";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useIssues } from "@/hooks/store/use-issues"; import { useIssues } from "@/hooks/store/use-issues";
import { IssuesStoreContext } from "@/hooks/use-issue-layout-store"; import { IssuesStoreContext } from "@/hooks/use-issue-layout-store";
// local imports
import { IssuePeekOverview } from "../../peek-overview"; const IssuePeekOverview = lazy(() =>
import { ModuleCalendarLayout } from "../calendar/roots/module-root"; import("../../peek-overview/root").then((module) => ({ default: module.IssuePeekOverview }))
import { BaseGanttRoot } from "../gantt"; );
import { ModuleKanBanLayout } from "../kanban/roots/module-root"; const ModuleCalendarLayout = lazy(() =>
import { ModuleListLayout } from "../list/roots/module-root"; import("../calendar/roots/module-root").then((module) => ({ default: module.ModuleCalendarLayout }))
import { ModuleSpreadsheetLayout } from "../spreadsheet/roots/module-root"; );
const BaseGanttRoot = lazy(() => import("../gantt").then((module) => ({ default: module.BaseGanttRoot })));
const ModuleKanBanLayout = lazy(() =>
import("../kanban/roots/module-root").then((module) => ({ default: module.ModuleKanBanLayout }))
);
const ModuleListLayout = lazy(() =>
import("../list/roots/module-root").then((module) => ({ default: module.ModuleListLayout }))
);
const ModuleSpreadsheetLayout = lazy(() =>
import("../spreadsheet/roots/module-root").then((module) => ({ default: module.ModuleSpreadsheetLayout }))
);
function ModuleIssueLayout(props: { activeLayout: EIssueLayoutTypes | undefined; moduleId: string }) { function ModuleIssueLayout(props: { activeLayout: EIssueLayoutTypes | undefined; moduleId: string }) {
switch (props.activeLayout) { switch (props.activeLayout) {
@ -50,9 +61,11 @@ export const ModuleLayoutRoot = observer(function ModuleLayoutRoot() {
const moduleId = routerModuleId ? routerModuleId.toString() : undefined; const moduleId = routerModuleId ? routerModuleId.toString() : undefined;
// hooks // hooks
const { issuesFilter } = useIssues(EIssuesStoreType.MODULE); const { issuesFilter } = useIssues(EIssuesStoreType.MODULE);
const { peekIssue } = useIssueDetail();
// derived values // derived values
const workItemFilters = moduleId ? issuesFilter?.getIssueFilters(moduleId) : undefined; const workItemFilters = moduleId ? issuesFilter?.getIssueFilters(moduleId) : undefined;
const activeLayout = workItemFilters?.displayFilters?.layout || undefined; const activeLayout = workItemFilters?.displayFilters?.layout || undefined;
const shouldRenderPeekOverview = !!peekIssue?.workspaceSlug && !!peekIssue?.projectId && !!peekIssue?.issueId;
useSWR( useSWR(
workspaceSlug && projectId && moduleId workspaceSlug && projectId && moduleId
@ -90,10 +103,15 @@ export const ModuleLayoutRoot = observer(function ModuleLayoutRoot() {
/> />
)} )}
<Row variant={ERowVariant.HUGGING} className="h-full w-full overflow-auto"> <Row variant={ERowVariant.HUGGING} className="h-full w-full overflow-auto">
<ModuleIssueLayout activeLayout={activeLayout} moduleId={moduleId} /> <Suspense fallback={null}>
<ModuleIssueLayout activeLayout={activeLayout} moduleId={moduleId} />
</Suspense>
</Row> </Row>
{/* peek overview */} {shouldRenderPeekOverview && (
<IssuePeekOverview /> <Suspense fallback={null}>
<IssuePeekOverview />
</Suspense>
)}
</div> </div>
)} )}
</ProjectLevelWorkItemFiltersHOC> </ProjectLevelWorkItemFiltersHOC>

View File

@ -4,6 +4,7 @@
* See the LICENSE file for details. * See the LICENSE file for details.
*/ */
import { lazy, Suspense } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import useSWR from "swr"; import useSWR from "swr";
@ -15,15 +16,24 @@ import { Spinner } from "@plane/ui";
import { ProjectLevelWorkItemFiltersHOC } from "@/components/work-item-filters/filters-hoc/project-level"; import { ProjectLevelWorkItemFiltersHOC } from "@/components/work-item-filters/filters-hoc/project-level";
import { WorkItemFiltersRow } from "@/components/work-item-filters/filters-row"; import { WorkItemFiltersRow } from "@/components/work-item-filters/filters-row";
// hooks // hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useIssues } from "@/hooks/store/use-issues"; import { useIssues } from "@/hooks/store/use-issues";
import { IssuesStoreContext } from "@/hooks/use-issue-layout-store"; import { IssuesStoreContext } from "@/hooks/use-issue-layout-store";
// local imports
import { IssuePeekOverview } from "../../peek-overview"; const CalendarLayout = lazy(() =>
import { CalendarLayout } from "../calendar/roots/project-root"; import("../calendar/roots/project-root").then((module) => ({ default: module.CalendarLayout }))
import { BaseGanttRoot } from "../gantt"; );
import { KanBanLayout } from "../kanban/roots/project-root"; const BaseGanttRoot = lazy(() => import("../gantt").then((module) => ({ default: module.BaseGanttRoot })));
import { ListLayout } from "../list/roots/project-root"; const IssuePeekOverview = lazy(() =>
import { ProjectSpreadsheetLayout } from "../spreadsheet/roots/project-root"; import("../../peek-overview/root").then((module) => ({ default: module.IssuePeekOverview }))
);
const KanBanLayout = lazy(() =>
import("../kanban/roots/project-root").then((module) => ({ default: module.KanBanLayout }))
);
const ListLayout = lazy(() => import("../list/roots/project-root").then((module) => ({ default: module.ListLayout })));
const ProjectSpreadsheetLayout = lazy(() =>
import("../spreadsheet/roots/project-root").then((module) => ({ default: module.ProjectSpreadsheetLayout }))
);
function ProjectIssueLayout(props: { activeLayout: EIssueLayoutTypes | undefined }) { function ProjectIssueLayout(props: { activeLayout: EIssueLayoutTypes | undefined }) {
switch (props.activeLayout) { switch (props.activeLayout) {
@ -49,9 +59,11 @@ export const ProjectLayoutRoot = observer(function ProjectLayoutRoot() {
const projectId = routerProjectId ? routerProjectId.toString() : undefined; const projectId = routerProjectId ? routerProjectId.toString() : undefined;
// hooks // hooks
const { issues, issuesFilter } = useIssues(EIssuesStoreType.PROJECT); const { issues, issuesFilter } = useIssues(EIssuesStoreType.PROJECT);
const { peekIssue } = useIssueDetail();
// derived values // derived values
const workItemFilters = projectId ? issuesFilter?.getIssueFilters(projectId) : undefined; const workItemFilters = projectId ? issuesFilter?.getIssueFilters(projectId) : undefined;
const activeLayout = workItemFilters?.displayFilters?.layout; const activeLayout = workItemFilters?.displayFilters?.layout;
const shouldRenderPeekOverview = !!peekIssue?.workspaceSlug && !!peekIssue?.projectId && !!peekIssue?.issueId;
useSWR( useSWR(
workspaceSlug && projectId ? `PROJECT_ISSUES_${workspaceSlug}_${projectId}` : null, workspaceSlug && projectId ? `PROJECT_ISSUES_${workspaceSlug}_${projectId}` : null,
@ -93,10 +105,15 @@ export const ProjectLayoutRoot = observer(function ProjectLayoutRoot() {
<Spinner className="h-4 w-4" /> <Spinner className="h-4 w-4" />
</div> </div>
)} )}
<ProjectIssueLayout activeLayout={activeLayout} /> <Suspense fallback={null}>
<ProjectIssueLayout activeLayout={activeLayout} />
</Suspense>
</div> </div>
{/* peek overview */} {shouldRenderPeekOverview && (
<IssuePeekOverview /> <Suspense fallback={null}>
<IssuePeekOverview />
</Suspense>
)}
</div> </div>
)} )}
</ProjectLevelWorkItemFiltersHOC> </ProjectLevelWorkItemFiltersHOC>

View File

@ -4,7 +4,7 @@
* See the LICENSE file for details. * See the LICENSE file for details.
*/ */
import React, { useEffect } from "react"; import { lazy, Suspense, useEffect } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import useSWR from "swr"; import useSWR from "swr";
@ -14,16 +14,27 @@ import { EIssuesStoreType, EIssueLayoutTypes } from "@plane/types";
// hooks // hooks
import { ProjectLevelWorkItemFiltersHOC } from "@/components/work-item-filters/filters-hoc/project-level"; import { ProjectLevelWorkItemFiltersHOC } from "@/components/work-item-filters/filters-hoc/project-level";
import { WorkItemFiltersRow } from "@/components/work-item-filters/filters-row"; import { WorkItemFiltersRow } from "@/components/work-item-filters/filters-row";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useIssues } from "@/hooks/store/use-issues"; import { useIssues } from "@/hooks/store/use-issues";
import { useProjectView } from "@/hooks/store/use-project-view"; import { useProjectView } from "@/hooks/store/use-project-view";
import { IssuesStoreContext } from "@/hooks/use-issue-layout-store"; import { IssuesStoreContext } from "@/hooks/use-issue-layout-store";
// local imports
import { IssuePeekOverview } from "../../peek-overview"; const IssuePeekOverview = lazy(() =>
import { ProjectViewCalendarLayout } from "../calendar/roots/project-view-root"; import("../../peek-overview/root").then((module) => ({ default: module.IssuePeekOverview }))
import { BaseGanttRoot } from "../gantt"; );
import { ProjectViewKanBanLayout } from "../kanban/roots/project-view-root"; const ProjectViewCalendarLayout = lazy(() =>
import { ProjectViewListLayout } from "../list/roots/project-view-root"; import("../calendar/roots/project-view-root").then((module) => ({ default: module.ProjectViewCalendarLayout }))
import { ProjectViewSpreadsheetLayout } from "../spreadsheet/roots/project-view-root"; );
const BaseGanttRoot = lazy(() => import("../gantt").then((module) => ({ default: module.BaseGanttRoot })));
const ProjectViewKanBanLayout = lazy(() =>
import("../kanban/roots/project-view-root").then((module) => ({ default: module.ProjectViewKanBanLayout }))
);
const ProjectViewListLayout = lazy(() =>
import("../list/roots/project-view-root").then((module) => ({ default: module.ProjectViewListLayout }))
);
const ProjectViewSpreadsheetLayout = lazy(() =>
import("../spreadsheet/roots/project-view-root").then((module) => ({ default: module.ProjectViewSpreadsheetLayout }))
);
function ProjectViewIssueLayout(props: { activeLayout: EIssueLayoutTypes | undefined; viewId: string }) { function ProjectViewIssueLayout(props: { activeLayout: EIssueLayoutTypes | undefined; viewId: string }) {
switch (props.activeLayout) { switch (props.activeLayout) {
@ -51,6 +62,7 @@ export const ProjectViewLayoutRoot = observer(function ProjectViewLayoutRoot() {
// hooks // hooks
const { issuesFilter } = useIssues(EIssuesStoreType.PROJECT_VIEW); const { issuesFilter } = useIssues(EIssuesStoreType.PROJECT_VIEW);
const { getViewById } = useProjectView(); const { getViewById } = useProjectView();
const { peekIssue } = useIssueDetail();
// derived values // derived values
const projectView = viewId ? getViewById(viewId) : undefined; const projectView = viewId ? getViewById(viewId) : undefined;
const workItemFilters = viewId ? issuesFilter?.getIssueFilters(viewId) : undefined; const workItemFilters = viewId ? issuesFilter?.getIssueFilters(viewId) : undefined;
@ -63,6 +75,7 @@ export const ProjectViewLayoutRoot = observer(function ProjectViewLayoutRoot() {
richFilters: projectView.rich_filters, richFilters: projectView.rich_filters,
} }
: undefined; : undefined;
const shouldRenderPeekOverview = !!peekIssue?.workspaceSlug && !!peekIssue?.projectId && !!peekIssue?.issueId;
useSWR( useSWR(
workspaceSlug && projectId && viewId ? `PROJECT_VIEW_ISSUES_${workspaceSlug}_${projectId}_${viewId}` : null, workspaceSlug && projectId && viewId ? `PROJECT_VIEW_ISSUES_${workspaceSlug}_${projectId}_${viewId}` : null,
@ -110,10 +123,15 @@ export const ProjectViewLayoutRoot = observer(function ProjectViewLayoutRoot() {
/> />
)} )}
<div className="relative h-full w-full overflow-auto"> <div className="relative h-full w-full overflow-auto">
<ProjectViewIssueLayout activeLayout={activeLayout} viewId={viewId.toString()} /> <Suspense fallback={null}>
<ProjectViewIssueLayout activeLayout={activeLayout} viewId={viewId.toString()} />
</Suspense>
</div> </div>
{/* peek overview */} {shouldRenderPeekOverview && (
<IssuePeekOverview /> <Suspense fallback={null}>
<IssuePeekOverview />
</Suspense>
)}
</div> </div>
)} )}
</ProjectLevelWorkItemFiltersHOC> </ProjectLevelWorkItemFiltersHOC>

View File

@ -4,6 +4,7 @@
* See the LICENSE file for details. * See the LICENSE file for details.
*/ */
import { lazy, Suspense } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useParams, useSearchParams } from "next/navigation"; import { useParams, useSearchParams } from "next/navigation";
// components // components
@ -14,7 +15,7 @@ import { EUserProjectRoles } from "@plane/types";
import { ContentWrapper, Row, ERowVariant } from "@plane/ui"; import { ContentWrapper, Row, ERowVariant } from "@plane/ui";
// components // components
import { ListLayout } from "@/components/core/list"; import { ListLayout } from "@/components/core/list";
import { ModuleCardItem, ModuleListItem, ModulePeekOverview, ModulesListGanttChartView } from "@/components/modules"; import { ModuleCardItem, ModuleListItem, ModulePeekOverview } from "@/components/modules";
import { CycleModuleBoardLayoutLoader } from "@/components/ui/loader/cycle-module-board-loader"; import { CycleModuleBoardLayoutLoader } from "@/components/ui/loader/cycle-module-board-loader";
import { CycleModuleListLayoutLoader } from "@/components/ui/loader/cycle-module-list-loader"; import { CycleModuleListLayoutLoader } from "@/components/ui/loader/cycle-module-list-loader";
import { GanttLayoutLoader } from "@/components/ui/loader/layouts/gantt-layout-loader"; import { GanttLayoutLoader } from "@/components/ui/loader/layouts/gantt-layout-loader";
@ -24,6 +25,12 @@ import { useModule } from "@/hooks/store/use-module";
import { useModuleFilter } from "@/hooks/store/use-module-filter"; import { useModuleFilter } from "@/hooks/store/use-module-filter";
import { useUserPermissions } from "@/hooks/store/user"; import { useUserPermissions } from "@/hooks/store/user";
const ModulesListGanttChartView = lazy(() =>
import("@/components/modules/gantt-chart/modules-list-layout").then((module) => ({
default: module.ModulesListGanttChartView,
}))
);
export const ModulesListView = observer(function ModulesListView() { export const ModulesListView = observer(function ModulesListView() {
// router // router
const { workspaceSlug, projectId } = useParams(); const { workspaceSlug, projectId } = useParams();
@ -105,7 +112,9 @@ export const ModulesListView = observer(function ModulesListView() {
)} )}
{displayFilters?.layout === "gantt" && ( {displayFilters?.layout === "gantt" && (
<div className="size-full overflow-hidden"> <div className="size-full overflow-hidden">
<ModulesListGanttChartView /> <Suspense fallback={<GanttLayoutLoader />}>
<ModulesListGanttChartView />
</Suspense>
</div> </div>
)} )}
<div className="flex-shrink-0"> <div className="flex-shrink-0">

View File

@ -0,0 +1,105 @@
"use client";
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { lazy, Suspense, useState } from "react";
import { useTranslation } from "@plane/i18n";
import { SearchIcon } from "@plane/propel/icons";
import { cn } from "@plane/utils";
const TopNavPowerK = lazy(() =>
import("./top-nav-power-k").then((module) => ({
default: module.TopNavPowerK,
}))
);
type TDeferredTopNavPowerKProps = {
variant?: "top-navigation" | "sidebar" | "expanded-toolbar";
};
export const DeferredTopNavPowerK = ({ variant = "top-navigation" }: TDeferredTopNavPowerKProps) => {
const { t } = useTranslation();
const [shouldLoad, setShouldLoad] = useState(false);
const [shouldAutoOpen, setShouldAutoOpen] = useState(false);
const searchLabel = t("power_k.search_menu.quick_command_placeholder");
const handleOpen = () => {
setShouldAutoOpen(true);
setShouldLoad(true);
};
if (shouldLoad) {
return (
<Suspense fallback={<TopNavPowerKFallback variant={variant} onOpen={handleOpen} label={searchLabel} />}>
<TopNavPowerK
autoOpen={shouldAutoOpen}
variant={variant}
onAutoOpenComplete={() => setShouldAutoOpen(false)}
/>
</Suspense>
);
}
return <TopNavPowerKFallback variant={variant} onOpen={handleOpen} label={searchLabel} />;
};
const TopNavPowerKFallback = ({
label,
onOpen,
variant,
}: {
label: string;
onOpen: () => void;
variant: "top-navigation" | "sidebar" | "expanded-toolbar";
}) => {
if (variant === "expanded-toolbar") {
return (
<div className="nodedc-expanded-search-control" data-open={false}>
<button
type="button"
className="nodedc-expanded-tool-button nodedc-expanded-search-trigger"
aria-label={label}
aria-pressed={false}
onClick={onOpen}
>
<SearchIcon className="size-4 shrink-0" />
</button>
</div>
);
}
if (variant === "sidebar") {
return (
<div className="relative z-30 h-8 w-8">
<button
type="button"
className="absolute left-0 top-0 z-[161] flex size-8 items-center justify-center rounded-full border-0 bg-white/[0.04] text-placeholder backdrop-blur-[18px] outline-none transition-all hover:bg-white/[0.07]"
aria-label={label}
onClick={onOpen}
>
<SearchIcon className="size-3.5 shrink-0 text-placeholder" />
</button>
</div>
);
}
return (
<div className="relative z-30 flex w-[364px] items-center transition-all duration-300 ease-in-out">
<button
type="button"
className={cn(
"flex h-7 w-full items-center rounded-lg border border-subtle-1 bg-layer-2 p-2 text-left transition-colors duration-200"
)}
aria-label={label}
onClick={onOpen}
>
<SearchIcon className="mr-2 size-3.5 shrink-0 text-placeholder" />
<span className="min-w-0 flex-1 text-13 text-placeholder">{label}</span>
</button>
</div>
);
};

View File

@ -4,7 +4,7 @@
* See the LICENSE file for details. * See the LICENSE file for details.
*/ */
import React, { useEffect } from "react"; import React, { lazy, Suspense, useEffect } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useParams, useLocation, Link, useNavigate } from "react-router"; import { useParams, useLocation, Link, useNavigate } from "react-router";
import { EUserPermissionsLevel, EUserPermissions } from "@plane/constants"; import { EUserPermissionsLevel, EUserPermissions } from "@plane/constants";
@ -18,8 +18,6 @@ import { useUserPermissions } from "@/hooks/store/user";
// plane web imports // plane web imports
import { useNavigationItems } from "@/plane-web/components/navigations"; import { useNavigationItems } from "@/plane-web/components/navigations";
// local imports // local imports
import { LeaveProjectModal } from "../project/leave-project-modal";
import { PublishProjectModal } from "../project/publish-project/modal";
import { ProjectActionsMenu } from "./project-actions-menu"; import { ProjectActionsMenu } from "./project-actions-menu";
import { ProjectHeader } from "./project-header"; import { ProjectHeader } from "./project-header";
import { TabNavigationOverflowMenu } from "./tab-navigation-overflow-menu"; import { TabNavigationOverflowMenu } from "./tab-navigation-overflow-menu";
@ -30,6 +28,18 @@ import { useProjectActions } from "./use-project-actions";
import { useResponsiveTabLayout } from "./use-responsive-tab-layout"; import { useResponsiveTabLayout } from "./use-responsive-tab-layout";
import { useTabPreferences } from "./use-tab-preferences"; import { useTabPreferences } from "./use-tab-preferences";
const LeaveProjectModal = lazy(() =>
import("../project/leave-project-modal").then((module) => ({
default: module.LeaveProjectModal,
}))
);
const PublishProjectModal = lazy(() =>
import("../project/publish-project/modal").then((module) => ({
default: module.PublishProjectModal,
}))
);
// Local type definition for navigation items with app-specific fields // Local type definition for navigation items with app-specific fields
export type TNavigationItem = { export type TNavigationItem = {
name: string; name: string;
@ -109,7 +119,7 @@ export const TabNavigationRoot = observer(function TabNavigationRoot(props: TTab
// Filter and sort navigation items // Filter and sort navigation items
const allNavigationItems = navigationItems const allNavigationItems = navigationItems
.filter((item) => item.shouldRender) .filter((item) => item.shouldRender)
.sort((a, b) => a.sortOrder - b.sortOrder); .toSorted((a, b) => a.sortOrder - b.sortOrder);
// Split items into two categories: // Split items into two categories:
// 1. visibleNavigationItems: Items NOT user-hidden (may still overflow due to space) // 1. visibleNavigationItems: Items NOT user-hidden (may still overflow due to space)
@ -162,12 +172,18 @@ export const TabNavigationRoot = observer(function TabNavigationRoot(props: TTab
return ( return (
<> <>
<PublishProjectModal isOpen={publishModalOpen} projectId={projectId} onClose={() => handlePublishModal(false)} /> <Suspense fallback={null}>
<LeaveProjectModal {publishModalOpen && (
project={project} <PublishProjectModal isOpen={publishModalOpen} projectId={projectId} onClose={() => handlePublishModal(false)} />
isOpen={leaveProjectModalOpen} )}
onClose={() => handleLeaveProjectModal(false)} {leaveProjectModalOpen && (
/> <LeaveProjectModal
project={project}
isOpen={leaveProjectModalOpen}
onClose={() => handleLeaveProjectModal(false)}
/>
)}
</Suspense>
{/* container for the tab navigation */} {/* container for the tab navigation */}
<div className="flex size-full items-center gap-3 overflow-hidden"> <div className="flex size-full items-center gap-3 overflow-hidden">

View File

@ -4,7 +4,7 @@
* See the LICENSE file for details. * See the LICENSE file for details.
*/ */
import { useState, useMemo, useCallback, useEffect, useLayoutEffect, useRef } from "react"; import { lazy, Suspense, useState, useMemo, useCallback, useEffect, useLayoutEffect, useRef } from "react";
import { Command } from "cmdk"; import { Command } from "cmdk";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
@ -15,20 +15,32 @@ import { CloseIcon, SearchIcon } from "@plane/propel/icons";
import { cn } from "@plane/utils"; import { cn } from "@plane/utils";
// power-k // power-k
import type { TPowerKCommandConfig, TPowerKContext } from "@/components/power-k/core/types"; import type { TPowerKCommandConfig, TPowerKContext } from "@/components/power-k/core/types";
import { ProjectsAppPowerKCommandsList } from "@/components/power-k/ui/modal/commands-list";
import { PowerKModalFooter } from "@/components/power-k/ui/modal/footer";
import { useIssueDetail } from "@/hooks/store/use-issue-detail"; import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { usePowerK } from "@/hooks/store/use-power-k"; import { usePowerK } from "@/hooks/store/use-power-k";
import { useUser } from "@/hooks/store/user"; import { useUser } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router"; import { useAppRouter } from "@/hooks/use-app-router";
import { useExpandableSearch } from "@/hooks/use-expandable-search"; import { useExpandableSearch } from "@/hooks/use-expandable-search";
const ProjectsAppPowerKCommandsList = lazy(() =>
import("@/components/power-k/ui/modal/commands-list").then((module) => ({
default: module.ProjectsAppPowerKCommandsList,
}))
);
const PowerKModalFooter = lazy(() =>
import("@/components/power-k/ui/modal/footer").then((module) => ({
default: module.PowerKModalFooter,
}))
);
type TTopNavPowerKProps = { type TTopNavPowerKProps = {
autoOpen?: boolean;
onAutoOpenComplete?: () => void;
variant?: "top-navigation" | "sidebar" | "expanded-toolbar"; variant?: "top-navigation" | "sidebar" | "expanded-toolbar";
}; };
export const TopNavPowerK = observer((props: TTopNavPowerKProps) => { export const TopNavPowerK = observer((props: TTopNavPowerKProps) => {
const { variant = "top-navigation" } = props; const { autoOpen = false, onAutoOpenComplete, variant = "top-navigation" } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const isWideSearch = variant === "top-navigation" || variant === "expanded-toolbar"; const isWideSearch = variant === "top-navigation" || variant === "expanded-toolbar";
const isExpandedToolbar = variant === "expanded-toolbar"; const isExpandedToolbar = variant === "expanded-toolbar";
@ -51,6 +63,7 @@ export const TopNavPowerK = observer((props: TTopNavPowerKProps) => {
const sidebarSearchPortalRef = useRef<HTMLDivElement>(null); const sidebarSearchPortalRef = useRef<HTMLDivElement>(null);
const sidebarSearchButtonRef = useRef<HTMLButtonElement>(null); const sidebarSearchButtonRef = useRef<HTMLButtonElement>(null);
const autoOpenHandledRef = useRef(false);
// store hooks // store hooks
const { activeContext, setActivePage, activePage, setTopNavInputRef } = usePowerK(); const { activeContext, setActivePage, activePage, setTopNavInputRef } = usePowerK();
@ -76,6 +89,15 @@ export const TopNavPowerK = observer((props: TTopNavPowerKProps) => {
additionalRefs: [sidebarSearchPortalRef], additionalRefs: [sidebarSearchPortalRef],
}); });
useEffect(() => {
if (!autoOpen || autoOpenHandledRef.current) return;
autoOpenHandledRef.current = true;
openPanel();
requestAnimationFrame(() => inputRef.current?.focus());
onAutoOpenComplete?.();
}, [autoOpen, inputRef, onAutoOpenComplete, openPanel]);
// derived values // derived values
const { const {
issue: { getIssueById, getIssueIdByIdentifier }, issue: { getIssueById, getIssueIdByIdentifier },
@ -277,22 +299,26 @@ export const TopNavPowerK = observer((props: TTopNavPowerKProps) => {
> >
<Command.Input value={searchTerm} hidden /> <Command.Input value={searchTerm} hidden />
<Command.List className="vertical-scrollbar scrollbar-sm max-h-[60vh] overflow-y-auto px-2 pb-4 outline-none"> <Command.List className="vertical-scrollbar scrollbar-sm max-h-[60vh] overflow-y-auto px-2 pb-4 outline-none">
<ProjectsAppPowerKCommandsList <Suspense fallback={null}>
activePage={activePage} <ProjectsAppPowerKCommandsList
context={context} activePage={activePage}
handleCommandSelect={handleCommandSelect} context={context}
handlePageDataSelection={handlePageDataSelection} handleCommandSelect={handleCommandSelect}
isWorkspaceLevel={isWorkspaceLevel} handlePageDataSelection={handlePageDataSelection}
searchTerm={searchTerm} isWorkspaceLevel={isWorkspaceLevel}
setSearchTerm={setSearchTerm} searchTerm={searchTerm}
handleSearchMenuClose={() => closePanel()} setSearchTerm={setSearchTerm}
/> handleSearchMenuClose={() => closePanel()}
/>
</Suspense>
</Command.List> </Command.List>
<PowerKModalFooter <Suspense fallback={null}>
isWorkspaceLevel={isWorkspaceLevel} <PowerKModalFooter
projectId={context.params.projectId?.toString()} isWorkspaceLevel={isWorkspaceLevel}
onWorkspaceLevelChange={setIsWorkspaceLevel} projectId={context.params.projectId?.toString()}
/> onWorkspaceLevelChange={setIsWorkspaceLevel}
/>
</Suspense>
</Command> </Command>
); );

View File

@ -4,24 +4,53 @@
* See the LICENSE file for details. * See the LICENSE file for details.
*/ */
import { useMemo, useState } from "react"; import { lazy, Suspense, useMemo, useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
// hooks // hooks
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useIssueDetail } from "@/hooks/store/use-issue-detail"; import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { usePowerK } from "@/hooks/store/use-power-k"; import { usePowerK } from "@/hooks/store/use-power-k";
import { useUser } from "@/hooks/store/user"; import { useUser } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router"; import { useAppRouter } from "@/hooks/use-app-router";
// plane web imports
import { ProjectLevelModals } from "@/plane-web/components/command-palette/modals/project-level";
import { WorkItemLevelModals } from "@/plane-web/components/command-palette/modals/work-item-level";
import { WorkspaceLevelModals } from "@/plane-web/components/command-palette/modals/workspace-level";
// local imports // local imports
import { useProjectsAppPowerKCommands } from "./config/commands"; import { useProjectsAppPowerKCommands } from "./config/commands";
import type { TPowerKCommandConfig, TPowerKContext } from "./core/types"; import type { TPowerKCommandConfig, TPowerKContext } from "./core/types";
import { GlobalShortcutsProvider } from "./global-shortcuts"; import { GlobalShortcutsProvider } from "./global-shortcuts";
import { ProjectsAppPowerKCommandsList } from "./ui/modal/commands-list";
import { ProjectsAppPowerKModalWrapper } from "./ui/modal/wrapper"; const WorkspaceLevelModals = lazy(() =>
import("@/plane-web/components/command-palette/modals/workspace-level").then((module) => ({
default: module.WorkspaceLevelModals,
}))
);
const ProjectLevelModals = lazy(() =>
import("@/plane-web/components/command-palette/modals/project-level").then((module) => ({
default: module.ProjectLevelModals,
}))
);
const WorkItemLevelModals = lazy(() =>
import("@/plane-web/components/command-palette/modals/work-item-level").then((module) => ({
default: module.WorkItemLevelModals,
}))
);
const ProjectsAppPowerKModal = lazy(async () => {
const [wrapperModule, commandsListModule] = await Promise.all([
import("./ui/modal/wrapper"),
import("./ui/modal/commands-list"),
]);
return {
default: (props: Omit<Parameters<typeof wrapperModule.ProjectsAppPowerKModalWrapper>[0], "commandsListComponent">) => (
<wrapperModule.ProjectsAppPowerKModalWrapper
{...props}
commandsListComponent={commandsListModule.ProjectsAppPowerKCommandsList}
/>
),
};
});
/** /**
* Projects App PowerK provider * Projects App PowerK provider
@ -36,6 +65,16 @@ export const ProjectsAppPowerKProvider = observer(function ProjectsAppPowerKProv
const [shouldShowContextBasedActions, setShouldShowContextBasedActions] = useState(true); const [shouldShowContextBasedActions, setShouldShowContextBasedActions] = useState(true);
// store hooks // store hooks
const { activeContext, isPowerKModalOpen, togglePowerKModal, setActivePage } = usePowerK(); const { activeContext, isPowerKModalOpen, togglePowerKModal, setActivePage } = usePowerK();
const {
createPageModal,
isBulkDeleteIssueModalOpen,
isCreateCycleModalOpen,
isCreateIssueModalOpen,
isCreateModuleModalOpen,
isCreateProjectModalOpen,
isCreateViewModalOpen,
isDeleteIssueModalOpen,
} = useCommandPalette();
const { data: currentUser } = useUser(); const { data: currentUser } = useUser();
// derived values // derived values
const { const {
@ -46,6 +85,15 @@ export const ProjectsAppPowerKProvider = observer(function ProjectsAppPowerKProv
const workItemDetails = workItemId ? getIssueById(workItemId) : undefined; const workItemDetails = workItemId ? getIssueById(workItemId) : undefined;
const projectId: string | string[] | undefined | null = routerProjectId ?? workItemDetails?.project_id; const projectId: string | string[] | undefined | null = routerProjectId ?? workItemDetails?.project_id;
const commands = useProjectsAppPowerKCommands(); const commands = useProjectsAppPowerKCommands();
const shouldLoadWorkspaceLevelModals = Boolean(workspaceSlug && isCreateProjectModalOpen);
const shouldLoadProjectLevelModals = Boolean(
workspaceSlug &&
projectId &&
(isCreateCycleModalOpen || isCreateModuleModalOpen || isCreateViewModalOpen || createPageModal.isOpen)
);
const shouldLoadWorkItemLevelModals = Boolean(
isCreateIssueModalOpen || isDeleteIssueModalOpen || isBulkDeleteIssueModalOpen
);
// Build command context from props and store // Build command context from props and store
const context: TPowerKContext = useMemo( const context: TPowerKContext = useMemo(
() => ({ () => ({
@ -79,17 +127,16 @@ export const ProjectsAppPowerKProvider = observer(function ProjectsAppPowerKProv
return ( return (
<> <>
<GlobalShortcutsProvider context={context} commands={commands} /> <GlobalShortcutsProvider context={context} commands={commands} />
{workspaceSlug && <WorkspaceLevelModals workspaceSlug={workspaceSlug.toString()} />} <Suspense fallback={null}>
{workspaceSlug && projectId && ( {shouldLoadWorkspaceLevelModals && <WorkspaceLevelModals workspaceSlug={workspaceSlug!.toString()} />}
<ProjectLevelModals workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} /> {shouldLoadProjectLevelModals && (
)} <ProjectLevelModals workspaceSlug={workspaceSlug!.toString()} projectId={projectId!.toString()} />
<WorkItemLevelModals workItemIdentifier={workItemIdentifier?.toString()} /> )}
<ProjectsAppPowerKModalWrapper {shouldLoadWorkItemLevelModals && <WorkItemLevelModals workItemIdentifier={workItemIdentifier?.toString()} />}
commandsListComponent={ProjectsAppPowerKCommandsList} {isPowerKModalOpen && (
context={context} <ProjectsAppPowerKModal context={context} isOpen={isPowerKModalOpen} onClose={() => togglePowerKModal(false)} />
isOpen={isPowerKModalOpen} )}
onClose={() => togglePowerKModal(false)} </Suspense>
/>
</> </>
); );
}); });

View File

@ -6,7 +6,7 @@
import { type ChangeEvent, useMemo, useRef, useState } from "react"; import { type ChangeEvent, useMemo, useRef, useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { Bot, Check, KeyRound, Route, ShieldCheck } from "lucide-react"; import { Bot, Check, Copy, KeyRound, Route, ShieldCheck } from "lucide-react";
import useSWR from "swr"; import useSWR from "swr";
import { Button } from "@plane/propel/button"; import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import { TOAST_TYPE, setToast } from "@plane/propel/toast";
@ -40,8 +40,13 @@ const TASK_AUTHOR_SCOPES = [
]; ];
const AGENT_AVATAR_ACCEPT = "image/png,image/jpeg,image/webp,image/gif"; const AGENT_AVATAR_ACCEPT = "image/png,image/jpeg,image/webp,image/gif";
const MAX_AGENT_AVATAR_BYTES = 256 * 1024; const MAX_AGENT_AVATAR_SOURCE_BYTES = 50 * 1024 * 1024;
const AGENT_AVATAR_RENDER_SIZE = 512;
const AGENT_AVATAR_OUTPUT_QUALITY = 0.86;
const OPS_AGENT_FILENAME = "OPS_AGENT.md"; const OPS_AGENT_FILENAME = "OPS_AGENT.md";
const CODEX_MCP_SERVER_NAME = "nodedc-ops-agent";
const CODEX_TOKEN_ENV_VAR = "NODEDC_OPS_AGENT_TOKEN";
const DEFAULT_OPS_AGENT_MCP_ENDPOINT = "https://ops-agents.nodedc.ru/mcp";
const codexAgentService = new WorkspaceCodexAgentService(); const codexAgentService = new WorkspaceCodexAgentService();
const projectService = new ProjectService(); const projectService = new ProjectService();
@ -123,6 +128,9 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
() => mergeSetupCards(persistedSetupCards ?? [], createdSetupCards), () => mergeSetupCards(persistedSetupCards ?? [], createdSetupCards),
[createdSetupCards, persistedSetupCards] [createdSetupCards, persistedSetupCards]
); );
const connectionGuideMcpEndpoint = getMcpEndpoint(setupCards.find((card) => card.setup)?.setup);
const connectionGuideConfigSnippet = buildCodexConfigSnippet(connectionGuideMcpEndpoint);
const connectionGuideOpsAgentMd = buildOpsAgentMarkdown(connectionGuideMcpEndpoint);
const handleCopy = async (value: string, label: string) => { const handleCopy = async (value: string, label: string) => {
await navigator.clipboard.writeText(value); await navigator.clipboard.writeText(value);
@ -241,8 +249,8 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
await mutateSetupCards(); await mutateSetupCards();
setToast({ setToast({
type: TOAST_TYPE.SUCCESS, type: TOAST_TYPE.SUCCESS,
title: "Новый token выпущен", title: "Новый токен выпущен",
message: "Скопируйте token сейчас. После перезахода backend вернет только masked suffix.", message: "Скопируйте токен сейчас. После перезахода backend вернет только masked suffix.",
}); });
} catch (error: any) { } catch (error: any) {
setToast({ setToast({
@ -258,6 +266,7 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
const handleSaveAgentName = async (agent: TCodexAgent) => { const handleSaveAgentName = async (agent: TCodexAgent) => {
const displayName = getAgentDraftName(agentDraftNames, agent).trim(); const displayName = getAgentDraftName(agentDraftNames, agent).trim();
if (!displayName) return; if (!displayName) return;
if (displayName === agent.display_name) return;
setUpdatingAgentIds((current) => ({ ...current, [agent.id]: true })); setUpdatingAgentIds((current) => ({ ...current, [agent.id]: true }));
try { try {
@ -422,7 +431,6 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
const isAgentDirty = draftName.trim() !== agent.display_name; const isAgentDirty = draftName.trim() !== agent.display_name;
const setupCard = setupCards.find((card) => card.agent.id === agent.id); const setupCard = setupCards.find((card) => card.agent.id === agent.id);
const agentTokens = setupCard?.tokens ?? []; const agentTokens = setupCard?.tokens ?? [];
const setup = setupCard?.setup;
return ( return (
<section key={agent.id} className="nodedc-settings-card flex flex-col gap-5 px-5 py-5"> <section key={agent.id} className="nodedc-settings-card flex flex-col gap-5 px-5 py-5">
@ -456,6 +464,14 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
[agent.id]: event.target.value, [agent.id]: event.target.value,
})) }))
} }
onBlur={() => {
if (isAgentDirty && !isUpdatingAgent) void handleSaveAgentName(agent);
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.currentTarget.blur();
}
}}
placeholder="Имя агента" placeholder="Имя агента"
/> />
</label> </label>
@ -483,7 +499,7 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
loading={isCreatingToken} loading={isCreatingToken}
onClick={() => void handleCreateToken(agent)} onClick={() => void handleCreateToken(agent)}
> >
Новый token Новый токен
</Button> </Button>
<Button <Button
variant="secondary" variant="secondary"
@ -498,73 +514,32 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
{areSetupCardsLoading && agentTokens.length === 0 ? ( {areSetupCardsLoading && agentTokens.length === 0 ? (
<div className="nodedc-settings-field px-4 py-4 text-13 text-secondary"> <div className="nodedc-settings-field px-4 py-4 text-13 text-secondary">
Загрузка token и Ops Agent.md... Загрузка токена...
</div> </div>
) : agentTokens.length > 0 ? ( ) : agentTokens.length > 0 ? (
<div className="grid gap-4"> <div className="grid gap-4">
{agentTokens.map((token) => { {agentTokens.map((token) => {
const revealedToken = revealedTokens[token.id]; const revealedToken = revealedTokens[token.id];
const tokenValue = revealedToken ?? maskToken(token); const tokenValue = revealedToken ?? maskToken(token);
const isTokenRevealed = Boolean(revealedToken);
return ( return (
<div key={token.id} className="grid gap-4 lg:grid-cols-[minmax(0,0.8fr)_minmax(0,1.4fr)]"> <div key={token.id} className="nodedc-settings-field p-4">
<div className="nodedc-settings-field p-4"> <div className="mb-2 text-12 font-semibold tracking-wide text-tertiary uppercase">
<div className="mb-2 text-12 font-semibold tracking-wide text-tertiary uppercase"> Agent token
Agent token
</div>
<code className="nodedc-settings-input block min-h-12 px-3 py-3 text-12 break-all text-primary">
{tokenValue}
</code>
{isTokenRevealed && (
<div className="mt-3 flex flex-wrap gap-2">
<Button
variant="secondary"
size="sm"
className="nodedc-settings-chip"
onClick={() => void handleCopy(revealedToken, "Токен")}
>
Скопировать токен
</Button>
</div>
)}
</div> </div>
<div className="relative">
<div className="nodedc-settings-field p-4"> <code className="nodedc-settings-input flex h-12 w-full items-center overflow-hidden px-4 pr-14 text-12 text-primary">
<div className="mb-2 text-12 font-semibold tracking-wide text-tertiary uppercase"> <span className="truncate">{tokenValue}</span>
Ops Agent.md </code>
</div> <button
{setup?.agents_md ? ( type="button"
<> aria-label="Скопировать токен"
<textarea className="absolute top-1/2 right-1.5 grid size-9 -translate-y-1/2 place-items-center rounded-full bg-[rgb(var(--nodedc-accent-rgb))] text-[rgb(var(--nodedc-on-accent-rgb))] transition hover:opacity-90 disabled:bg-white/10 disabled:text-tertiary disabled:opacity-60"
readOnly disabled={!revealedToken}
className="nodedc-settings-input font-mono h-64 w-full resize-y px-3 py-3 text-12" onClick={() => revealedToken && void handleCopy(revealedToken, "Токен")}
value={setup.agents_md} >
/> <Copy className="size-4" />
<div className="mt-3 flex flex-wrap gap-2"> </button>
<Button
variant="secondary"
size="sm"
className="nodedc-settings-chip"
onClick={() => void handleCopy(setup.agents_md ?? "", "Ops Agent.md")}
>
Скопировать Ops Agent.md
</Button>
<Button
variant="secondary"
size="sm"
className="nodedc-settings-chip"
onClick={() => handleDownload(setup.agents_md ?? "", OPS_AGENT_FILENAME)}
>
Скачать
</Button>
</div>
</>
) : (
<div className="nodedc-settings-input flex min-h-24 items-center px-4 text-13 text-secondary">
Setup packet пока недоступен. Проверьте Gateway и grants агента.
</div>
)}
</div> </div>
</div> </div>
); );
@ -572,7 +547,7 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
</div> </div>
) : ( ) : (
<div className="nodedc-settings-field px-4 py-4 text-13 text-secondary"> <div className="nodedc-settings-field px-4 py-4 text-13 text-secondary">
Token ещё не выпущен. Нажмите «Новый token», чтобы получить token и Ops Agent.md. Токен ещё не выпущен. Нажмите «Новый токен», чтобы получить доступ для локального Codex.
</div> </div>
)} )}
</section> </section>
@ -580,9 +555,20 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
}) })
) : ( ) : (
<div className="nodedc-settings-card px-5 py-5 text-center text-13 text-secondary"> <div className="nodedc-settings-card px-5 py-5 text-center text-13 text-secondary">
Агентов пока нет. Создайте агента, выберите project и сразу получите token + Ops Agent.md. Агентов пока нет. Создайте агента, выберите project и сразу получите токен доступа.
</div> </div>
)} )}
{activeAgents.length > 0 && (
<section className="nodedc-settings-card px-5 py-5">
<CodexConnectionGuide
configSnippet={connectionGuideConfigSnippet}
mcpEndpoint={connectionGuideMcpEndpoint}
onCopyConfig={() => void handleCopy(connectionGuideConfigSnippet, "config.toml")}
onDownloadAgentsMd={() => handleDownload(connectionGuideOpsAgentMd, OPS_AGENT_FILENAME)}
/>
</section>
)}
</> </>
)} )}
</> </>
@ -591,6 +577,154 @@ export const CodexAgentApiSettingsContent = observer(function CodexAgentApiSetti
); );
}); });
type TCodexConnectionGuideProps = {
configSnippet: string;
mcpEndpoint: string;
onCopyConfig: () => void;
onDownloadAgentsMd: () => void;
};
function CodexConnectionGuide(props: TCodexConnectionGuideProps) {
return (
<div className="grid gap-4 p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<div className="text-14 font-semibold text-primary">Подключение локального Codex</div>
</div>
<span className="nodedc-settings-chip inline-flex min-h-11 w-fit items-center justify-center text-12">
MCP endpoint · {props.mcpEndpoint}
</span>
</div>
<div className="grid gap-4 xl:grid-cols-3">
<div className="nodedc-settings-input min-h-36 px-4 py-4 text-13 leading-5 text-secondary">
<div className="mb-2 font-semibold text-primary">1. Найдите config.toml</div>
<p>Windows: откройте файл через проводник или VS Code.</p>
<code className="mt-2 block break-all text-12 text-primary">
C:\Users\имя-пользователя\.codex\config.toml
</code>
<p className="mt-3">macOS / Linux:</p>
<code className="mt-2 block break-all text-12 text-primary">~/.codex/config.toml</code>
<p className="mt-3">Если файла нет создайте его.</p>
</div>
<div className="nodedc-settings-input min-h-36 px-4 py-4 text-13 leading-5 text-secondary">
<div className="mb-2 font-semibold text-primary">2. Сохраните токен</div>
<p>
Создайте пользовательскую переменную окружения <code>{CODEX_TOKEN_ENV_VAR}</code>, заменив токен из примера
на уникальный токен конкретного агента.
</p>
<code className="mt-3 block break-all rounded-2xl bg-black/20 px-3 py-3 text-12 text-primary">
{CODEX_TOKEN_ENV_VAR}=ndcag_...
</code>
</div>
<div className="nodedc-settings-input min-h-36 px-4 py-4 text-13 leading-5 text-secondary">
<div className="mb-2 font-semibold text-primary">3. Добавьте Ops Agent.md</div>
<p>
Скачайте <code>{OPS_AGENT_FILENAME}</code>. Если в проекте уже есть <code>AGENTS.md</code>, добавьте
содержимое Ops Agent.md в начало текущего файла. Если файла правил нет положите Ops Agent.md в корень
проекта.
</p>
<Button
variant="primary"
size="sm"
className="nodedc-settings-save-button mt-3"
onClick={props.onDownloadAgentsMd}
>
Скачать Ops Agent.md
</Button>
</div>
</div>
<div className="nodedc-settings-field p-4">
<div className="mb-2 text-12 font-semibold tracking-wide text-tertiary uppercase">config.toml block</div>
<textarea
readOnly
className="nodedc-settings-input font-mono h-44 w-full resize-y px-3 py-3 text-12"
value={props.configSnippet}
/>
<div className="mt-3 flex flex-wrap gap-2">
<Button variant="primary" size="sm" className="nodedc-settings-save-button" onClick={props.onCopyConfig}>
Скопировать config.toml
</Button>
</div>
</div>
</div>
);
}
function getMcpEndpoint(setup?: TCodexAgentSetupPacket): string {
return setup?.mcp_server?.url ?? DEFAULT_OPS_AGENT_MCP_ENDPOINT;
}
function buildCodexConfigSnippet(endpoint: string): string {
return `[mcp_servers.${CODEX_MCP_SERVER_NAME}]
url = "${endpoint}"
bearer_token_env_var = "${CODEX_TOKEN_ENV_VAR}"
enabled = true
required = true
startup_timeout_sec = 20
tool_timeout_sec = 60`;
}
function buildOpsAgentMarkdown(endpoint: string): string {
return `# NODE.DC Ops Agent Rules
MCP endpoint: ${endpoint}
## Startup
- Call \`tasker_get_agent_instructions\` before creating or changing Tasker cards.
- Call \`tasker_list_projects\` and \`tasker_get_project_context\` before writing into a project.
- Keep Tasker as the source of truth for project cards, checkers, status, labels, comments, and assignments.
## Write Safety
- Every write tool call must include a unique \`idempotency_key\`.
- Never delete or archive Tasker cards, comments, labels, projects, states, members, or workspaces.
- Do not call raw Tasker APIs. Use only the NODE.DC MCP tools.
- Only assign existing project members returned by project context.
- If a needed label is missing, call \`tasker_ensure_labels\` first and then use returned ids with \`tasker_set_issue_labels\`.
## Card Writing
- Keep card titles concise and operational.
- Put current architecture, planned architecture, implementation notes, and validation into structured text blocks.
- Every structured text block must put its visible heading into the block \`title\` field.
- Do not put headings inside block body. Wrong: body starts with \`## Текущая архитектура\`. Correct: \`title: "Текущая архитектура"\`, body contains only content.
- Put short verifiable work items into checker blocks with explicit \`title\` fields.
- After code work, update the related card with factual files touched and validation performed.
## Labels
- Before assigning a new marker/label, check project context labels.
- If the label does not exist, call \`tasker_ensure_labels\` with the granted \`project_id\`.
- Use only label ids returned by project context or \`tasker_ensure_labels\` when calling \`tasker_set_issue_labels\`.
## Effective Grants
- Grants are bound to the local agent token.
- Load effective workspace/project grants through \`tasker_get_agent_instructions\` and \`tasker_list_projects\` after connecting.
- Do not assume access to projects, labels, states, or members that were not returned by NODE.DC MCP tools.
## Available Tools
- \`tasker_get_agent_instructions\`: Return effective NODE.DC Tasker card-writing rules, grants, scopes, and mode expectations.
- \`tasker_list_projects\`: List Tasker projects granted to the current agent.
- \`tasker_get_project_context\`: Return states, labels, members, and card-writing context for one granted project.
- \`tasker_search_issues\`: Search work items inside one granted Tasker project.
- \`tasker_create_issue\`: Create a Tasker card with optional NODE.DC structured text/checker blocks.
- \`tasker_update_issue\`: Patch allowed issue fields without delete, archive, or project transfer.
- \`tasker_update_structured_blocks\`: Replace NODE.DC structured text/checker blocks in an issue detail layout.
- \`tasker_move_issue\`: Move an issue to an existing state in the same granted project.
- \`tasker_append_comment\`: Append a comment to a granted issue.
- \`tasker_ensure_labels\`: Create missing labels in a granted project and return label ids.
- \`tasker_set_issue_labels\`: Replace issue labels with existing labels from the granted project.
- \`tasker_assign_issue\`: Replace issue assignees with existing members of the granted project.
`;
}
function getAgentDraftName(agentDraftNames: Record<string, string>, agent: TCodexAgent): string { function getAgentDraftName(agentDraftNames: Record<string, string>, agent: TCodexAgent): string {
return agentDraftNames[agent.id] ?? agent.display_name; return agentDraftNames[agent.id] ?? agent.display_name;
} }
@ -608,7 +742,7 @@ function maskToken(token: TCodexAgentToken): string {
function getAvatarSrc(avatarUrl?: string | null): string | null { function getAvatarSrc(avatarUrl?: string | null): string | null {
if (!avatarUrl) return null; if (!avatarUrl) return null;
if (/^(data:|blob:|https?:\/\/)/.test(avatarUrl)) return avatarUrl; if (/^(data:|blob:|https?:\/\/)/.test(avatarUrl)) return avatarUrl;
return getFileURL(avatarUrl); return getFileURL(avatarUrl) ?? null;
} }
function AgentAvatar(props: { avatarUrl?: string | null; name: string; size?: "md" | "lg" }) { function AgentAvatar(props: { avatarUrl?: string | null; name: string; size?: "md" | "lg" }) {
@ -702,15 +836,73 @@ function readAvatarDataUrl(file: File): Promise<string> {
return Promise.reject(new Error("Поддерживаются только изображения PNG, JPG, WEBP или GIF.")); return Promise.reject(new Error("Поддерживаются только изображения PNG, JPG, WEBP или GIF."));
} }
if (file.size > MAX_AGENT_AVATAR_BYTES) { if (file.size > MAX_AGENT_AVATAR_SOURCE_BYTES) {
return Promise.reject(new Error("Аватар агента должен быть не больше 256 КБ.")); return Promise.reject(new Error("Исходный файл аватара должен быть не больше 50 МБ."));
} }
return resizeAvatarFile(file);
}
async function resizeAvatarFile(file: File): Promise<string> {
const objectUrl = URL.createObjectURL(file);
try {
const image = await loadImageElement(objectUrl);
const sourceWidth = image.naturalWidth || image.width;
const sourceHeight = image.naturalHeight || image.height;
if (!sourceWidth || !sourceHeight) {
throw new Error("Не удалось определить размер изображения.");
}
const scale = Math.min(1, AGENT_AVATAR_RENDER_SIZE / Math.max(sourceWidth, sourceHeight));
const width = Math.max(1, Math.round(sourceWidth * scale));
const height = Math.max(1, Math.round(sourceHeight * scale));
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d");
if (!context) {
throw new Error("Не удалось подготовить аватар.");
}
context.drawImage(image, 0, 0, width, height);
const blob = await canvasToBlob(canvas, "image/webp", AGENT_AVATAR_OUTPUT_QUALITY);
return blobToDataUrl(blob);
} finally {
URL.revokeObjectURL(objectUrl);
}
}
function loadImageElement(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image();
image.addEventListener("load", () => resolve(image), { once: true });
image.addEventListener("error", () => reject(new Error("Не удалось открыть изображение аватара.")), { once: true });
image.src = src;
});
}
function canvasToBlob(canvas: HTMLCanvasElement, type: string, quality: number): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (blob) {
resolve(blob);
return;
}
reject(new Error("Не удалось сжать аватар."));
},
type,
quality
);
});
}
function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = () => resolve(String(reader.result)); reader.addEventListener("load", () => resolve(String(reader.result)), { once: true });
reader.onerror = () => reject(new Error("Не удалось прочитать файл аватара.")); reader.addEventListener("error", () => reject(new Error("Не удалось прочитать файл аватара.")), { once: true });
reader.readAsDataURL(file); reader.readAsDataURL(blob);
}); });
} }

View File

@ -11,7 +11,7 @@ import { useTranslation } from "@plane/i18n";
import { InboxIcon, PlusIcon } from "@plane/propel/icons"; import { InboxIcon, PlusIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip"; import { Tooltip } from "@plane/propel/tooltip";
import useSWR from "swr"; import useSWR from "swr";
import { TopNavPowerK } from "@/components/navigation"; import { DeferredTopNavPowerK } from "@/components/navigation/deferred-top-nav-power-k";
import { useCommandPalette } from "@/hooks/store/use-command-palette"; import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useProject } from "@/hooks/store/use-project"; import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user"; import { useUserPermissions } from "@/hooks/store/user";
@ -61,7 +61,7 @@ export const SidebarUtilityRail = observer(function SidebarUtilityRail() {
</button> </button>
</Tooltip> </Tooltip>
)} )}
<TopNavPowerK variant="sidebar" /> <DeferredTopNavPowerK variant="sidebar" />
<Tooltip tooltipContent="Уведомления" position="right"> <Tooltip tooltipContent="Уведомления" position="right">
<button <button
type="button" type="button"

View File

@ -75,7 +75,12 @@ const buildIssueStreamUrl = (workspaceSlug: string, projectId: string) => {
return url.toString(); return url.toString();
}; };
export const useIssueRealtimeEvents = (storeType: EIssuesStoreType, workspaceSlug?: string, projectId?: string) => { export const useIssueRealtimeEvents = (
storeType: EIssuesStoreType,
workspaceSlug?: string,
projectId?: string,
enabled = true
) => {
const { issueMap, issues, issuesFilter } = useIssues(storeType); const { issueMap, issues, issuesFilter } = useIssues(storeType);
const issueServiceRef = useRef(new IssueService()); const issueServiceRef = useRef(new IssueService());
const issueMapRef = useRef(issueMap); const issueMapRef = useRef(issueMap);
@ -98,7 +103,8 @@ export const useIssueRealtimeEvents = (storeType: EIssuesStoreType, workspaceSlu
}, [issuesFilter]); }, [issuesFilter]);
useEffect(() => { useEffect(() => {
if (!workspaceSlug || !projectId || !REALTIME_STORE_TYPES.has(storeType) || typeof window === "undefined") return; if (!enabled || !workspaceSlug || !projectId || !REALTIME_STORE_TYPES.has(storeType) || typeof window === "undefined")
return;
let socket: WebSocket | undefined; let socket: WebSocket | undefined;
let reconnectTimer: ReturnType<typeof setTimeout> | undefined; let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
@ -280,5 +286,5 @@ export const useIssueRealtimeEvents = (storeType: EIssuesStoreType, workspaceSlu
if (reconnectTimer) clearTimeout(reconnectTimer); if (reconnectTimer) clearTimeout(reconnectTimer);
socket?.close(); socket?.close();
}; };
}, [storeType, workspaceSlug, projectId]); }, [enabled, storeType, workspaceSlug, projectId]);
}; };

View File

@ -17,6 +17,7 @@ import { useTimeLineType } from "../components/gantt-chart/contexts";
export const useTimeLineChart = (timelineType: TTimelineType): IBaseTimelineStore => { export const useTimeLineChart = (timelineType: TTimelineType): IBaseTimelineStore => {
const context = useContext(StoreContext); const context = useContext(StoreContext);
if (!context) throw new Error("useTimeLineChart must be used within StoreProvider"); if (!context) throw new Error("useTimeLineChart must be used within StoreProvider");
if (!context.timelineStore) throw context.loadTimelineStore();
return getTimelineStore(context.timelineStore, timelineType); return getTimelineStore(context.timelineStore, timelineType);
}; };
@ -27,6 +28,7 @@ export const useTimeLineChartStore = (): IBaseTimelineStore => {
if (!context) throw new Error("useTimeLineChartStore must be used within StoreProvider"); if (!context) throw new Error("useTimeLineChartStore must be used within StoreProvider");
if (!timelineType) throw new Error("useTimeLineChartStore must be used within TimeLineTypeContext"); if (!timelineType) throw new Error("useTimeLineChartStore must be used within TimeLineTypeContext");
if (!context.timelineStore) throw context.loadTimelineStore();
return getTimelineStore(context.timelineStore, timelineType); return getTimelineStore(context.timelineStore, timelineType);
}; };

View File

@ -5,12 +5,11 @@
*/ */
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useEffect, useState } from "react"; import { useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import useSWR from "swr"; import useSWR from "swr";
// plane imports // plane imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { GANTT_TIMELINE_TYPE } from "@plane/types";
// components // components
import { ProjectAccessRestriction } from "@/components/auth-screens/project/project-access-restriction"; import { ProjectAccessRestriction } from "@/components/auth-screens/project/project-access-restriction";
import { import {
@ -36,7 +35,6 @@ import { useProject } from "@/hooks/store/use-project";
import { useProjectState } from "@/hooks/store/use-project-state"; import { useProjectState } from "@/hooks/store/use-project-state";
import { useProjectView } from "@/hooks/store/use-project-view"; import { useProjectView } from "@/hooks/store/use-project-view";
import { useUser, useUserPermissions } from "@/hooks/store/user"; import { useUser, useUserPermissions } from "@/hooks/store/user";
import { useTimeLineChart } from "@/hooks/use-timeline-chart";
interface IProjectAuthWrapper { interface IProjectAuthWrapper {
workspaceSlug: string; workspaceSlug: string;
@ -55,7 +53,6 @@ export const ProjectAuthWrapper = observer(function ProjectAuthWrapper(props: IP
const { joinProject } = useUserPermissions(); const { joinProject } = useUserPermissions();
const { fetchAllCycles } = useCycle(); const { fetchAllCycles } = useCycle();
const { fetchModulesSlim, fetchModules } = useModule(); const { fetchModulesSlim, fetchModules } = useModule();
const { initGantt } = useTimeLineChart(GANTT_TIMELINE_TYPE.MODULE);
const { fetchViews } = useProjectView(); const { fetchViews } = useProjectView();
const { const {
project: { fetchProjectMembers, fetchProjectUserProperties }, project: { fetchProjectMembers, fetchProjectUserProperties },
@ -73,12 +70,6 @@ export const ProjectAuthWrapper = observer(function ProjectAuthWrapper(props: IP
); );
const currentProjectRole = getProjectRoleByWorkspaceSlugAndProjectId(workspaceSlug, projectId); const currentProjectRole = getProjectRoleByWorkspaceSlugAndProjectId(workspaceSlug, projectId);
const isWorkspaceAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE, workspaceSlug); const isWorkspaceAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE, workspaceSlug);
// Initialize module timeline chart
useEffect(() => {
initGantt();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// fetching project details // fetching project details
const { isLoading: isProjectDetailsLoading, error: projectDetailsError } = useSWR( const { isLoading: isProjectDetailsLoading, error: projectDetailsError } = useSWR(
PROJECT_DETAILS(workspaceSlug, projectId), PROJECT_DETAILS(workspaceSlug, projectId),

View File

@ -11,7 +11,7 @@ import { v4 as uuidv4 } from "uuid";
// plane types // plane types
import type { EFileAssetType, TFileEntityInfo, TFileSignedURLResponse } from "@plane/types"; import type { EFileAssetType, TFileEntityInfo, TFileSignedURLResponse } from "@plane/types";
// services // services
import { FileService } from "@/services/file.service"; import type { FileService } from "@/services/file.service";
import type { TAttachmentUploadStatus } from "../issue/issue-details/attachment.store"; import type { TAttachmentUploadStatus } from "../issue/issue-details/attachment.store";
export interface IEditorAssetStore { export interface IEditorAssetStore {
@ -52,7 +52,7 @@ export class EditorAssetStore implements IEditorAssetStore {
// observables // observables
assetsUploadStatus: Record<string, TAttachmentUploadStatus> = {}; assetsUploadStatus: Record<string, TAttachmentUploadStatus> = {};
// services // services
fileService: FileService; private fileService: FileService | undefined;
constructor() { constructor() {
makeObservable(this, { makeObservable(this, {
@ -63,8 +63,6 @@ export class EditorAssetStore implements IEditorAssetStore {
// actions // actions
uploadEditorAsset: action, uploadEditorAsset: action,
}); });
// services
this.fileService = new FileService();
} }
get assetsUploadPercentage() { get assetsUploadPercentage() {
@ -93,9 +91,19 @@ export class EditorAssetStore implements IEditorAssetStore {
}); });
}, 16); }, 16);
private getFileService = async () => {
if (!this.fileService) {
const { FileService } = await import("@/services/file.service");
this.fileService = new FileService();
}
return this.fileService;
};
uploadEditorAsset: IEditorAssetStore["uploadEditorAsset"] = async (args) => { uploadEditorAsset: IEditorAssetStore["uploadEditorAsset"] = async (args) => {
const { blockId, data, file, projectId, workspaceSlug } = args; const { blockId, data, file, projectId, workspaceSlug } = args;
const tempId = uuidv4(); const tempId = uuidv4();
const fileService = await this.getFileService();
try { try {
// update attachment upload status // update attachment upload status
@ -109,7 +117,7 @@ export class EditorAssetStore implements IEditorAssetStore {
}); });
}); });
if (projectId) { if (projectId) {
const response = await this.fileService.uploadProjectAsset( const response = await fileService.uploadProjectAsset(
workspaceSlug, workspaceSlug,
projectId, projectId,
data, data,
@ -121,7 +129,7 @@ export class EditorAssetStore implements IEditorAssetStore {
); );
return response; return response;
} else { } else {
const response = await this.fileService.uploadWorkspaceAsset(workspaceSlug, data, file, (progressEvent) => { const response = await fileService.uploadWorkspaceAsset(workspaceSlug, data, file, (progressEvent) => {
const progressPercentage = Math.round((progressEvent.progress ?? 0) * 100); const progressPercentage = Math.round((progressEvent.progress ?? 0) * 100);
this.debouncedUpdateProgress(blockId, progressPercentage); this.debouncedUpdateProgress(blockId, progressPercentage);
}); });
@ -138,7 +146,8 @@ export class EditorAssetStore implements IEditorAssetStore {
}; };
duplicateEditorAsset: IEditorAssetStore["duplicateEditorAsset"] = async (args) => { duplicateEditorAsset: IEditorAssetStore["duplicateEditorAsset"] = async (args) => {
const { assetId, entityId, entityType, projectId, workspaceSlug } = args; const { assetId, entityId, entityType, projectId, workspaceSlug } = args;
const { asset_id } = await this.fileService.duplicateAsset(workspaceSlug, assetId, { const fileService = await this.getFileService();
const { asset_id } = await fileService.duplicateAsset(workspaceSlug, assetId, {
entity_id: entityId, entity_id: entityId,
entity_type: entityType, entity_type: entityType,
project_id: projectId, project_id: projectId,

View File

@ -11,7 +11,7 @@ import { v4 as uuidv4 } from "uuid";
// types // types
import type { TIssueAttachment, TIssueAttachmentMap, TIssueAttachmentIdMap, TIssueServiceType } from "@plane/types"; import type { TIssueAttachment, TIssueAttachmentMap, TIssueAttachmentIdMap, TIssueServiceType } from "@plane/types";
// services // services
import { IssueAttachmentService } from "@/services/issue"; import type { IssueAttachmentService } from "@/services/issue/issue_attachment.service";
import type { IIssueRootStore } from "../root.store"; import type { IIssueRootStore } from "../root.store";
import type { IIssueDetail } from "./root.store"; import type { IIssueDetail } from "./root.store";
@ -75,7 +75,8 @@ export class IssueAttachmentStore implements IIssueAttachmentStore {
rootIssueStore: IIssueRootStore; rootIssueStore: IIssueRootStore;
rootIssueDetailStore: IIssueDetail; rootIssueDetailStore: IIssueDetail;
// services // services
issueAttachmentService; private issueAttachmentService: IssueAttachmentService | undefined;
private serviceType: TIssueServiceType;
constructor(rootStore: IIssueRootStore, serviceType: TIssueServiceType) { constructor(rootStore: IIssueRootStore, serviceType: TIssueServiceType) {
makeObservable(this, { makeObservable(this, {
@ -94,10 +95,18 @@ export class IssueAttachmentStore implements IIssueAttachmentStore {
// root store // root store
this.rootIssueStore = rootStore; this.rootIssueStore = rootStore;
this.rootIssueDetailStore = rootStore.issueDetail; this.rootIssueDetailStore = rootStore.issueDetail;
// services this.serviceType = serviceType;
this.issueAttachmentService = new IssueAttachmentService(serviceType);
} }
private getIssueAttachmentService = async () => {
if (!this.issueAttachmentService) {
const { IssueAttachmentService } = await import("@/services/issue/issue_attachment.service");
this.issueAttachmentService = new IssueAttachmentService(this.serviceType);
}
return this.issueAttachmentService;
};
// computed // computed
get issueAttachments() { get issueAttachments() {
const issueId = this.rootIssueDetailStore.peekIssue?.issueId; const issueId = this.rootIssueDetailStore.peekIssue?.issueId;
@ -143,7 +152,8 @@ export class IssueAttachmentStore implements IIssueAttachmentStore {
}; };
fetchAttachments = async (workspaceSlug: string, projectId: string, issueId: string) => { fetchAttachments = async (workspaceSlug: string, projectId: string, issueId: string) => {
const response = await this.issueAttachmentService.getIssueAttachments(workspaceSlug, projectId, issueId); const issueAttachmentService = await this.getIssueAttachmentService();
const response = await issueAttachmentService.getIssueAttachments(workspaceSlug, projectId, issueId);
runInAction(() => { runInAction(() => {
response.forEach((attachment) => set(this.attachmentMap, attachment.id, attachment)); response.forEach((attachment) => set(this.attachmentMap, attachment.id, attachment));
set( set(
@ -166,6 +176,7 @@ export class IssueAttachmentStore implements IIssueAttachmentStore {
createAttachment = async (workspaceSlug: string, projectId: string, issueId: string, file: File) => { createAttachment = async (workspaceSlug: string, projectId: string, issueId: string, file: File) => {
const tempId = uuidv4(); const tempId = uuidv4();
const issueAttachmentService = await this.getIssueAttachmentService();
try { try {
// update attachment upload status // update attachment upload status
runInAction(() => { runInAction(() => {
@ -177,7 +188,7 @@ export class IssueAttachmentStore implements IIssueAttachmentStore {
type: file.type, type: file.type,
}); });
}); });
const response = await this.issueAttachmentService.uploadIssueAttachment( const response = await issueAttachmentService.uploadIssueAttachment(
workspaceSlug, workspaceSlug,
projectId, projectId,
issueId, issueId,
@ -220,7 +231,8 @@ export class IssueAttachmentStore implements IIssueAttachmentStore {
}; };
removeAttachment = async (workspaceSlug: string, projectId: string, issueId: string, attachmentId: string) => { removeAttachment = async (workspaceSlug: string, projectId: string, issueId: string, attachmentId: string) => {
const response = await this.issueAttachmentService.deleteIssueAttachment( const issueAttachmentService = await this.getIssueAttachmentService();
const response = await issueAttachmentService.deleteIssueAttachment(
workspaceSlug, workspaceSlug,
projectId, projectId,
issueId, issueId,

View File

@ -7,7 +7,7 @@
import { action, observable, makeObservable, runInAction } from "mobx"; import { action, observable, makeObservable, runInAction } from "mobx";
import { computedFn } from "mobx-utils"; import { computedFn } from "mobx-utils";
// types // types
import { APITokenService } from "@plane/services"; import type { APITokenService } from "@plane/services";
import type { IApiToken } from "@plane/types"; import type { IApiToken } from "@plane/types";
// services // services
// store // store
@ -30,7 +30,7 @@ export class ApiTokenStore implements IApiTokenStore {
// observables // observables
apiTokens: Record<string, IApiToken> | null = null; apiTokens: Record<string, IApiToken> | null = null;
// services // services
apiTokenService; private apiTokenService: APITokenService | undefined;
// root store // root store
rootStore; rootStore;
@ -47,10 +47,17 @@ export class ApiTokenStore implements IApiTokenStore {
}); });
// root store // root store
this.rootStore = _rootStore; this.rootStore = _rootStore;
// services
this.apiTokenService = new APITokenService();
} }
private getApiTokenService = async () => {
if (!this.apiTokenService) {
const { APITokenService } = await import("@plane/services");
this.apiTokenService = new APITokenService();
}
return this.apiTokenService;
};
/** /**
* get API token by id * get API token by id
* @param apiTokenId * @param apiTokenId
@ -63,54 +70,58 @@ export class ApiTokenStore implements IApiTokenStore {
/** /**
* fetch all the API tokens * fetch all the API tokens
*/ */
fetchApiTokens = async () => fetchApiTokens = async () => {
await this.apiTokenService.list().then((response) => { const apiTokenService = await this.getApiTokenService();
const apiTokensObject: { [apiTokenId: string]: IApiToken } = response.reduce((accumulator, currentWebhook) => { const response = await apiTokenService.list();
if (currentWebhook && currentWebhook.id) { const apiTokensObject = response.reduce<Record<string, IApiToken>>((accumulator, currentWebhook) => {
return { ...accumulator, [currentWebhook.id]: currentWebhook }; if (currentWebhook && currentWebhook.id) {
} accumulator[currentWebhook.id] = currentWebhook;
return accumulator; }
}, {}); return accumulator;
runInAction(() => { }, {});
this.apiTokens = apiTokensObject; runInAction(() => {
}); this.apiTokens = apiTokensObject;
return response;
}); });
return response;
};
/** /**
* fetch API token details using token id * fetch API token details using token id
* @param tokenId * @param tokenId
*/ */
fetchApiTokenDetails = async (tokenId: string) => fetchApiTokenDetails = async (tokenId: string) => {
await this.apiTokenService.retrieve(tokenId).then((response) => { const apiTokenService = await this.getApiTokenService();
runInAction(() => { const response = await apiTokenService.retrieve(tokenId);
this.apiTokens = { ...this.apiTokens, [response.id]: response }; runInAction(() => {
}); this.apiTokens = { ...this.apiTokens, [response.id]: response };
return response;
}); });
return response;
};
/** /**
* create API token using data * create API token using data
* @param data * @param data
*/ */
createApiToken = async (data: Partial<IApiToken>) => createApiToken = async (data: Partial<IApiToken>) => {
await this.apiTokenService.create(data).then((response) => { const apiTokenService = await this.getApiTokenService();
runInAction(() => { const response = await apiTokenService.create(data);
this.apiTokens = { ...this.apiTokens, [response.id]: response }; runInAction(() => {
}); this.apiTokens = { ...this.apiTokens, [response.id]: response };
return response;
}); });
return response;
};
/** /**
* delete API token using token id * delete API token using token id
* @param tokenId * @param tokenId
*/ */
deleteApiToken = async (tokenId: string) => deleteApiToken = async (tokenId: string) => {
await this.apiTokenService.destroy(tokenId).then(() => { const apiTokenService = await this.getApiTokenService();
const updatedApiTokens = { ...this.apiTokens }; await apiTokenService.destroy(tokenId);
delete updatedApiTokens[tokenId]; const updatedApiTokens = { ...this.apiTokens };
runInAction(() => { delete updatedApiTokens[tokenId];
this.apiTokens = updatedApiTokens; runInAction(() => {
}); this.apiTokens = updatedApiTokens;
}); });
};
} }

View File

@ -9,6 +9,28 @@ http {
default_type application/octet-stream; default_type application/octet-stream;
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_min_length 1024;
gzip_types
application/javascript
application/json
application/manifest+json
application/rss+xml
application/vnd.ms-fontobject
application/wasm
application/x-javascript
application/xml
font/ttf
font/otf
image/svg+xml
text/css
text/javascript
text/plain
text/xml;
set_real_ip_from 0.0.0.0/0; set_real_ip_from 0.0.0.0/0;
real_ip_recursive on; real_ip_recursive on;
real_ip_header X-Forward-For; real_ip_header X-Forward-For;

View File

@ -19,7 +19,8 @@ BUILD_ADMIN=${BUILD_ADMIN:-0}
BACKEND_BUILDKIT=${BACKEND_BUILDKIT:-0} BACKEND_BUILDKIT=${BACKEND_BUILDKIT:-0}
WEB_BUILDKIT=${WEB_BUILDKIT:-0} WEB_BUILDKIT=${WEB_BUILDKIT:-0}
ADMIN_BUILDKIT=${ADMIN_BUILDKIT:-1} ADMIN_BUILDKIT=${ADMIN_BUILDKIT:-0}
ADMIN_REQUIRED=${ADMIN_REQUIRED:-0}
PLANE_BACKEND_IMAGE=${PLANE_BACKEND_IMAGE:-nodedc/plane-backend:local} PLANE_BACKEND_IMAGE=${PLANE_BACKEND_IMAGE:-nodedc/plane-backend:local}
PLANE_FRONTEND_IMAGE=${PLANE_FRONTEND_IMAGE:-nodedc/plane-frontend:ru} PLANE_FRONTEND_IMAGE=${PLANE_FRONTEND_IMAGE:-nodedc/plane-frontend:ru}
@ -106,11 +107,19 @@ fi
if [ "$BUILD_ADMIN" = "1" ]; then if [ "$BUILD_ADMIN" = "1" ]; then
printf "== admin image: %s ==\n" "$PLANE_ADMIN_IMAGE" printf "== admin image: %s ==\n" "$PLANE_ADMIN_IMAGE"
cd "$SRC" cd "$SRC"
run_env DOCKER_BUILDKIT="$ADMIN_BUILDKIT" "$DOCKER" build \ if run_env DOCKER_BUILDKIT="$ADMIN_BUILDKIT" "$DOCKER" build \
--build-arg "VITE_NODEDC_LAUNCHER_URL=$VITE_NODEDC_LAUNCHER_URL" \ --build-arg "VITE_NODEDC_LAUNCHER_URL=$VITE_NODEDC_LAUNCHER_URL" \
-t "$PLANE_ADMIN_IMAGE" \ -t "$PLANE_ADMIN_IMAGE" \
-f apps/admin/Dockerfile.admin . -f apps/admin/Dockerfile.admin .; then
RECREATE_SERVICES="$RECREATE_SERVICES admin" RECREATE_SERVICES="$RECREATE_SERVICES admin"
else
admin_status=$?
if [ "$ADMIN_REQUIRED" = "1" ] || [ -z "$RECREATE_SERVICES" ]; then
printf "!! admin image build failed (exit %s); aborting\n" "$admin_status" >&2
exit "$admin_status"
fi
printf "!! admin image build failed (exit %s); continuing with:%s\n" "$admin_status" "$RECREATE_SERVICES" >&2
fi
else else
printf "== skip admin image ==\n" printf "== skip admin image ==\n"
fi fi