ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: единый realtime слой Tasker
Добавлен NODE.DC realtime event stream для workspace/project/member/invite/profile событий. Обновлены frontend stores и live controller. Доработаны settings modal, member dropdown и confirm remove под NODE.DC UX.
This commit is contained in:
parent
fc59481703
commit
480f85cce8
|
|
@ -135,3 +135,34 @@ def publish_issue_event_on_commit(event_type, issue, actor_id=None, changed_fiel
|
|||
transaction.on_commit(_publish)
|
||||
if publish_external_bridge:
|
||||
publish_external_contour_issue_event_on_commit(event_type, issue, actor_id=actor_id, changed_fields=changed_fields)
|
||||
|
||||
|
||||
def publish_assignee_cleanup_issue_events_on_commit(project_id=None, workspace_id=None, assignee_id=None, actor_id=None):
|
||||
if not assignee_id:
|
||||
return
|
||||
|
||||
from plane.db.models import Issue
|
||||
|
||||
issues = (
|
||||
Issue.objects.filter(
|
||||
deleted_at__isnull=True,
|
||||
issue_assignee__assignee_id=assignee_id,
|
||||
issue_assignee__deleted_at__isnull=True,
|
||||
)
|
||||
.select_related("workspace")
|
||||
.distinct()
|
||||
)
|
||||
|
||||
if project_id:
|
||||
issues = issues.filter(project_id=project_id)
|
||||
if workspace_id:
|
||||
issues = issues.filter(workspace_id=workspace_id)
|
||||
|
||||
for issue in issues:
|
||||
publish_issue_event_on_commit(
|
||||
"issue.updated",
|
||||
issue,
|
||||
actor_id=actor_id,
|
||||
changed_fields=["assignees"],
|
||||
publish_external_bridge=True,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,161 @@
|
|||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
import json
|
||||
import logging
|
||||
from uuid import uuid4
|
||||
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from plane.settings.redis import redis_instance
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
NODEDC_EVENT_CHANNEL_PREFIX = "plane:nodedc-events:user"
|
||||
|
||||
|
||||
def nodedc_user_event_channel(user_id):
|
||||
return f"{NODEDC_EVENT_CHANNEL_PREFIX}:{user_id}"
|
||||
|
||||
|
||||
def _normalize_user_ids(user_ids):
|
||||
normalized_user_ids = []
|
||||
seen_user_ids = set()
|
||||
|
||||
for user_id in user_ids or []:
|
||||
if not user_id:
|
||||
continue
|
||||
normalized_user_id = str(user_id)
|
||||
if normalized_user_id in seen_user_ids:
|
||||
continue
|
||||
seen_user_ids.add(normalized_user_id)
|
||||
normalized_user_ids.append(normalized_user_id)
|
||||
|
||||
return normalized_user_ids
|
||||
|
||||
|
||||
def _publish_payload_to_users(user_ids, payload):
|
||||
client = redis_instance()
|
||||
for user_id in _normalize_user_ids(user_ids):
|
||||
client.publish(
|
||||
nodedc_user_event_channel(user_id),
|
||||
json.dumps({**payload, "target_user_id": user_id}, cls=DjangoJSONEncoder),
|
||||
)
|
||||
|
||||
|
||||
def publish_nodedc_event_to_users_on_commit(event_type, user_ids, payload=None):
|
||||
event_payload = {
|
||||
"event_id": str(uuid4()),
|
||||
"type": event_type,
|
||||
"emitted_at": timezone.now(),
|
||||
**(payload or {}),
|
||||
}
|
||||
|
||||
def _publish():
|
||||
try:
|
||||
_publish_payload_to_users(user_ids, event_payload)
|
||||
except Exception:
|
||||
logger.exception("Failed to publish NODE.DC realtime event")
|
||||
|
||||
transaction.on_commit(_publish)
|
||||
|
||||
|
||||
def publish_nodedc_workspace_event_on_commit(workspace, event_type, payload=None, extra_user_ids=None):
|
||||
workspace_payload = {
|
||||
"workspace_id": str(workspace.id),
|
||||
"workspace_slug": workspace.slug,
|
||||
**(payload or {}),
|
||||
}
|
||||
extra_user_ids = _normalize_user_ids(extra_user_ids or [])
|
||||
|
||||
def _publish():
|
||||
try:
|
||||
from plane.db.models import WorkspaceMember
|
||||
|
||||
workspace_user_ids = WorkspaceMember.objects.filter(
|
||||
workspace_id=workspace.id,
|
||||
is_active=True,
|
||||
member__is_bot=False,
|
||||
deleted_at__isnull=True,
|
||||
).values_list("member_id", flat=True)
|
||||
|
||||
_publish_payload_to_users(
|
||||
[*workspace_user_ids, *extra_user_ids],
|
||||
{
|
||||
"event_id": str(uuid4()),
|
||||
"type": event_type,
|
||||
"emitted_at": timezone.now(),
|
||||
**workspace_payload,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to publish NODE.DC workspace realtime event")
|
||||
|
||||
transaction.on_commit(_publish)
|
||||
|
||||
|
||||
def publish_nodedc_user_profile_event_on_commit(user, changed_fields=None):
|
||||
changed_fields = sorted(set(changed_fields or []))
|
||||
payload = {
|
||||
"member_id": str(user.id),
|
||||
"email": user.email,
|
||||
"display_name": user.display_name,
|
||||
"avatar": user.avatar or None,
|
||||
"changed_fields": changed_fields,
|
||||
}
|
||||
|
||||
def _publish():
|
||||
try:
|
||||
from plane.db.models import WorkspaceMember
|
||||
|
||||
memberships = (
|
||||
WorkspaceMember.objects.filter(
|
||||
member_id=user.id,
|
||||
is_active=True,
|
||||
deleted_at__isnull=True,
|
||||
)
|
||||
.select_related("workspace")
|
||||
.only("workspace_id", "workspace__slug")
|
||||
)
|
||||
|
||||
workspace_ids = []
|
||||
client = redis_instance()
|
||||
for membership in memberships:
|
||||
workspace_ids.append(str(membership.workspace_id))
|
||||
workspace_user_ids = WorkspaceMember.objects.filter(
|
||||
workspace_id=membership.workspace_id,
|
||||
is_active=True,
|
||||
member__is_bot=False,
|
||||
deleted_at__isnull=True,
|
||||
).values_list("member_id", flat=True)
|
||||
event_payload = {
|
||||
"event_id": str(uuid4()),
|
||||
"type": "user.profile.updated",
|
||||
"emitted_at": timezone.now(),
|
||||
"workspace_id": str(membership.workspace_id),
|
||||
"workspace_slug": membership.workspace.slug,
|
||||
**payload,
|
||||
}
|
||||
for target_user_id in _normalize_user_ids([*workspace_user_ids, user.id]):
|
||||
client.publish(
|
||||
nodedc_user_event_channel(target_user_id),
|
||||
json.dumps({**event_payload, "target_user_id": target_user_id}, cls=DjangoJSONEncoder),
|
||||
)
|
||||
|
||||
if not workspace_ids:
|
||||
_publish_payload_to_users(
|
||||
[user.id],
|
||||
{
|
||||
"event_id": str(uuid4()),
|
||||
"type": "user.profile.updated",
|
||||
"emitted_at": timezone.now(),
|
||||
**payload,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to publish NODE.DC profile realtime event")
|
||||
|
||||
transaction.on_commit(_publish)
|
||||
|
|
@ -9,6 +9,8 @@ from django.db.models import Min
|
|||
|
||||
# Module imports
|
||||
from .base import BaseViewSet, BaseAPIView
|
||||
from plane.app.realtime.issue_events import publish_assignee_cleanup_issue_events_on_commit
|
||||
from plane.app.realtime.nodedc_events import publish_nodedc_workspace_event_on_commit
|
||||
from plane.authentication.nodedc_workspace_policy import (
|
||||
is_nodedc_launcher_managed_workspace,
|
||||
nodedc_launcher_managed_workspace_response,
|
||||
|
|
@ -176,6 +178,18 @@ class ProjectMemberViewSet(BaseViewSet):
|
|||
],
|
||||
batch_size=10,
|
||||
)
|
||||
for project_member in project_members:
|
||||
publish_nodedc_workspace_event_on_commit(
|
||||
project_member.workspace,
|
||||
"project_member.created",
|
||||
payload={
|
||||
"project_id": str(project_member.project_id),
|
||||
"member_id": str(project_member.member_id),
|
||||
"role": project_member.role,
|
||||
"source": "tasker",
|
||||
},
|
||||
extra_user_ids=[project_member.member_id],
|
||||
)
|
||||
# Send emails to notify the users
|
||||
[
|
||||
project_add_user_email.delay(
|
||||
|
|
@ -301,6 +315,17 @@ class ProjectMemberViewSet(BaseViewSet):
|
|||
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
publish_nodedc_workspace_event_on_commit(
|
||||
project_member.workspace,
|
||||
"project_member.updated",
|
||||
payload={
|
||||
"project_id": str(project_member.project_id),
|
||||
"member_id": str(project_member.member_id),
|
||||
"role": project_member.role,
|
||||
"source": "tasker",
|
||||
},
|
||||
extra_user_ids=[project_member.member_id],
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
|
@ -336,9 +361,24 @@ class ProjectMemberViewSet(BaseViewSet):
|
|||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
publish_assignee_cleanup_issue_events_on_commit(
|
||||
project_id=project_id,
|
||||
assignee_id=project_member.member_id,
|
||||
actor_id=request.user.id,
|
||||
)
|
||||
project_member.is_active = False
|
||||
project_member.save()
|
||||
IssueAssignee.objects.filter(project_id=project_id, assignee_id=project_member.member_id).delete()
|
||||
publish_nodedc_workspace_event_on_commit(
|
||||
project_member.workspace,
|
||||
"project_member.deleted",
|
||||
payload={
|
||||
"project_id": str(project_member.project_id),
|
||||
"member_id": str(project_member.member_id),
|
||||
"source": "tasker",
|
||||
},
|
||||
extra_user_ids=[project_member.member_id],
|
||||
)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
|
|
@ -367,10 +407,25 @@ class ProjectMemberViewSet(BaseViewSet):
|
|||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
publish_assignee_cleanup_issue_events_on_commit(
|
||||
project_id=project_id,
|
||||
assignee_id=project_member.member_id,
|
||||
actor_id=request.user.id,
|
||||
)
|
||||
# Deactivate the user
|
||||
project_member.is_active = False
|
||||
project_member.save()
|
||||
IssueAssignee.objects.filter(project_id=project_id, assignee_id=project_member.member_id).delete()
|
||||
publish_nodedc_workspace_event_on_commit(
|
||||
project_member.workspace,
|
||||
"project_member.deleted",
|
||||
payload={
|
||||
"project_id": str(project_member.project_id),
|
||||
"member_id": str(project_member.member_id),
|
||||
"source": "tasker",
|
||||
},
|
||||
extra_user_ids=[project_member.member_id],
|
||||
)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ from rest_framework.response import Response
|
|||
|
||||
# Module imports
|
||||
from plane.app.permissions import WorkSpaceAdminPermission
|
||||
from plane.app.realtime.nodedc_events import publish_nodedc_workspace_event_on_commit
|
||||
from plane.authentication.nodedc_workspace_policy import (
|
||||
get_nodedc_workspace_creation_policy,
|
||||
is_nodedc_launcher_managed_workspace,
|
||||
|
|
@ -159,6 +160,18 @@ class WorkspaceInvitationsViewset(BaseViewSet):
|
|||
invitation.nodedc_approval_request_id = approval_request_id
|
||||
invitation.save(update_fields=["nodedc_approval_request_id", "updated_at"])
|
||||
approved_requests.append(approval_request_id)
|
||||
invited_user = User.objects.filter(email__iexact=invitation.email, is_bot=False).first()
|
||||
publish_nodedc_workspace_event_on_commit(
|
||||
workspace,
|
||||
"workspace_invite.created",
|
||||
payload={
|
||||
"invite_id": str(invitation.id),
|
||||
"email": invitation.email,
|
||||
"status": invitation.nodedc_approval_status,
|
||||
"source": "tasker",
|
||||
},
|
||||
extra_user_ids=[request.user.id, getattr(invited_user, "id", None)],
|
||||
)
|
||||
except Exception:
|
||||
WorkspaceMemberInvite.objects.filter(id__in=[invitation.id for invitation in workspace_invitations]).delete()
|
||||
return Response(
|
||||
|
|
@ -197,6 +210,18 @@ class WorkspaceInvitationsViewset(BaseViewSet):
|
|||
"invitee_email": invitation.email,
|
||||
},
|
||||
)
|
||||
invited_user = User.objects.filter(email__iexact=invitation.email, is_bot=False).first()
|
||||
publish_nodedc_workspace_event_on_commit(
|
||||
workspace,
|
||||
"workspace_invite.created",
|
||||
payload={
|
||||
"invite_id": str(invitation.id),
|
||||
"email": invitation.email,
|
||||
"status": invitation.nodedc_approval_status,
|
||||
"source": "tasker",
|
||||
},
|
||||
extra_user_ids=[request.user.id, getattr(invited_user, "id", None)],
|
||||
)
|
||||
|
||||
return Response({"message": "Emails sent successfully"}, status=status.HTTP_200_OK)
|
||||
|
||||
|
|
@ -214,7 +239,23 @@ class WorkspaceInvitationsViewset(BaseViewSet):
|
|||
status=status.HTTP_502_BAD_GATEWAY,
|
||||
)
|
||||
|
||||
invited_user = User.objects.filter(email__iexact=workspace_member_invite.email, is_bot=False).first()
|
||||
workspace = workspace_member_invite.workspace
|
||||
invite_payload = {
|
||||
"invite_id": str(workspace_member_invite.id),
|
||||
"email": workspace_member_invite.email,
|
||||
"status": workspace_member_invite.nodedc_approval_status,
|
||||
"source": "tasker",
|
||||
}
|
||||
extra_user_ids = [workspace_member_invite.created_by_id, getattr(invited_user, "id", None)]
|
||||
|
||||
workspace_member_invite.delete()
|
||||
publish_nodedc_workspace_event_on_commit(
|
||||
workspace,
|
||||
"workspace_invite.deleted",
|
||||
payload=invite_payload,
|
||||
extra_user_ids=extra_user_ids,
|
||||
)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
|
|
@ -340,6 +381,17 @@ class WorkspaceJoinEndpoint(BaseAPIView):
|
|||
},
|
||||
)
|
||||
|
||||
publish_nodedc_workspace_event_on_commit(
|
||||
workspace_invite.workspace,
|
||||
"workspace_member.created",
|
||||
payload={
|
||||
"member_id": str(user.id),
|
||||
"role": workspace_invite.role,
|
||||
"source": "tasker",
|
||||
},
|
||||
extra_user_ids=[user.id, workspace_invite.created_by_id],
|
||||
)
|
||||
|
||||
# Delete the invitation
|
||||
workspace_invite.delete()
|
||||
|
||||
|
|
@ -447,6 +499,16 @@ class UserWorkspaceInvitationsViewSet(BaseViewSet):
|
|||
},
|
||||
)
|
||||
)
|
||||
publish_nodedc_workspace_event_on_commit(
|
||||
invitation.workspace,
|
||||
"workspace_member.created",
|
||||
payload={
|
||||
"member_id": str(request.user.id),
|
||||
"role": invitation.role,
|
||||
"source": "tasker",
|
||||
},
|
||||
extra_user_ids=[request.user.id, invitation.created_by_id],
|
||||
)
|
||||
|
||||
# Bulk create the user for all the workspaces
|
||||
WorkspaceMember.objects.bulk_create(
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ from rest_framework import status
|
|||
from rest_framework.response import Response
|
||||
|
||||
from plane.app.permissions import WorkspaceEntityPermission, allow_permission, ROLE
|
||||
from plane.app.realtime.issue_events import publish_assignee_cleanup_issue_events_on_commit
|
||||
from plane.app.realtime.nodedc_events import publish_nodedc_workspace_event_on_commit
|
||||
from plane.authentication.nodedc_workspace_policy import (
|
||||
get_nodedc_workspace_creation_policy,
|
||||
is_nodedc_launcher_managed_workspace,
|
||||
|
|
@ -54,7 +56,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
|
|||
workspace_member = WorkspaceMember.objects.get(member=request.user, workspace__slug=slug, is_active=True)
|
||||
|
||||
# Get all active workspace members
|
||||
workspace_members = self.get_queryset()
|
||||
workspace_members = self.get_queryset().filter(is_active=True)
|
||||
if workspace_member.role > 5:
|
||||
serializer = WorkspaceMemberAdminSerializer(workspace_members, fields=("id", "member", "role"), many=True)
|
||||
else:
|
||||
|
|
@ -102,6 +104,16 @@ class WorkSpaceMemberViewSet(BaseViewSet):
|
|||
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
publish_nodedc_workspace_event_on_commit(
|
||||
workspace_member.workspace,
|
||||
"workspace_member.updated",
|
||||
payload={
|
||||
"member_id": str(workspace_member.member_id),
|
||||
"role": workspace_member.role,
|
||||
"source": "tasker",
|
||||
},
|
||||
extra_user_ids=[workspace_member.member_id],
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
|
@ -166,6 +178,20 @@ class WorkSpaceMemberViewSet(BaseViewSet):
|
|||
status=status.HTTP_502_BAD_GATEWAY,
|
||||
)
|
||||
|
||||
project_ids = list(
|
||||
ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
member_id=workspace_member.member_id,
|
||||
is_active=True,
|
||||
).values_list("project_id", flat=True)
|
||||
)
|
||||
|
||||
publish_assignee_cleanup_issue_events_on_commit(
|
||||
workspace_id=workspace_member.workspace_id,
|
||||
assignee_id=workspace_member.member_id,
|
||||
actor_id=request.user.id,
|
||||
)
|
||||
|
||||
# Deactivate the users from the projects where the user is part of
|
||||
_ = ProjectMember.objects.filter(
|
||||
workspace__slug=slug, member_id=workspace_member.member_id, is_active=True
|
||||
|
|
@ -174,6 +200,16 @@ class WorkSpaceMemberViewSet(BaseViewSet):
|
|||
|
||||
workspace_member.is_active = False
|
||||
workspace_member.save()
|
||||
publish_nodedc_workspace_event_on_commit(
|
||||
workspace_member.workspace,
|
||||
"workspace_member.deleted",
|
||||
payload={
|
||||
"member_id": str(workspace_member.member_id),
|
||||
"project_ids": [str(project_id) for project_id in project_ids],
|
||||
"source": "tasker",
|
||||
},
|
||||
extra_user_ids=[workspace_member.member_id],
|
||||
)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@invalidate_cache(
|
||||
|
|
@ -224,6 +260,20 @@ class WorkSpaceMemberViewSet(BaseViewSet):
|
|||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
project_ids = list(
|
||||
ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
member_id=workspace_member.member_id,
|
||||
is_active=True,
|
||||
).values_list("project_id", flat=True)
|
||||
)
|
||||
|
||||
publish_assignee_cleanup_issue_events_on_commit(
|
||||
workspace_id=workspace_member.workspace_id,
|
||||
assignee_id=workspace_member.member_id,
|
||||
actor_id=request.user.id,
|
||||
)
|
||||
|
||||
# # Deactivate the users from the projects where the user is part of
|
||||
_ = ProjectMember.objects.filter(
|
||||
workspace__slug=slug, member_id=workspace_member.member_id, is_active=True
|
||||
|
|
@ -233,6 +283,16 @@ class WorkSpaceMemberViewSet(BaseViewSet):
|
|||
# # Deactivate the user
|
||||
workspace_member.is_active = False
|
||||
workspace_member.save()
|
||||
publish_nodedc_workspace_event_on_commit(
|
||||
workspace_member.workspace,
|
||||
"workspace_member.deleted",
|
||||
payload={
|
||||
"member_id": str(workspace_member.member_id),
|
||||
"project_ids": [str(project_id) for project_id in project_ids],
|
||||
"source": "tasker",
|
||||
},
|
||||
extra_user_ids=[workspace_member.member_id],
|
||||
)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,11 @@ from django.views import View
|
|||
from django.views.decorators.csrf import csrf_exempt
|
||||
|
||||
from plane.authentication.views.nodedc_logout import is_internal_logout_request_authorized
|
||||
from plane.app.realtime.issue_events import publish_assignee_cleanup_issue_events_on_commit
|
||||
from plane.app.realtime.nodedc_events import (
|
||||
publish_nodedc_user_profile_event_on_commit,
|
||||
publish_nodedc_workspace_event_on_commit,
|
||||
)
|
||||
from plane.utils.host import base_host
|
||||
from plane.db.models import (
|
||||
ExternalIdentityLink,
|
||||
|
|
@ -361,6 +366,9 @@ class NodeDCInternalUserProfileSyncEndpoint(View):
|
|||
with transaction.atomic():
|
||||
updated_fields = sync_user_profile_from_payload(user, payload)
|
||||
|
||||
if updated_fields:
|
||||
publish_nodedc_user_profile_event_on_commit(user, changed_fields=updated_fields)
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"ok": True,
|
||||
|
|
@ -434,6 +442,17 @@ class NodeDCInternalWorkspaceMembershipEnsureEndpoint(View):
|
|||
profile.last_workspace_id = workspace.id
|
||||
profile.save(update_fields=["last_workspace_id", "updated_at"])
|
||||
|
||||
publish_nodedc_workspace_event_on_commit(
|
||||
workspace,
|
||||
"workspace_member.created" if created else "workspace_member.updated",
|
||||
payload={
|
||||
"member_id": str(user.id),
|
||||
"role": membership.role,
|
||||
"source": "launcher",
|
||||
},
|
||||
extra_user_ids=[user.id],
|
||||
)
|
||||
|
||||
return JsonResponse({"ok": True, "membership": serialize_membership(membership, created)})
|
||||
|
||||
|
||||
|
|
@ -475,7 +494,16 @@ class NodeDCInternalWorkspaceMembershipRemoveEndpoint(View):
|
|||
}
|
||||
)
|
||||
|
||||
project_ids = list(
|
||||
ProjectMember.objects.filter(
|
||||
project__workspace=workspace,
|
||||
member=user,
|
||||
is_active=True,
|
||||
).values_list("project_id", flat=True)
|
||||
)
|
||||
|
||||
with transaction.atomic():
|
||||
publish_assignee_cleanup_issue_events_on_commit(workspace_id=workspace.id, assignee_id=user.id)
|
||||
ProjectMember.objects.filter(
|
||||
project__workspace=workspace,
|
||||
member=user,
|
||||
|
|
@ -488,6 +516,17 @@ class NodeDCInternalWorkspaceMembershipRemoveEndpoint(View):
|
|||
membership.is_active = False
|
||||
membership.save(update_fields=["is_active", "updated_at"])
|
||||
|
||||
publish_nodedc_workspace_event_on_commit(
|
||||
workspace,
|
||||
"workspace_member.deleted",
|
||||
payload={
|
||||
"member_id": str(user.id),
|
||||
"project_ids": [str(project_id) for project_id in project_ids],
|
||||
"source": "launcher",
|
||||
},
|
||||
extra_user_ids=[user.id],
|
||||
)
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"ok": True,
|
||||
|
|
@ -530,6 +569,19 @@ class NodeDCInternalWorkspaceInviteApproveEndpoint(View):
|
|||
]
|
||||
)
|
||||
|
||||
invited_user = User.objects.filter(email__iexact=invitation.email, is_bot=False).first()
|
||||
publish_nodedc_workspace_event_on_commit(
|
||||
invitation.workspace,
|
||||
"workspace_invite.approved",
|
||||
payload={
|
||||
"invite_id": str(invitation.id),
|
||||
"email": invitation.email,
|
||||
"status": invitation.nodedc_approval_status,
|
||||
"source": "launcher",
|
||||
},
|
||||
extra_user_ids=[invitation.created_by_id, getattr(invited_user, "id", None)],
|
||||
)
|
||||
|
||||
return JsonResponse({"ok": True, "invite": serialize_workspace_invite(request, invitation)})
|
||||
|
||||
|
||||
|
|
@ -561,6 +613,19 @@ class NodeDCInternalWorkspaceInviteRejectEndpoint(View):
|
|||
]
|
||||
)
|
||||
|
||||
invited_user = User.objects.filter(email__iexact=invitation.email, is_bot=False).first()
|
||||
publish_nodedc_workspace_event_on_commit(
|
||||
invitation.workspace,
|
||||
"workspace_invite.rejected",
|
||||
payload={
|
||||
"invite_id": str(invitation.id),
|
||||
"email": invitation.email,
|
||||
"status": invitation.nodedc_approval_status,
|
||||
"source": "launcher",
|
||||
},
|
||||
extra_user_ids=[invitation.created_by_id, getattr(invited_user, "id", None)],
|
||||
)
|
||||
|
||||
return JsonResponse({"ok": True, "invite": serialize_workspace_invite(request, invitation)})
|
||||
|
||||
|
||||
|
|
@ -648,6 +713,28 @@ class NodeDCInternalProjectMembershipEnsureEndpoint(View):
|
|||
profile.last_workspace_id = workspace.id
|
||||
profile.save(update_fields=["last_workspace_id", "updated_at"])
|
||||
|
||||
publish_nodedc_workspace_event_on_commit(
|
||||
workspace,
|
||||
"workspace_member.updated",
|
||||
payload={
|
||||
"member_id": str(user.id),
|
||||
"role": workspace_membership.role,
|
||||
"source": "launcher",
|
||||
},
|
||||
extra_user_ids=[user.id],
|
||||
)
|
||||
publish_nodedc_workspace_event_on_commit(
|
||||
workspace,
|
||||
"project_member.created" if created else "project_member.updated",
|
||||
payload={
|
||||
"project_id": str(project.id),
|
||||
"member_id": str(user.id),
|
||||
"role": project_member.role,
|
||||
"source": "launcher",
|
||||
},
|
||||
extra_user_ids=[user.id],
|
||||
)
|
||||
|
||||
return JsonResponse({"ok": True, "membership": serialize_project_membership(project_member, created)})
|
||||
|
||||
|
||||
|
|
@ -695,10 +782,22 @@ class NodeDCInternalProjectMembershipRemoveEndpoint(View):
|
|||
}
|
||||
)
|
||||
|
||||
publish_assignee_cleanup_issue_events_on_commit(project_id=project.id, assignee_id=user.id)
|
||||
project_member.is_active = False
|
||||
project_member.save(update_fields=["is_active", "updated_at"])
|
||||
IssueAssignee.objects.filter(project=project, assignee=user).delete()
|
||||
|
||||
publish_nodedc_workspace_event_on_commit(
|
||||
workspace,
|
||||
"project_member.deleted",
|
||||
payload={
|
||||
"project_id": str(project.id),
|
||||
"member_id": str(user.id),
|
||||
"source": "launcher",
|
||||
},
|
||||
extra_user_ids=[user.id],
|
||||
)
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"ok": True,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ FROM node:22-alpine AS base
|
|||
|
||||
# Setup pnpm package manager with corepack and configure global bin directory for caching
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
ENV PATH="$PNPM_HOME:$PNPM_HOME/bin:$PATH"
|
||||
RUN corepack enable
|
||||
|
||||
# *****************************************************************************
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { CollaborationController } from "./collaboration.controller";
|
|||
import { DocumentController } from "./document.controller";
|
||||
import { HealthController } from "./health.controller";
|
||||
import { IssueStreamController } from "./issue-stream.controller";
|
||||
import { NodeDCStreamController } from "./nodedc-stream.controller";
|
||||
import { PdfExportController } from "./pdf-export.controller";
|
||||
|
||||
export const CONTROLLERS = [
|
||||
|
|
@ -15,5 +16,6 @@ export const CONTROLLERS = [
|
|||
DocumentController,
|
||||
HealthController,
|
||||
IssueStreamController,
|
||||
NodeDCStreamController,
|
||||
PdfExportController,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,126 @@
|
|||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import type { Request } from "express";
|
||||
import type Redis from "ioredis";
|
||||
import type { WebSocket as WSSocket } from "ws";
|
||||
// plane imports
|
||||
import { Controller, WebSocket as WSDecorator } from "@plane/decorators";
|
||||
import { logger } from "@plane/logger";
|
||||
// redis
|
||||
import { redisManager } from "@/redis";
|
||||
// services
|
||||
import { UserService } from "@/services/user.service";
|
||||
|
||||
const NODEDC_EVENT_CHANNEL_PREFIX = "plane:nodedc-events:user";
|
||||
const HEARTBEAT_INTERVAL_MS = 25_000;
|
||||
|
||||
const sendJson = (ws: WSSocket, payload: Record<string, unknown>) => {
|
||||
if (ws.readyState !== 1) return;
|
||||
ws.send(JSON.stringify(payload));
|
||||
};
|
||||
|
||||
@Controller("/nodedc")
|
||||
export class NodeDCStreamController {
|
||||
[key: string]: unknown;
|
||||
|
||||
@WSDecorator("/stream")
|
||||
handleConnection(ws: WSSocket, req: Request) {
|
||||
void this.handleNodeDCStream(ws, req);
|
||||
}
|
||||
|
||||
private async handleNodeDCStream(ws: WSSocket, req: Request) {
|
||||
const cookie = req.headers.cookie?.toString();
|
||||
|
||||
if (!cookie) {
|
||||
ws.close(1008, "Missing NODE.DC stream credentials");
|
||||
return;
|
||||
}
|
||||
|
||||
let subscriber: Redis | undefined;
|
||||
let heartbeat: NodeJS.Timeout | undefined;
|
||||
let isCleanedUp = false;
|
||||
|
||||
const cleanup = async () => {
|
||||
if (isCleanedUp) return;
|
||||
isCleanedUp = true;
|
||||
|
||||
if (heartbeat) clearInterval(heartbeat);
|
||||
if (!subscriber) return;
|
||||
|
||||
try {
|
||||
await subscriber.unsubscribe();
|
||||
subscriber.disconnect();
|
||||
} catch (error) {
|
||||
logger.error("NODEDC_STREAM_CONTROLLER: Redis cleanup failed:", error);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const userService = new UserService();
|
||||
const user = await userService.currentUser(cookie);
|
||||
const redisClient = redisManager.getClient();
|
||||
|
||||
if (!redisClient) {
|
||||
ws.close(1011, "NODE.DC stream unavailable");
|
||||
return;
|
||||
}
|
||||
|
||||
const channel = `${NODEDC_EVENT_CHANNEL_PREFIX}:${user.id}`;
|
||||
subscriber = redisClient.duplicate({ lazyConnect: true });
|
||||
await subscriber.connect();
|
||||
await subscriber.subscribe(channel);
|
||||
|
||||
subscriber.on("message", (_channel, message) => {
|
||||
try {
|
||||
const event = JSON.parse(message) as Record<string, unknown>;
|
||||
sendJson(ws, event);
|
||||
} catch (error) {
|
||||
logger.error("NODEDC_STREAM_CONTROLLER: Failed to forward event:", error);
|
||||
}
|
||||
});
|
||||
|
||||
subscriber.on("error", (error) => {
|
||||
logger.error("NODEDC_STREAM_CONTROLLER: Redis subscriber error:", error);
|
||||
ws.close(1011, "NODE.DC stream subscriber failed");
|
||||
});
|
||||
|
||||
heartbeat = setInterval(() => {
|
||||
sendJson(ws, { type: "nodedc.stream.ping", server_ts: new Date().toISOString() });
|
||||
}, HEARTBEAT_INTERVAL_MS);
|
||||
|
||||
sendJson(ws, {
|
||||
type: "nodedc.stream.ready",
|
||||
user_id: user.id,
|
||||
server_ts: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("NODEDC_STREAM_CONTROLLER: WebSocket authentication failed:", error);
|
||||
ws.close(1008, "NODE.DC stream authentication failed");
|
||||
await cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
ws.on("message", (message) => {
|
||||
try {
|
||||
const payload = JSON.parse(message.toString()) as { type?: string };
|
||||
if (payload.type === "nodedc.stream.pong") return;
|
||||
} catch {
|
||||
// Client messages are optional for this stream.
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
void cleanup();
|
||||
});
|
||||
|
||||
ws.on("error", (error: Error) => {
|
||||
logger.error("NODEDC_STREAM_CONTROLLER: WebSocket connection error:", error);
|
||||
ws.close(1011, "NODE.DC stream connection failed");
|
||||
void cleanup();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -6,7 +6,6 @@
|
|||
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
// types
|
||||
import { Button } from "@plane/propel/button";
|
||||
|
|
@ -22,12 +21,11 @@ type Props = {
|
|||
onSubmit: () => Promise<void>;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
export const ConfirmProjectMemberRemove = observer(function ConfirmProjectMemberRemove(props: Props) {
|
||||
const { data, onSubmit, isOpen, onClose } = props;
|
||||
// router
|
||||
const { projectId } = useParams();
|
||||
const { data, onSubmit, isOpen, onClose, projectId } = props;
|
||||
// states
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
// store hooks
|
||||
|
|
@ -50,45 +48,63 @@ export const ConfirmProjectMemberRemove = observer(function ConfirmProjectMember
|
|||
if (!projectId) return <></>;
|
||||
|
||||
const isCurrentUser = currentUser?.id === data?.id;
|
||||
const currentProjectDetails = getProjectById(projectId.toString());
|
||||
const currentProjectDetails = getProjectById(projectId);
|
||||
const memberName = data?.display_name || "участника";
|
||||
|
||||
return (
|
||||
<ModalCore isOpen={isOpen} handleClose={handleClose} position={EModalPosition.CENTER} width={EModalWidth.XXL}>
|
||||
<div className="bg-surface-1 px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<div className="sm:flex sm:items-start">
|
||||
<div className="mx-auto flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-danger-subtle sm:mx-0 sm:h-10 sm:w-10">
|
||||
<AlertTriangle className="h-6 w-6 text-danger-primary" aria-hidden="true" />
|
||||
<ModalCore
|
||||
isOpen={isOpen}
|
||||
handleClose={handleClose}
|
||||
position={EModalPosition.CENTER}
|
||||
width={EModalWidth.XXL}
|
||||
className="overflow-hidden border-0 bg-[rgba(10,10,14,0.96)] p-0 shadow-[0_28px_80px_rgba(0,0,0,0.42)]"
|
||||
>
|
||||
<div className="p-6 sm:p-7">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="grid size-12 shrink-0 place-items-center rounded-[1.2rem] bg-[rgb(var(--nodedc-accent-rgb))]/18 text-[rgb(var(--nodedc-accent-rgb))]">
|
||||
<AlertTriangle className="h-5 w-5" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||
<h3 className="text-16 leading-6 font-medium text-primary">
|
||||
{isCurrentUser ? "Leave project?" : `Remove ${data?.display_name}?`}
|
||||
<div className="min-w-0">
|
||||
<p className="text-12 font-semibold tracking-[0.24em] text-tertiary uppercase">NODE.DC Tasker</p>
|
||||
<h3 className="mt-2 text-18 leading-6 font-semibold text-primary">
|
||||
{isCurrentUser ? "Покинуть проект?" : `Удалить ${memberName} из проекта?`}
|
||||
</h3>
|
||||
<div className="mt-2">
|
||||
<p className="text-13 text-secondary">
|
||||
<p className="mt-3 text-13 leading-6 text-secondary">
|
||||
{isCurrentUser ? (
|
||||
<>
|
||||
Are you sure you want to leave the <span className="font-bold">{currentProjectDetails?.name}</span>{" "}
|
||||
project? You will be able to join the project if invited again or if it{"'"}s public.
|
||||
Вы потеряете доступ к проекту{" "}
|
||||
<span className="font-semibold text-primary">{currentProjectDetails?.name}</span>. Вернуться можно
|
||||
будет только после нового приглашения или если проект публичный.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Are you sure you want to remove member- <span className="font-bold">{data?.display_name}</span>?
|
||||
They will no longer have access to this project. This action cannot be undone.
|
||||
Пользователь <span className="font-semibold text-primary">{memberName}</span> потеряет доступ к
|
||||
проекту. Действие нельзя отменить автоматически.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 p-4 sm:px-6">
|
||||
<Button variant="secondary" size="lg" onClick={handleClose}>
|
||||
Cancel
|
||||
<div className="mt-7 flex justify-end gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
onClick={handleClose}
|
||||
className="nodedc-modal-secondary-button min-w-[11.5rem] px-8"
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button variant="error-fill" size="lg" tabIndex={1} onClick={handleDeletion} loading={isDeleteLoading}>
|
||||
{isCurrentUser ? (isDeleteLoading ? "Leaving..." : "Leave") : isDeleteLoading ? "Removing..." : "Remove"}
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
onClick={handleDeletion}
|
||||
loading={isDeleteLoading}
|
||||
className="nodedc-modal-primary-button min-w-[12.5rem] px-8"
|
||||
>
|
||||
{isCurrentUser ? (isDeleteLoading ? "Выходим..." : "Покинуть") : isDeleteLoading ? "Удаляем..." : "Удалить"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalCore>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ export const ProjectMemberListItem = observer(function ProjectMemberListItem(pro
|
|||
await leaveProject(workspaceSlug.toString(), projectId.toString())
|
||||
.then(async () => {
|
||||
router.push(`/${workspaceSlug}/projects`);
|
||||
return undefined;
|
||||
})
|
||||
.catch((err) => {
|
||||
setToast({
|
||||
|
|
@ -55,6 +56,7 @@ export const ProjectMemberListItem = observer(function ProjectMemberListItem(pro
|
|||
title: "You can’t leave this project yet.",
|
||||
message: err?.error || "Something went wrong. Please try again.",
|
||||
});
|
||||
return undefined;
|
||||
});
|
||||
} else
|
||||
await removeMemberFromProject(workspaceSlug.toString(), projectId.toString(), memberId).catch((err) =>
|
||||
|
|
@ -73,6 +75,7 @@ export const ProjectMemberListItem = observer(function ProjectMemberListItem(pro
|
|||
<ConfirmProjectMemberRemove
|
||||
isOpen={removeMemberModal !== null}
|
||||
onClose={() => setRemoveMemberModal(null)}
|
||||
projectId={projectId}
|
||||
data={{ id: removeMemberModal.member.id, display_name: removeMemberModal.member.display_name || "" }}
|
||||
onSubmit={() => handleRemove(removeMemberModal.member.id)}
|
||||
/>
|
||||
|
|
@ -82,7 +85,7 @@ export const ProjectMemberListItem = observer(function ProjectMemberListItem(pro
|
|||
columns={columns}
|
||||
data={(memberDetails?.filter((member): member is IProjectMemberDetails => member !== null) ?? []) as any}
|
||||
keyExtractor={(rowData) => rowData?.member.id ?? ""}
|
||||
tableClassName="nodedc-settings-table-surface w-max table-auto border-separate border-spacing-0 overflow-visible"
|
||||
tableClassName="nodedc-settings-table-surface min-w-full table-auto border-separate border-spacing-0 overflow-visible"
|
||||
tHeadClassName="border-b border-white/6"
|
||||
thClassName="text-left font-medium divide-x-0 text-placeholder"
|
||||
tBodyClassName="divide-y-0"
|
||||
|
|
|
|||
|
|
@ -69,12 +69,12 @@ export const MemberSelect = observer(function MemberSelect(props: Props) {
|
|||
<SearchSelectionDropdown
|
||||
value={value}
|
||||
label={
|
||||
<div className="flex h-3.5 items-center gap-2">
|
||||
<div className="flex min-h-5 min-w-0 items-center gap-2">
|
||||
{selectedOption && (
|
||||
<Avatar name={selectedOption.member?.display_name} src={getFileURL(selectedOption.member?.avatar_url)} />
|
||||
)}
|
||||
{selectedOption ? (
|
||||
selectedOption.member?.display_name
|
||||
<span className="truncate">{selectedOption.member?.display_name}</span>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Ban className="h-3.5 w-3.5 rotate-90 text-placeholder" />
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { Loader, ToggleSwitch } from "@plane/ui";
|
|||
import { PROJECT_DETAILS } from "@/constants/fetch-keys";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
// local imports
|
||||
import { MemberSelect } from "./member-select";
|
||||
|
|
@ -60,6 +61,9 @@ export const ProjectSettingsMemberDefaults = observer(function ProjectSettingsMe
|
|||
// store hooks
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
|
||||
const {
|
||||
project: { fetchProjectMembers, getProjectMemberFetchStatus },
|
||||
} = useMember();
|
||||
const { currentProjectDetails, fetchProjectDetails, updateProject } = useProject();
|
||||
// derived values
|
||||
const isAdmin = allowPermissions(
|
||||
|
|
@ -76,6 +80,14 @@ export const ProjectSettingsMemberDefaults = observer(function ProjectSettingsMe
|
|||
workspaceSlug && projectId ? () => fetchProjectDetails(workspaceSlug, projectId) : null
|
||||
);
|
||||
|
||||
const hasFetchedProjectMembers = getProjectMemberFetchStatus(projectId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspaceSlug || !projectId || hasFetchedProjectMembers) return;
|
||||
|
||||
void fetchProjectMembers(workspaceSlug, projectId, true).catch(console.error);
|
||||
}, [fetchProjectMembers, hasFetchedProjectMembers, projectId, workspaceSlug]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentProjectDetails) return;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
|
||||
import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
|
||||
import { observer } from "mobx-react";
|
||||
|
|
@ -194,7 +193,7 @@ export const ProjectSettingsModal = observer(function ProjectSettingsModal() {
|
|||
handleClose={handleClose}
|
||||
position={EModalPosition.CENTER}
|
||||
width={EModalWidth.VIIXL}
|
||||
className="h-[88vh] max-h-[920px] overflow-hidden border-0 bg-[rgba(10,10,14,0.96)] shadow-[0_28px_80px_rgba(0,0,0,0.42)]"
|
||||
className="h-[88vh] max-h-[920px] !max-w-[calc(100vw-1.5rem)] overflow-hidden border-0 bg-[rgba(10,10,14,0.96)] shadow-[0_28px_80px_rgba(0,0,0,0.42)] sm:!max-w-[calc(100vw-2rem)] xl:!max-w-[88rem]"
|
||||
>
|
||||
{workspaceSlug && activeProjectId ? (
|
||||
<ProjectAuthWrapper workspaceSlug={workspaceSlug} projectId={activeProjectId}>
|
||||
|
|
@ -233,7 +232,7 @@ export const ProjectSettingsModal = observer(function ProjectSettingsModal() {
|
|||
size="sm"
|
||||
className="min-h-0 flex-1 overflow-y-auto"
|
||||
>
|
||||
<div className="mx-auto w-full max-w-[74rem] px-5 pb-7 lg:px-8">
|
||||
<div className="mx-auto w-full max-w-[82rem] px-5 pb-7 lg:px-6">
|
||||
<ProjectSettingsModalContent
|
||||
activeTab={activeTab}
|
||||
projectId={activeProjectId}
|
||||
|
|
|
|||
|
|
@ -71,11 +71,22 @@ export const ConfirmWorkspaceMemberRemove = observer(function ConfirmWorkspaceMe
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 p-4 sm:px-6">
|
||||
<Button variant="secondary" size="lg" onClick={handleClose}>
|
||||
<div className="flex justify-end gap-3 p-4 sm:px-6">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
onClick={handleClose}
|
||||
className="nodedc-modal-secondary-button min-w-[11.5rem] px-8"
|
||||
>
|
||||
Отменить
|
||||
</Button>
|
||||
<Button variant="primary" size="lg" tabIndex={1} onClick={handleDeletion} loading={isRemoving}>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
onClick={handleDeletion}
|
||||
loading={isRemoving}
|
||||
className="nodedc-modal-primary-button min-w-[12.5rem] px-8"
|
||||
>
|
||||
{currentUser?.id === userDetails.id
|
||||
? isRemoving
|
||||
? "Выход..."
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ export const WorkspaceMembersListItem = observer(function WorkspaceMembersListIt
|
|||
if (isEmpty(columns)) return <MembersLayoutLoader />;
|
||||
|
||||
return (
|
||||
<div className="horizontal-scrollbar scrollbar-sm w-full overflow-x-auto overflow-y-hidden rounded-[1.2rem]">
|
||||
<div className="w-full overflow-visible rounded-[1.2rem]">
|
||||
{removeMemberModal && (
|
||||
<ConfirmWorkspaceMemberRemove
|
||||
isOpen={removeMemberModal.member.id.length > 0}
|
||||
|
|
@ -109,7 +109,7 @@ export const WorkspaceMembersListItem = observer(function WorkspaceMembersListIt
|
|||
(memberDetails?.filter((member): member is IWorkspaceMember => member !== null) ?? []) as unknown as RowData[]
|
||||
}
|
||||
keyExtractor={(rowData) => rowData?.member.id ?? ""}
|
||||
tableClassName="nodedc-settings-table-surface w-max table-auto border-separate border-spacing-0 overflow-visible"
|
||||
tableClassName="nodedc-settings-table-surface min-w-full table-auto border-separate border-spacing-0 overflow-visible"
|
||||
tHeadClassName="border-b border-white/6"
|
||||
thClassName="text-left font-medium divide-x-0 text-placeholder"
|
||||
tBodyClassName="divide-y-0"
|
||||
|
|
|
|||
|
|
@ -49,7 +49,14 @@ import {
|
|||
const HIDDEN_WORKSPACE_SETTINGS_KEYS = new Set<TWorkspaceSettingsTabs>(["billing-and-plans"]);
|
||||
const LAUNCHER_MANAGED_HIDDEN_WORKSPACE_SETTINGS_KEYS = new Set<TWorkspaceSettingsTabs>(["members"]);
|
||||
const WORKSPACE_FEATURE_GATED_SETTINGS_KEYS = new Set<TWorkspaceSettingsTabs>(["ai-voice-tasker"]);
|
||||
const MODAL_TABS = new Set<TWorkspaceSettingsTabs>(["general", "members", "export", "storage", "webhooks", "ai-voice-tasker"]);
|
||||
const MODAL_TABS = new Set<TWorkspaceSettingsTabs>([
|
||||
"general",
|
||||
"members",
|
||||
"export",
|
||||
"storage",
|
||||
"webhooks",
|
||||
"ai-voice-tasker",
|
||||
]);
|
||||
const workspaceAIService = new WorkspaceAIService();
|
||||
const workspaceService = new WorkspaceService();
|
||||
|
||||
|
|
@ -77,7 +84,11 @@ export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal()
|
|||
const { t } = useTranslation();
|
||||
const canLoadVoiceTaskerEntitlement =
|
||||
!!currentWorkspace?.slug &&
|
||||
allowPermissions(WORKSPACE_SETTINGS["ai-voice-tasker"].access, EUserPermissionsLevel.WORKSPACE, currentWorkspace.slug);
|
||||
allowPermissions(
|
||||
WORKSPACE_SETTINGS["ai-voice-tasker"].access,
|
||||
EUserPermissionsLevel.WORKSPACE,
|
||||
currentWorkspace.slug
|
||||
);
|
||||
const { data: aiSettings, isLoading: isVoiceTaskerEntitlementLoading } = useSWR(
|
||||
canLoadVoiceTaskerEntitlement ? `WORKSPACE_AI_SETTINGS_${currentWorkspace.slug}` : null,
|
||||
() => workspaceAIService.retrieveSettings(currentWorkspace?.slug as string)
|
||||
|
|
@ -94,7 +105,9 @@ export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal()
|
|||
const tab = getWorkspaceSettingsModalTabFromSearch(window.location.search);
|
||||
setIsOpen(Boolean(tab));
|
||||
if (tab) setActiveTab(tab);
|
||||
setActiveWebhookId(tab === "webhooks" ? getWorkspaceSettingsWebhookIdFromSearch(window.location.search) : undefined);
|
||||
setActiveWebhookId(
|
||||
tab === "webhooks" ? getWorkspaceSettingsWebhookIdFromSearch(window.location.search) : undefined
|
||||
);
|
||||
};
|
||||
|
||||
const handleModalEvent = (event: Event) => {
|
||||
|
|
@ -162,7 +175,9 @@ export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal()
|
|||
}
|
||||
|
||||
if (activeTab === "webhooks" && currentWorkspace?.slug) {
|
||||
return <WorkspaceWebhooksSettingsContent selectedWebhookId={activeWebhookId} workspaceSlug={currentWorkspace.slug} />;
|
||||
return (
|
||||
<WorkspaceWebhooksSettingsContent selectedWebhookId={activeWebhookId} workspaceSlug={currentWorkspace.slug} />
|
||||
);
|
||||
}
|
||||
|
||||
return <WorkspaceDetails />;
|
||||
|
|
@ -178,7 +193,7 @@ export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal()
|
|||
handleClose={handleClose}
|
||||
position={EModalPosition.CENTER}
|
||||
width={EModalWidth.VIIXL}
|
||||
className="h-[88vh] max-h-[920px] overflow-hidden border-0 bg-[rgba(10,10,14,0.96)] shadow-[0_28px_80px_rgba(0,0,0,0.42)]"
|
||||
className="h-[88vh] max-h-[920px] !max-w-[calc(100vw-1.5rem)] overflow-hidden border-0 bg-[rgba(10,10,14,0.96)] shadow-[0_28px_80px_rgba(0,0,0,0.42)] sm:!max-w-[calc(100vw-2rem)] xl:!max-w-[88rem]"
|
||||
>
|
||||
<div className="flex h-full min-h-0">
|
||||
<div className="hidden h-full w-[296px] shrink-0 md:block">
|
||||
|
|
@ -212,7 +227,7 @@ export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal()
|
|||
</div>
|
||||
|
||||
<ScrollArea scrollType="hover" orientation="vertical" size="sm" className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="mx-auto w-full max-w-[74rem] px-5 pb-7 lg:px-8">{renderContent()}</div>
|
||||
<div className="mx-auto w-full max-w-[78rem] px-5 pb-7 lg:px-8">{renderContent()}</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -260,7 +275,7 @@ function WorkspaceModalSidebar({
|
|||
|
||||
return (
|
||||
<div key={category} className="shrink-0 py-3.5 first:pt-0 last:pb-0">
|
||||
<div className="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.18em] text-tertiary">
|
||||
<div className="px-3 py-1.5 text-[11px] font-semibold tracking-[0.18em] text-tertiary uppercase">
|
||||
{t(category)}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,238 @@
|
|||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useSWRConfig } from "swr";
|
||||
// plane imports
|
||||
import { LIVE_BASE_PATH, LIVE_BASE_URL } from "@plane/constants";
|
||||
// hooks
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
|
||||
type TNodeDCRealtimeEvent = {
|
||||
event_id?: string;
|
||||
type?: string;
|
||||
workspace_slug?: string;
|
||||
project_id?: string;
|
||||
member_id?: string;
|
||||
affected_user_ids?: string[];
|
||||
project_ids?: string[];
|
||||
};
|
||||
|
||||
const MAX_PROCESSED_EVENTS = 250;
|
||||
|
||||
const buildNodeDCStreamUrl = () => {
|
||||
const liveBaseUrl = LIVE_BASE_URL?.trim() || window.location.origin;
|
||||
const liveBasePath = LIVE_BASE_PATH?.trim() || "/live";
|
||||
const url = new URL(liveBaseUrl);
|
||||
|
||||
url.protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
url.pathname = `${liveBasePath.replace(/\/$/, "")}/nodedc/stream`;
|
||||
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
const isWorkspacePath = (pathname: string | null, workspaceSlug: string) =>
|
||||
pathname === `/${workspaceSlug}` || !!pathname?.startsWith(`/${workspaceSlug}/`);
|
||||
|
||||
const isProjectPath = (pathname: string | null, workspaceSlug: string, projectId: string) =>
|
||||
pathname === `/${workspaceSlug}/projects/${projectId}` ||
|
||||
!!pathname?.startsWith(`/${workspaceSlug}/projects/${projectId}/`);
|
||||
|
||||
const isCurrentUserAffected = (event: TNodeDCRealtimeEvent, currentUserId?: string) => {
|
||||
if (!currentUserId) return false;
|
||||
if (event.member_id === currentUserId) return true;
|
||||
return event.affected_user_ids?.includes(currentUserId) ?? false;
|
||||
};
|
||||
|
||||
export const useNodeDCRealtimeEvents = (enabled: boolean, currentUserId?: string) => {
|
||||
const pathname = usePathname();
|
||||
const router = useAppRouter();
|
||||
const { mutate } = useSWRConfig();
|
||||
const workspaceRoot = useWorkspace();
|
||||
const projectStore = useProject();
|
||||
const memberRoot = useMember();
|
||||
const userStore = useUser();
|
||||
const pathnameRef = useRef(pathname);
|
||||
const currentUserIdRef = useRef(currentUserId);
|
||||
const processedEventIdsRef = useRef<string[]>([]);
|
||||
const processedEventSetRef = useRef(new Set<string>());
|
||||
|
||||
useEffect(() => {
|
||||
pathnameRef.current = pathname;
|
||||
}, [pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
currentUserIdRef.current = currentUserId;
|
||||
}, [currentUserId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || typeof window === "undefined") return;
|
||||
|
||||
let socket: WebSocket | undefined;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let cancelled = false;
|
||||
let reconnectAttempt = 0;
|
||||
|
||||
const rememberEvent = (eventId?: string) => {
|
||||
if (!eventId) return true;
|
||||
if (processedEventSetRef.current.has(eventId)) return false;
|
||||
|
||||
processedEventIdsRef.current.push(eventId);
|
||||
processedEventSetRef.current.add(eventId);
|
||||
|
||||
if (processedEventIdsRef.current.length > MAX_PROCESSED_EVENTS) {
|
||||
const removedEventId = processedEventIdsRef.current.shift();
|
||||
if (removedEventId) processedEventSetRef.current.delete(removedEventId);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const refreshWorkspaceScope = async (event: TNodeDCRealtimeEvent) => {
|
||||
const workspaceSlug = event.workspace_slug;
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
await Promise.allSettled([
|
||||
workspaceRoot.fetchWorkspaces(),
|
||||
memberRoot.workspace.fetchWorkspaceMembers(workspaceSlug),
|
||||
memberRoot.workspace.fetchWorkspaceMemberInvitations(workspaceSlug),
|
||||
projectStore.fetchProjects(workspaceSlug),
|
||||
userStore.permission.fetchUserProjectPermissions(workspaceSlug),
|
||||
]);
|
||||
|
||||
if (
|
||||
event.type === "workspace_member.deleted" &&
|
||||
isCurrentUserAffected(event, currentUserIdRef.current) &&
|
||||
isWorkspacePath(pathnameRef.current, workspaceSlug)
|
||||
) {
|
||||
router.replace(workspaceRoot.getWorkspaceRedirectionUrl());
|
||||
}
|
||||
};
|
||||
|
||||
const refreshProjectScope = async (event: TNodeDCRealtimeEvent) => {
|
||||
const workspaceSlug = event.workspace_slug;
|
||||
const projectId = event.project_id;
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
await Promise.allSettled([
|
||||
workspaceRoot.fetchWorkspaces(),
|
||||
memberRoot.workspace.fetchWorkspaceMembers(workspaceSlug),
|
||||
projectStore.fetchProjects(workspaceSlug),
|
||||
userStore.permission.fetchUserProjectPermissions(workspaceSlug),
|
||||
projectId ? memberRoot.project.fetchProjectMembers(workspaceSlug, projectId, true) : Promise.resolve(),
|
||||
]);
|
||||
|
||||
if (
|
||||
projectId &&
|
||||
event.type === "project_member.deleted" &&
|
||||
isCurrentUserAffected(event, currentUserIdRef.current) &&
|
||||
isProjectPath(pathnameRef.current, workspaceSlug, projectId)
|
||||
) {
|
||||
router.replace(`/${workspaceSlug}`);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshInviteScope = async (event: TNodeDCRealtimeEvent) => {
|
||||
await Promise.allSettled([
|
||||
mutate("USER_WORKSPACE_INVITATIONS_NOTICE"),
|
||||
event.workspace_slug
|
||||
? memberRoot.workspace.fetchWorkspaceMemberInvitations(event.workspace_slug)
|
||||
: Promise.resolve(),
|
||||
]);
|
||||
};
|
||||
|
||||
const refreshProfileScope = async (event: TNodeDCRealtimeEvent) => {
|
||||
await Promise.allSettled([
|
||||
isCurrentUserAffected(event, currentUserIdRef.current) ? userStore.fetchCurrentUser() : Promise.resolve(),
|
||||
event.workspace_slug ? memberRoot.workspace.fetchWorkspaceMembers(event.workspace_slug) : Promise.resolve(),
|
||||
event.workspace_slug && event.project_id
|
||||
? memberRoot.project.fetchProjectMembers(event.workspace_slug, event.project_id, true)
|
||||
: Promise.resolve(),
|
||||
mutate("USER_INFORMATION"),
|
||||
]);
|
||||
};
|
||||
|
||||
const handleEvent = async (event: TNodeDCRealtimeEvent) => {
|
||||
if (!rememberEvent(event.event_id)) return;
|
||||
|
||||
if (event.type?.startsWith("workspace_member.")) {
|
||||
await refreshWorkspaceScope(event);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type?.startsWith("project_member.")) {
|
||||
await refreshProjectScope(event);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type?.startsWith("workspace_invite.")) {
|
||||
await refreshInviteScope(event);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "user.profile.updated") {
|
||||
await refreshProfileScope(event);
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleReconnect = () => {
|
||||
if (cancelled) return;
|
||||
const delay = Math.min(1000 * 2 ** reconnectAttempt, 15000);
|
||||
reconnectAttempt += 1;
|
||||
reconnectTimer = setTimeout(connect, delay);
|
||||
};
|
||||
|
||||
const connect = () => {
|
||||
try {
|
||||
socket = new WebSocket(buildNodeDCStreamUrl());
|
||||
|
||||
socket.addEventListener("open", () => {
|
||||
reconnectAttempt = 0;
|
||||
});
|
||||
|
||||
socket.addEventListener("message", (message) => {
|
||||
try {
|
||||
const event = JSON.parse(message.data) as TNodeDCRealtimeEvent;
|
||||
if (event.type === "nodedc.stream.ping") {
|
||||
socket?.send(JSON.stringify({ type: "nodedc.stream.pong" }));
|
||||
return;
|
||||
}
|
||||
if (event.type === "nodedc.stream.ready") return;
|
||||
|
||||
void handleEvent(event);
|
||||
} catch (error) {
|
||||
console.error("Failed to process NODE.DC realtime event", error);
|
||||
}
|
||||
});
|
||||
|
||||
socket.addEventListener("close", () => {
|
||||
socket = undefined;
|
||||
scheduleReconnect();
|
||||
});
|
||||
|
||||
socket.addEventListener("error", () => {
|
||||
socket?.close();
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to connect NODE.DC realtime stream", error);
|
||||
scheduleReconnect();
|
||||
}
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
socket?.close();
|
||||
};
|
||||
}, [enabled, memberRoot, mutate, projectStore, router, userStore, workspaceRoot]);
|
||||
};
|
||||
|
|
@ -18,6 +18,7 @@ import { buildNodeDCOIDCLoginUrl, getCurrentRelativePath, shouldUseNodeDCOIDC }
|
|||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
import { useUser, useUserProfile, useUserSettings } from "@/hooks/store/user";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { useNodeDCRealtimeEvents } from "@/hooks/use-nodedc-realtime-events";
|
||||
// services
|
||||
import { WorkspaceService } from "@/services/workspace.service";
|
||||
|
||||
|
|
@ -64,6 +65,8 @@ export const AuthenticationWrapper = observer(function AuthenticationWrapper(pro
|
|||
const shouldWatchPendingInvites =
|
||||
pageType === EPageTypes.AUTHENTICATED && !!currentUser?.id && isUserOnboard && pathname !== "/invitations";
|
||||
|
||||
useNodeDCRealtimeEvents(pageType === EPageTypes.AUTHENTICATED && !!currentUser?.id && isUserOnboard, currentUser?.id);
|
||||
|
||||
const { data: pendingWorkspaceInvitations } = useSWR(
|
||||
shouldWatchPendingInvites ? "USER_WORKSPACE_INVITATIONS_NOTICE" : null,
|
||||
() => workspaceService.userWorkspaceInvitations(),
|
||||
|
|
@ -79,7 +82,12 @@ export const AuthenticationWrapper = observer(function AuthenticationWrapper(pro
|
|||
|
||||
const inviteKey = pendingWorkspaceInvitations
|
||||
.map((invitation) => invitation.id)
|
||||
.toSorted()
|
||||
.reduce<string[]>((sortedInvitationIds, invitationId) => {
|
||||
const insertAt = sortedInvitationIds.findIndex((sortedInvitationId) => sortedInvitationId > invitationId);
|
||||
if (insertAt === -1) sortedInvitationIds.push(invitationId);
|
||||
else sortedInvitationIds.splice(insertAt, 0, invitationId);
|
||||
return sortedInvitationIds;
|
||||
}, [])
|
||||
.join(":");
|
||||
if (pendingInviteToastKey.current === inviteKey) return;
|
||||
pendingInviteToastKey.current = inviteKey;
|
||||
|
|
|
|||
|
|
@ -236,15 +236,17 @@ export class WorkspaceMemberStore implements IWorkspaceMemberStore {
|
|||
fetchWorkspaceMembers = async (workspaceSlug: string) =>
|
||||
await this.workspaceService.fetchWorkspaceMembers(workspaceSlug).then((response) => {
|
||||
runInAction(() => {
|
||||
const nextWorkspaceMemberMap: Record<string, IWorkspaceMembership> = {};
|
||||
response.forEach((member) => {
|
||||
set(this.memberRoot?.memberMap, member.member.id, { ...member.member, joining_date: member.created_at });
|
||||
set(this.workspaceMemberMap, [workspaceSlug, member.member.id], {
|
||||
nextWorkspaceMemberMap[member.member.id] = {
|
||||
id: member.id,
|
||||
member: member.member.id,
|
||||
role: member.role,
|
||||
role: member.role as EUserPermissions,
|
||||
is_active: member.is_active,
|
||||
};
|
||||
});
|
||||
});
|
||||
set(this.workspaceMemberMap, workspaceSlug, nextWorkspaceMemberMap);
|
||||
});
|
||||
return response;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -341,6 +341,20 @@ export class ProjectStore implements IProjectStore {
|
|||
}
|
||||
const projectsResponse = await this.projectService.getProjects(workspaceSlug);
|
||||
runInAction(() => {
|
||||
const workspaceId = this.rootStore.workspaceRoot.getWorkspaceBySlug(workspaceSlug)?.id;
|
||||
const projectIds = new Set(projectsResponse.map((project) => project.id));
|
||||
|
||||
if (workspaceId) {
|
||||
Object.values(this.projectMap)
|
||||
.filter((project) => project.workspace === workspaceId && !projectIds.has(project.id))
|
||||
.forEach((project) => {
|
||||
delete this.projectMap[project.id];
|
||||
if (this.rootStore.user.permission.workspaceProjectsPermissions?.[workspaceSlug]) {
|
||||
delete this.rootStore.user.permission.workspaceProjectsPermissions[workspaceSlug][project.id];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
projectsResponse.forEach((project) => {
|
||||
update(this.projectMap, [project.id], (p) => ({ ...p, ...project }));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -188,9 +188,10 @@ export abstract class BaseWorkspaceRootStore implements IWorkspaceRootStore {
|
|||
try {
|
||||
const workspaceResponse = await this.workspaceService.userWorkspaces();
|
||||
runInAction(() => {
|
||||
workspaceResponse.forEach((workspace) => {
|
||||
set(this.workspaces, [workspace.id], workspace);
|
||||
});
|
||||
this.workspaces = workspaceResponse.reduce<Record<string, IWorkspace>>((workspaceMap, workspace) => {
|
||||
workspaceMap[workspace.id] = workspace;
|
||||
return workspaceMap;
|
||||
}, {});
|
||||
});
|
||||
return workspaceResponse;
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -58,14 +58,6 @@ export function CustomSearchSelect(props: ICustomSearchSelectProps) {
|
|||
const filteredOptions =
|
||||
query === "" ? options : options?.filter((option) => option.query.toLowerCase().includes(query.toLowerCase()));
|
||||
|
||||
const comboboxProps: any = {
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
};
|
||||
|
||||
if (multiple) comboboxProps.multiple = true;
|
||||
|
||||
const openDropdown = () => {
|
||||
setIsOpen(true);
|
||||
if (referenceElement) referenceElement.focus();
|
||||
|
|
@ -74,7 +66,7 @@ export function CustomSearchSelect(props: ICustomSearchSelectProps) {
|
|||
|
||||
const closeDropdown = () => {
|
||||
setIsOpen(false);
|
||||
onClose && onClose();
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
|
||||
|
|
@ -85,10 +77,24 @@ export function CustomSearchSelect(props: ICustomSearchSelectProps) {
|
|||
else openDropdown();
|
||||
};
|
||||
|
||||
const handleChange = (nextValue: any) => {
|
||||
onChange(nextValue);
|
||||
if (!multiple) closeDropdown();
|
||||
};
|
||||
|
||||
const comboboxProps: any = {
|
||||
value,
|
||||
onChange: handleChange,
|
||||
disabled,
|
||||
};
|
||||
|
||||
if (multiple) comboboxProps.multiple = true;
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
as="div"
|
||||
ref={dropdownRef}
|
||||
role="presentation"
|
||||
tabIndex={tabIndex}
|
||||
className={cn("relative flex-shrink-0 text-left", className)}
|
||||
onKeyDown={handleKeyDown}
|
||||
|
|
|
|||
Loading…
Reference in New Issue