Compare commits
3 Commits
b0a682b63b
...
83ea515962
| Author | SHA1 | Date |
|---|---|---|
|
|
83ea515962 | |
|
|
44d2c5dd27 | |
|
|
6962642614 |
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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={[
|
||||||
|
|
|
||||||
|
|
@ -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")}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 />
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
});
|
});
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue