chore: implemented multiple modules select in the issues (#3484)

* fix: add multiple module in an issue

* feat: implemented multiple modules select in the issue detail and issue peekoverview and resolved build errors.

* feat: handled module parameters type error in the issue create and draft modal

* feat: handled UI for modules select dropdown

* fix: delete module activity updated

* ui: module issue activity

* fix: module search endpoint and issue fetch in the modules

* fix: module ids optimized

* fix: replaced module_id from boolean to array of module Id's in module search modal params

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
This commit is contained in:
guru_sainath 2024-01-30 15:23:20 +05:30 committed by GitHub
parent c6d6b9a0e9
commit 804dd8300d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
43 changed files with 1016 additions and 488 deletions

View File

@ -562,7 +562,7 @@ class IssueSerializer(DynamicBaseSerializer):
state_id = serializers.PrimaryKeyRelatedField(read_only=True)
parent_id = serializers.PrimaryKeyRelatedField(read_only=True)
cycle_id = serializers.PrimaryKeyRelatedField(read_only=True)
module_id = serializers.PrimaryKeyRelatedField(read_only=True)
module_ids = serializers.SerializerMethodField()
# Many to many
label_ids = serializers.PrimaryKeyRelatedField(
@ -597,7 +597,7 @@ class IssueSerializer(DynamicBaseSerializer):
"project_id",
"parent_id",
"cycle_id",
"module_id",
"module_ids",
"label_ids",
"assignee_ids",
"sub_issues_count",
@ -613,6 +613,10 @@ class IssueSerializer(DynamicBaseSerializer):
]
read_only_fields = fields
def get_module_ids(self, obj):
# Access the prefetched modules and extract module IDs
return [module for module in obj.issue_module.values_list("module_id", flat=True)]
class IssueLiteSerializer(DynamicBaseSerializer):
workspace_detail = WorkspaceLiteSerializer(

View File

@ -35,17 +35,26 @@ urlpatterns = [
name="project-modules",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:module_id>/module-issues/",
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/modules/",
ModuleIssueViewSet.as_view(
{
"post": "create_issue_modules",
}
),
name="issue-module",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:module_id>/issues/",
ModuleIssueViewSet.as_view(
{
"post": "create_module_issues",
"get": "list",
"post": "create",
}
),
name="project-module-issues",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:module_id>/module-issues/<uuid:issue_id>/",
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:module_id>/issues/<uuid:issue_id>/",
ModuleIssueViewSet.as_view(
{
"get": "retrieve",

View File

@ -599,16 +599,11 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
)
.filter(project_id=project_id)
.filter(workspace__slug=slug)
.select_related("project")
.select_related("workspace")
.select_related("state")
.select_related("parent")
.prefetch_related("assignees")
.prefetch_related("labels")
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.order_by(order_by)
.filter(**filters)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(module_id=F("issue_module__module_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()

View File

@ -100,7 +100,7 @@ def dashboard_assigned_issues(self, request, slug):
)
.filter(**filters)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels")
.prefetch_related("assignees", "labels", "issue_module__module")
.prefetch_related(
Prefetch(
"issue_relation",
@ -110,7 +110,6 @@ def dashboard_assigned_issues(self, request, slug):
)
)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(module_id=F("issue_module__module_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
@ -221,9 +220,8 @@ def dashboard_created_issues(self, request, slug):
)
.filter(**filters)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(module_id=F("issue_module__module_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()

View File

@ -95,7 +95,7 @@ class InboxIssueViewSet(BaseViewSet):
issue_inbox__inbox_id=self.kwargs.get("inbox_id")
)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("labels", "assignees")
.prefetch_related("assignees", "labels", "issue_module__module")
.prefetch_related(
Prefetch(
"issue_inbox",
@ -105,7 +105,6 @@ class InboxIssueViewSet(BaseViewSet):
)
)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(module_id=F("issue_module__module_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()

View File

@ -112,12 +112,8 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
project_id=self.kwargs.get("project_id")
)
.filter(workspace__slug=self.kwargs.get("slug"))
.select_related("project")
.select_related("workspace")
.select_related("state")
.select_related("parent")
.prefetch_related("assignees")
.prefetch_related("labels")
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.prefetch_related(
Prefetch(
"issue_reactions",
@ -125,7 +121,6 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
)
)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(module_id=F("issue_module__module_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
@ -1087,12 +1082,31 @@ class IssueArchiveViewSet(BaseViewSet):
.filter(archived_at__isnull=False)
.filter(project_id=self.kwargs.get("project_id"))
.filter(workspace__slug=self.kwargs.get("slug"))
.select_related("project")
.select_related("workspace")
.select_related("state")
.select_related("parent")
.prefetch_related("assignees")
.prefetch_related("labels")
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
)
@method_decorator(gzip_page)
@ -1120,22 +1134,6 @@ class IssueArchiveViewSet(BaseViewSet):
issue_queryset = (
self.get_queryset()
.filter(**filters)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(module_id=F("issue_module__module_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
)
# Priority Ordering
@ -1681,18 +1679,37 @@ class IssueDraftViewSet(BaseViewSet):
.filter(project_id=self.kwargs.get("project_id"))
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(is_draft=True)
.select_related("project")
.select_related("workspace")
.select_related("state")
.select_related("parent")
.prefetch_related("assignees")
.prefetch_related("labels")
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
)
@method_decorator(gzip_page)
@ -1719,22 +1736,6 @@ class IssueDraftViewSet(BaseViewSet):
issue_queryset = (
self.get_queryset()
.filter(**filters)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(module_id=F("issue_module__module_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
)
# Priority Ordering

View File

@ -7,6 +7,8 @@ from django.db.models import Prefetch, F, OuterRef, Func, Exists, Count, Q
from django.core import serializers
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from django.core.serializers.json import DjangoJSONEncoder
# Third party imports
from rest_framework.response import Response
@ -296,23 +298,20 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
"issue", flat=True
)
)
_ = [
issue_activity.delay(
type="module.activity.deleted",
requested_data=json.dumps(
{
"module_id": str(pk),
"module_name": str(module.name),
"issues": [str(issue_id) for issue_id in module_issues],
}
),
requested_data=json.dumps({"module_id": str(pk)}),
actor_id=str(request.user.id),
issue_id=str(pk),
project_id=str(project_id),
current_instance=None,
issue_id=str(issue),
project_id=project_id,
current_instance=json.dumps({"module_name": str(module.name)}),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
for issue in module_issues
]
module.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@ -332,62 +331,18 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
ProjectEntityPermission,
]
def get_queryset(self):
return self.filter_queryset(
super()
.get_queryset()
.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("issue")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(module_id=self.kwargs.get("module_id"))
.filter(project__project_projectmember__member=self.request.user)
.select_related("project")
.select_related("workspace")
.select_related("module")
.select_related("issue", "issue__state", "issue__project")
.prefetch_related("issue__assignees", "issue__labels")
.prefetch_related("module__members")
.distinct()
)
@method_decorator(gzip_page)
def list(self, request, slug, project_id, module_id):
fields = [
field
for field in request.GET.get("fields", "").split(",")
if field
]
order_by = request.GET.get("order_by", "created_at")
filters = issue_filters(request.query_params, "GET")
issues = (
Issue.issue_objects.filter(issue_module__module_id=module_id)
.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
def get_queryset(self):
return (
Issue.objects.filter(
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
issue_module__module_id=self.kwargs.get("module_id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.filter(project_id=project_id)
.filter(workspace__slug=slug)
.select_related("project")
.select_related("workspace")
.select_related("state")
.select_related("parent")
.prefetch_related("assignees")
.prefetch_related("labels")
.order_by(order_by)
.filter(**filters)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("labels", "assignees")
.prefetch_related('issue_module__module')
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(module_id=F("issue_module__module_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
@ -403,106 +358,119 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
.values("count")
)
.annotate(
is_subscribed=Exists(
IssueSubscriber.objects.filter(
subscriber=self.request.user, issue_id=OuterRef("id")
)
)
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
).distinct()
@method_decorator(gzip_page)
def list(self, request, slug, project_id, module_id):
fields = [
field
for field in request.GET.get("fields", "").split(",")
if field
]
filters = issue_filters(request.query_params, "GET")
issue_queryset = self.get_queryset().filter(**filters)
serializer = IssueSerializer(
issues, many=True, fields=fields if fields else None
issue_queryset, many=True, fields=fields if fields else None
)
return Response(serializer.data, status=status.HTTP_200_OK)
def create(self, request, slug, project_id, module_id):
# create multiple issues inside a module
def create_module_issues(self, request, slug, project_id, module_id):
issues = request.data.get("issues", [])
if not len(issues):
return Response(
{"error": "Issues are required"},
status=status.HTTP_400_BAD_REQUEST,
)
module = Module.objects.get(
workspace__slug=slug, project_id=project_id, pk=module_id
)
module_issues = list(ModuleIssue.objects.filter(issue_id__in=issues))
update_module_issue_activity = []
records_to_update = []
record_to_create = []
for issue in issues:
module_issue = [
module_issue
for module_issue in module_issues
if str(module_issue.issue_id) in issues
]
if len(module_issue):
if module_issue[0].module_id != module_id:
update_module_issue_activity.append(
{
"old_module_id": str(module_issue[0].module_id),
"new_module_id": str(module_id),
"issue_id": str(module_issue[0].issue_id),
}
)
module_issue[0].module_id = module_id
records_to_update.append(module_issue[0])
else:
record_to_create.append(
project = Project.objects.get(pk=project_id)
_ = ModuleIssue.objects.bulk_create(
[
ModuleIssue(
module=module,
issue_id=issue,
issue_id=str(issue),
module_id=module_id,
project_id=project_id,
workspace=module.workspace,
workspace_id=project.workspace_id,
created_by=request.user,
updated_by=request.user,
)
)
ModuleIssue.objects.bulk_create(
record_to_create,
for issue in issues
],
batch_size=10,
ignore_conflicts=True,
)
ModuleIssue.objects.bulk_update(
records_to_update,
["module"],
batch_size=10,
)
# Capture Issue Activity
# Bulk Update the activity
_ = [
issue_activity.delay(
type="module.activity.created",
requested_data=json.dumps({"modules_list": issues}),
actor_id=str(self.request.user.id),
issue_id=None,
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(
{
"updated_module_issues": update_module_issue_activity,
"created_module_issues": serializers.serialize(
"json", record_to_create
),
}
),
requested_data=json.dumps({"module_id": str(module_id)}),
actor_id=str(request.user.id),
issue_id=str(issue),
project_id=project_id,
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
for issue in issues
]
issues = (self.get_queryset().filter(pk__in=issues))
serializer = IssueSerializer(issues , many=True)
return Response(serializer.data, status=status.HTTP_201_CREATED)
issues = self.get_queryset().values_list("issue_id", flat=True)
# create multiple module inside an issue
def create_issue_modules(self, request, slug, project_id, issue_id):
modules = request.data.get("modules", [])
if not len(modules):
return Response(
IssueSerializer(
Issue.objects.filter(pk__in=issues), many=True
).data,
status=status.HTTP_200_OK,
{"error": "Modules are required"},
status=status.HTTP_400_BAD_REQUEST,
)
project = Project.objects.get(pk=project_id)
_ = ModuleIssue.objects.bulk_create(
[
ModuleIssue(
issue_id=issue_id,
module_id=module,
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
updated_by=request.user,
)
for module in modules
],
batch_size=10,
ignore_conflicts=True,
)
# Bulk Update the activity
_ = [
issue_activity.delay(
type="module.activity.created",
requested_data=json.dumps({"module_id": module}),
actor_id=str(request.user.id),
issue_id=issue_id,
project_id=project_id,
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
for module in modules
]
issue = (self.get_queryset().filter(pk=issue_id).first())
serializer = IssueSerializer(issue)
return Response(serializer.data, status=status.HTTP_201_CREATED)
def destroy(self, request, slug, project_id, module_id, issue_id):
module_issue = ModuleIssue.objects.get(
workspace__slug=slug,
@ -512,16 +480,11 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
)
issue_activity.delay(
type="module.activity.deleted",
requested_data=json.dumps(
{
"module_id": str(module_id),
"issues": [str(issue_id)],
}
),
requested_data=json.dumps({"module_id": str(module_id)}),
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=None,
current_instance=json.dumps({"module_name": module_issue.module.name}),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),

View File

@ -228,7 +228,7 @@ class IssueSearchEndpoint(BaseAPIView):
parent = request.query_params.get("parent", "false")
issue_relation = request.query_params.get("issue_relation", "false")
cycle = request.query_params.get("cycle", "false")
module = request.query_params.get("module", "false")
module = request.query_params.get("module", False)
sub_issue = request.query_params.get("sub_issue", "false")
issue_id = request.query_params.get("issue_id", False)
@ -269,8 +269,8 @@ class IssueSearchEndpoint(BaseAPIView):
if cycle == "true":
issues = issues.exclude(issue_cycle__isnull=False)
if module == "true":
issues = issues.exclude(issue_module__isnull=False)
if module:
issues = issues.exclude(issue_module__module=module)
return Response(
issues.values(

View File

@ -87,12 +87,8 @@ class GlobalViewIssuesViewSet(BaseViewSet):
.values("count")
)
.filter(workspace__slug=self.kwargs.get("slug"))
.select_related("project")
.select_related("workspace")
.select_related("state")
.select_related("parent")
.prefetch_related("assignees")
.prefetch_related("labels")
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.prefetch_related(
Prefetch(
"issue_reactions",
@ -127,7 +123,6 @@ class GlobalViewIssuesViewSet(BaseViewSet):
.filter(**filters)
.filter(project__project_projectmember__member=self.request.user)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(module_id=F("issue_module__module_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
@ -150,13 +145,6 @@ class GlobalViewIssuesViewSet(BaseViewSet):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
is_subscribed=Exists(
IssueSubscriber.objects.filter(
subscriber=self.request.user, issue_id=OuterRef("id")
)
)
)
)
# Priority Ordering

View File

@ -1346,9 +1346,8 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
)
.filter(**filters)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(module_id=F("issue_module__module_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()

View File

@ -30,6 +30,7 @@ from plane.app.serializers import IssueActivitySerializer
from plane.bgtasks.notification_task import notifications
from plane.settings.redis import redis_instance
# Track Changes in name
def track_name(
requested_data,
@ -852,58 +853,14 @@ def create_module_issue_activity(
requested_data = (
json.loads(requested_data) if requested_data is not None else None
)
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
# Updated Records:
updated_records = current_instance.get("updated_module_issues", [])
created_records = json.loads(
current_instance.get("created_module_issues", [])
)
for updated_record in updated_records:
old_module = Module.objects.filter(
pk=updated_record.get("old_module_id", None)
).first()
new_module = Module.objects.filter(
pk=updated_record.get("new_module_id", None)
).first()
issue = Issue.objects.filter(pk=updated_record.get("issue_id")).first()
if issue:
issue.updated_at = timezone.now()
issue.save(update_fields=["updated_at"])
issue_activities.append(
IssueActivity(
issue_id=updated_record.get("issue_id"),
actor_id=actor_id,
verb="updated",
old_value=old_module.name,
new_value=new_module.name,
field="modules",
project_id=project_id,
workspace_id=workspace_id,
comment=f"updated module to ",
old_identifier=old_module.id,
new_identifier=new_module.id,
epoch=epoch,
)
)
for created_record in created_records:
module = Module.objects.filter(
pk=created_record.get("fields").get("module")
).first()
issue = Issue.objects.filter(
pk=created_record.get("fields").get("issue")
).first()
module = Module.objects.filter(pk=requested_data.get("module_id")).first()
issue = Issue.objects.filter(pk=issue_id).first()
if issue:
issue.updated_at = timezone.now()
issue.save(update_fields=["updated_at"])
issue_activities.append(
IssueActivity(
issue_id=created_record.get("fields").get("issue"),
issue_id=issue_id,
actor_id=actor_id,
verb="created",
old_value="",
@ -912,7 +869,7 @@ def create_module_issue_activity(
project_id=project_id,
workspace_id=workspace_id,
comment=f"added module {module.name}",
new_identifier=module.id,
new_identifier=requested_data.get("module_id"),
epoch=epoch,
)
)
@ -934,29 +891,23 @@ def delete_module_issue_activity(
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
module_id = requested_data.get("module_id", "")
module_name = requested_data.get("module_name", "")
module = Module.objects.filter(pk=module_id).first()
issues = requested_data.get("issues")
for issue in issues:
current_issue = Issue.objects.filter(pk=issue).first()
if issue:
module_name = current_instance.get("module_name")
current_issue = Issue.objects.filter(pk=issue_id).first()
if current_issue:
current_issue.updated_at = timezone.now()
current_issue.save(update_fields=["updated_at"])
issue_activities.append(
IssueActivity(
issue_id=issue,
issue_id=issue_id,
actor_id=actor_id,
verb="deleted",
old_value=module.name if module is not None else module_name,
old_value=module_name,
new_value="",
field="modules",
project_id=project_id,
workspace_id=workspace_id,
comment=f"removed this issue from {module.name if module is not None else module_name}",
old_identifier=module_id if module_id is not None else None,
comment=f"removed this issue from {module_name}",
old_identifier=requested_data.get("module_id") if requested_data.get("module_id") is not None else None,
epoch=epoch,
)
)
@ -1649,7 +1600,6 @@ def issue_activity(
except Exception as e:
capture_exception(e)
if notification:
notifications.delay(
type=type,

View File

@ -0,0 +1,23 @@
# Generated by Django 4.2.7 on 2024-01-24 18:55
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('db', '0057_auto_20240122_0901'),
]
operations = [
migrations.AlterField(
model_name='moduleissue',
name='issue',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_module', to='db.issue'),
),
migrations.AlterUniqueTogether(
name='moduleissue',
unique_together={('issue', 'module')},
),
]

View File

@ -134,11 +134,12 @@ class ModuleIssue(ProjectBaseModel):
module = models.ForeignKey(
"db.Module", on_delete=models.CASCADE, related_name="issue_module"
)
issue = models.OneToOneField(
issue = models.ForeignKey(
"db.Issue", on_delete=models.CASCADE, related_name="issue_module"
)
class Meta:
unique_together = ["issue", "module"]
verbose_name = "Module Issue"
verbose_name_plural = "Module Issues"
db_table = "module_issues"

View File

@ -21,7 +21,7 @@ export type TIssue = {
project_id: string;
parent_id: string | null;
cycle_id: string | null;
module_id: string | null;
module_ids: string[] | null;
created_at: string;
updated_at: string;

View File

@ -117,7 +117,7 @@ export type TProjectIssuesSearchParams = {
parent?: boolean;
issue_relation?: boolean;
cycle?: boolean;
module?: boolean;
module?: string[];
sub_issue?: boolean;
issue_id?: string;
workspace_search: boolean;

View File

@ -216,7 +216,7 @@ export const CommandPalette: FC = observer(() => {
<CreateUpdateIssueModal
isOpen={isCreateIssueModalOpen}
onClose={() => toggleCreateIssueModal(false)}
data={cycleId ? { cycle_id: cycleId.toString() } : moduleId ? { module_id: moduleId.toString() } : undefined}
data={cycleId ? { cycle_id: cycleId.toString() } : moduleId ? { module_ids: [moduleId.toString()] } : undefined}
storeType={createIssueStoreType}
/>

View File

@ -3,6 +3,7 @@ export * from "./cycle";
export * from "./date";
export * from "./estimate";
export * from "./module";
export * from "./module-select";
export * from "./priority";
export * from "./project";
export * from "./state";

View File

@ -0,0 +1,114 @@
import { FC } from "react";
import { twMerge } from "tailwind-merge";
import { observer } from "mobx-react-lite";
import { ChevronDown, X } from "lucide-react";
// hooks
import { useModule } from "hooks/store";
// ui and components
import { DiceIcon, Tooltip } from "@plane/ui";
// types
import { TModuleSelectButton } from "./types";
export const ModuleSelectButton: FC<TModuleSelectButton> = observer((props) => {
const {
value,
onChange,
placeholder,
buttonClassName,
buttonVariant,
hideIcon,
hideText,
dropdownArrow,
dropdownArrowClassName,
showTooltip,
showCount,
} = props;
// hooks
const { getModuleById } = useModule();
return (
<div
className={twMerge(
`w-full h-full relative overflow-hidden flex justify-between items-center gap-1 rounded text-sm px-2`,
buttonVariant === "border-with-text"
? `border-[0.5px] border-custom-border-300 hover:bg-custom-background-80`
: ``,
buttonVariant === "border-without-text"
? `border-[0.5px] border-custom-border-300 hover:bg-custom-background-80`
: ``,
buttonVariant === "background-with-text" ? `bg-custom-background-80` : ``,
buttonVariant === "background-without-text" ? `bg-custom-background-80` : ``,
buttonVariant === "transparent-with-text" ? `hover:bg-custom-background-80` : ``,
buttonVariant === "transparent-without-text" ? `hover:bg-custom-background-80` : ``,
buttonClassName
)}
>
<div className="relative overflow-hidden h-full flex flex-wrap items-center gap-1">
{value && typeof value === "string" ? (
<div className="relative overflow-hidden flex items-center gap-1.5">
{!hideIcon && <DiceIcon className="h-3 w-3 flex-shrink-0" />}
{!hideText && (
<span className="w-full overflow-hidden truncate inline-block line-clamp-1 capitalize">
{getModuleById(value)?.name || placeholder}
</span>
)}
</div>
) : value && Array.isArray(value) && value.length > 0 ? (
showCount ? (
<div className="relative overflow-hidden flex items-center gap-1.5">
{!hideIcon && <DiceIcon className="h-3 w-3 flex-shrink-0" />}
{!hideText && (
<span className="w-full overflow-hidden truncate inline-block line-clamp-1 capitalize">
{value.length} Modules
</span>
)}
</div>
) : (
value.map((moduleId) => {
const _module = getModuleById(moduleId);
if (!_module) return <></>;
return (
<div className="relative flex justify-between items-center gap-1 min-w-[60px] max-w-[84px] overflow-hidden bg-custom-background-80 px-1.5 py-1 rounded">
<Tooltip tooltipContent={_module?.name} disabled={!showTooltip}>
<div className="relative overflow-hidden flex items-center gap-1.5">
{!hideIcon && <DiceIcon className="h-3 w-3 flex-shrink-0" />}
{!hideText && (
<span className="w-full truncate inline-block line-clamp-1 capitalize">{_module?.name}</span>
)}
</div>
</Tooltip>
<Tooltip tooltipContent="Remove" disabled={!showTooltip}>
<span
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onChange(_module.id);
}}
>
<X className="h-2.5 w-2.5 text-custom-text-300 hover:text-red-500" />
</span>
</Tooltip>
</div>
);
})
)
) : (
!hideText && (
<div className="relative overflow-hidden flex items-center gap-1.5">
{!hideIcon && <DiceIcon className="h-3 w-3 flex-shrink-0" />}
{!hideText && (
<span className="w-full overflow-hidden truncate inline-block line-clamp-1 capitalize">
{placeholder}
</span>
)}
</div>
)
)}
</div>
{dropdownArrow && (
<ChevronDown className={twMerge("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
)}
</div>
);
});

View File

@ -0,0 +1,2 @@
export * from "./button";
export * from "./select";

View File

@ -0,0 +1,227 @@
import { FC, useEffect, useRef, useState, Fragment } from "react";
import { observer } from "mobx-react-lite";
import { Combobox } from "@headlessui/react";
import { usePopper } from "react-popper";
import { Check, Search } from "lucide-react";
import { twMerge } from "tailwind-merge";
// hooks
import { useModule } from "hooks/store";
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// components
import { ModuleSelectButton } from "./";
// types
import { TModuleSelectDropdown, TModuleSelectDropdownOption } from "./types";
import { DiceIcon } from "@plane/ui";
export const ModuleSelectDropdown: FC<TModuleSelectDropdown> = observer((props) => {
// props
const {
workspaceSlug,
projectId,
value = undefined,
onChange,
placeholder = "Module",
multiple = false,
disabled = false,
className = "",
buttonContainerClassName = "",
buttonClassName = "",
buttonVariant = "transparent-with-text",
hideIcon = false,
dropdownArrow = false,
dropdownArrowClassName = "",
showTooltip = false,
showCount = false,
placement,
tabIndex,
button,
} = props;
// states
const [query, setQuery] = useState("");
const [isOpen, setIsOpen] = useState(false);
// refs
const dropdownRef = useRef<HTMLDivElement | null>(null);
// popper-js refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
// popper-js init
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: placement ?? "bottom-start",
modifiers: [
{
name: "preventOverflow",
options: {
padding: 12,
},
},
],
});
// store hooks
const { getProjectModuleIds, fetchModules, getModuleById } = useModule();
const moduleIds = getProjectModuleIds(projectId);
const options: TModuleSelectDropdownOption[] | undefined = moduleIds?.map((moduleId) => {
const moduleDetails = getModuleById(moduleId);
return {
value: moduleId,
query: `${moduleDetails?.name}`,
content: (
<div className="flex items-center gap-2">
<DiceIcon className="h-3 w-3 flex-shrink-0" />
<span className="flex-grow truncate">{moduleDetails?.name}</span>
</div>
),
};
});
!multiple &&
options?.unshift({
value: undefined,
query: "No module",
content: (
<div className="flex items-center gap-2">
<DiceIcon className="h-3 w-3 flex-shrink-0" />
<span className="flex-grow truncate">No module</span>
</div>
),
});
const filteredOptions =
query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase()));
// fetch modules of the project if not already present in the store
useEffect(() => {
if (!workspaceSlug) return;
if (!moduleIds) fetchModules(workspaceSlug, projectId);
}, [moduleIds, fetchModules, projectId, workspaceSlug]);
const openDropdown = () => {
if (isOpen) closeDropdown();
else {
setIsOpen(true);
if (referenceElement) referenceElement.focus();
}
};
const closeDropdown = () => setIsOpen(false);
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
useOutsideClickDetector(dropdownRef, closeDropdown);
const comboboxProps: any = {};
if (multiple) comboboxProps.multiple = true;
return (
<Combobox
as="div"
ref={dropdownRef}
tabIndex={tabIndex}
className={twMerge("h-full", className)}
value={value}
onChange={onChange}
disabled={disabled}
onKeyDown={handleKeyDown}
{...comboboxProps}
>
<Combobox.Button as={Fragment}>
{button ? (
<button
ref={setReferenceElement}
type="button"
className={twMerge(
"block h-full max-w-full outline-none",
disabled ? "cursor-not-allowed text-custom-text-200" : "cursor-pointer",
buttonContainerClassName
)}
onClick={openDropdown}
>
{button}
</button>
) : (
<button
ref={setReferenceElement}
type="button"
className={twMerge(
"block h-full max-w-full outline-none ",
disabled ? "cursor-not-allowed text-custom-text-200" : "cursor-pointer",
buttonContainerClassName
)}
onClick={openDropdown}
>
<ModuleSelectButton
value={value}
onChange={onChange}
placeholder={placeholder}
disabled={disabled}
buttonClassName={buttonClassName}
buttonVariant={buttonVariant}
hideIcon={hideIcon}
hideText={["border-without-text", "background-without-text", "transparent-without-text"].includes(
buttonVariant
)}
dropdownArrow={dropdownArrow}
dropdownArrowClassName={dropdownArrowClassName}
showTooltip={showTooltip}
showCount={showCount}
/>
</button>
)}
</Combobox.Button>
{isOpen && (
<Combobox.Options className="fixed z-10" static>
<div
className="my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
<Combobox.Input
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search"
displayValue={(moduleIds: any) => {
const displayValueOptions: TModuleSelectDropdownOption[] | undefined = options?.filter((_module) =>
moduleIds.includes(_module.value)
);
return displayValueOptions?.map((_option) => _option.query).join(", ") || "Select Module";
}}
/>
</div>
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
{filteredOptions ? (
filteredOptions.length > 0 ? (
filteredOptions.map((option) => (
<Combobox.Option
key={option.value}
value={option.value}
className={({ active, selected }) =>
`w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${
active ? "bg-custom-background-80" : ""
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
}
onClick={() => !multiple && closeDropdown()}
>
{({ selected }) => (
<>
<span className="flex-grow truncate">{option.content}</span>
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
</>
)}
</Combobox.Option>
))
) : (
<p className="text-custom-text-400 italic py-1 px-1.5">No matching results</p>
)
) : (
<p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
)}
</div>
</div>
</Combobox.Options>
)}
</Combobox>
);
});

View File

@ -0,0 +1,50 @@
import { ReactNode } from "react";
import { Placement } from "@popperjs/core";
import { TDropdownProps, TButtonVariants } from "../types";
type TModuleSelectDropdownRoot = Omit<
TDropdownProps,
"buttonClassName",
"buttonContainerClassName",
"buttonContainerClassName",
"className",
"disabled",
"hideIcon",
"placeholder",
"placement",
"tabIndex",
"tooltip"
>;
export type TModuleSelectDropdownBase = {
value: string | string[] | undefined;
onChange: (moduleIds: undefined | string | (string | undefined)[]) => void;
placeholder?: string;
disabled?: boolean;
buttonClassName?: string;
buttonVariant?: TButtonVariants;
hideIcon?: boolean;
dropdownArrow?: boolean;
dropdownArrowClassName?: string;
showTooltip?: boolean;
showCount?: boolean;
};
export type TModuleSelectButton = TModuleSelectDropdownBase & { hideText?: boolean };
export type TModuleSelectDropdown = TModuleSelectDropdownBase & {
workspaceSlug: string;
projectId: string;
multiple?: boolean;
className?: string;
buttonContainerClassName?: string;
placement?: Placement;
tabIndex?: number;
button?: ReactNode;
};
export type TModuleSelectDropdownOption = {
value: string | undefined;
query: string;
content: JSX.Element;
};

View File

@ -21,7 +21,7 @@ import {
CycleDropdown,
DateDropdown,
EstimateDropdown,
ModuleDropdown,
ModuleSelectDropdown,
PriorityDropdown,
ProjectDropdown,
ProjectMemberDropdown,
@ -152,7 +152,7 @@ export const DraftIssueForm: FC<IssueFormProps> = observer((props) => {
project_id: watch("project_id"),
parent_id: watch("parent_id"),
cycle_id: watch("cycle_id"),
module_id: watch("module_id"),
module_ids: watch("module_ids"),
};
useEffect(() => {
@ -570,15 +570,17 @@ export const DraftIssueForm: FC<IssueFormProps> = observer((props) => {
)}
/>
)}
{projectDetails?.module_view && (
{projectDetails?.module_view && workspaceSlug && (
<Controller
control={control}
name="module_id"
name="module_ids"
render={({ field: { value, onChange } }) => (
<div className="h-7">
<ModuleDropdown
<ModuleSelectDropdown
workspaceSlug={workspaceSlug?.toString()}
projectId={projectId}
value={value}
value={value || undefined}
onChange={(moduleId) => onChange(moduleId)}
buttonVariant="border-with-text"
/>
@ -586,6 +588,7 @@ export const DraftIssueForm: FC<IssueFormProps> = observer((props) => {
)}
/>
)}
{(fieldsToShow.includes("all") || fieldsToShow.includes("estimate")) &&
areEstimatesEnabledForProject(projectId) && (
<Controller

View File

@ -94,7 +94,7 @@ export const CreateUpdateDraftIssueModal: React.FC<IssuesModalProps> = observer(
cycle: cycleId.toString(),
}));
}
if (moduleId && !prePopulateDataProps?.module_id) {
if (moduleId && !prePopulateDataProps?.module_ids) {
setPreloadedData((prevData) => ({
...(prevData ?? {}),
...prePopulateDataProps,
@ -123,7 +123,7 @@ export const CreateUpdateDraftIssueModal: React.FC<IssuesModalProps> = observer(
cycle: cycleId.toString(),
}));
}
if (moduleId && !prePopulateDataProps?.module_id) {
if (moduleId && !prePopulateDataProps?.module_ids) {
setPreloadedData((prevData) => ({
...(prevData ?? {}),
...prePopulateDataProps,
@ -233,11 +233,11 @@ export const CreateUpdateDraftIssueModal: React.FC<IssuesModalProps> = observer(
});
};
const addIssueToModule = async (issueId: string, moduleId: string) => {
const addIssueToModule = async (issueId: string, moduleIds: string[]) => {
if (!workspaceSlug || !activeProject) return;
await moduleService.addIssuesToModule(workspaceSlug as string, activeProject ?? "", moduleId as string, {
issues: [issueId],
await moduleService.addModulesToIssue(workspaceSlug as string, activeProject ?? "", issueId as string, {
modules: moduleIds,
});
};
@ -248,7 +248,7 @@ export const CreateUpdateDraftIssueModal: React.FC<IssuesModalProps> = observer(
.createIssue(workspaceSlug.toString(), activeProject, payload)
.then(async (res) => {
if (payload.cycle_id && payload.cycle_id !== "") await addIssueToCycle(res.id, payload.cycle_id);
if (payload.module_id && payload.module_id !== "") await addIssueToModule(res.id, payload.module_id);
if (payload.module_ids && payload.module_ids.length > 0) await addIssueToModule(res.id, payload.module_ids);
setToastAlert({
type: "success",

View File

@ -59,7 +59,7 @@ export const IssueModuleActivity: FC<TIssueModuleActivity> = observer((props) =>
rel="noopener noreferrer"
className="inline-flex items-center gap-1 truncate font-medium text-custom-text-100 hover:underline"
>
<span className="truncate"> {activity.new_value}</span>
<span className="truncate"> {activity.old_value}</span>
</a>
</>
)}

View File

@ -1,9 +1,10 @@
import React, { useState } from "react";
import { observer } from "mobx-react-lite";
import xor from "lodash/xor";
// hooks
import { useIssueDetail } from "hooks/store";
// components
import { ModuleDropdown } from "components/dropdowns";
import { ModuleSelectDropdown } from "components/dropdowns";
// ui
import { Spinner } from "@plane/ui";
// helpers
@ -32,58 +33,56 @@ export const IssueModuleSelect: React.FC<TIssueModuleSelect> = observer((props)
const issue = getIssueById(issueId);
const disableSelect = disabled || isUpdating;
const handleIssueModuleChange = async (moduleId: string | null) => {
if (!issue || issue.module_id === moduleId) return;
const handleIssueModuleChange = async (moduleIds: undefined | string | (string | undefined)[]) => {
if (!issue) return;
setIsUpdating(true);
if (moduleId) await issueOperations.addIssueToModule?.(workspaceSlug, projectId, moduleId, [issueId]);
else await issueOperations.removeIssueFromModule?.(workspaceSlug, projectId, issue.module_id ?? "", issueId);
if (moduleIds === undefined && issue?.module_ids && issue?.module_ids.length > 0)
await issueOperations.removeModulesFromIssue?.(workspaceSlug, projectId, issueId, issue?.module_ids);
if (typeof moduleIds === "string" && moduleIds)
await issueOperations.removeModulesFromIssue?.(workspaceSlug, projectId, issueId, [moduleIds]);
if (Array.isArray(moduleIds)) {
if (moduleIds.includes(undefined)) {
await issueOperations.removeModulesFromIssue?.(
workspaceSlug,
projectId,
issueId,
moduleIds.filter((x) => x != undefined) as string[]
);
} else {
const _moduleIds = xor(issue?.module_ids, moduleIds)[0];
if (_moduleIds) {
if (issue?.module_ids?.includes(_moduleIds))
await issueOperations.removeModulesFromIssue?.(workspaceSlug, projectId, issueId, [_moduleIds]);
else await issueOperations.addModulesToIssue?.(workspaceSlug, projectId, issueId, [_moduleIds]);
}
}
}
setIsUpdating(false);
};
return (
<div className={cn("flex items-center gap-1 h-full", className)}>
<ModuleDropdown
value={issue?.module_id ?? null}
onChange={handleIssueModuleChange}
buttonVariant="transparent-with-text"
<div className={cn(`flex items-center gap-1 h-full`, className)}>
<ModuleSelectDropdown
workspaceSlug={workspaceSlug}
projectId={projectId}
disabled={disableSelect}
className="w-full group"
buttonContainerClassName="w-full text-left"
buttonClassName={`text-sm ${issue?.module_id ? "" : "text-custom-text-400"}`}
value={issue?.module_ids?.length ? issue?.module_ids : undefined}
onChange={handleIssueModuleChange}
multiple={true}
placeholder="No module"
hideIcon
dropdownArrow
dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline"
/>
{/* <CustomSearchSelect
value={issue?.module_id}
onChange={(value: any) => handleIssueModuleChange(value)}
options={options}
customButton={
<div>
<Tooltip position="left" tooltipContent={`${issueModule?.name ?? "No module"}`}>
<button
type="button"
className={`flex w-full items-center rounded bg-custom-background-80 px-2.5 py-0.5 text-xs ${
disableSelect ? "cursor-not-allowed" : ""
} max-w-[10rem]`}
>
<span
className={`flex items-center gap-1.5 truncate ${
issueModule ? "text-custom-text-100" : "text-custom-text-200"
}`}
>
<span className="flex-shrink-0">{issueModule && <DiceIcon className="h-3.5 w-3.5" />}</span>
<span className="truncate">{issueModule?.name ?? "No module"}</span>
</span>
</button>
</Tooltip>
</div>
}
noChevron
disabled={disableSelect}
/> */}
className={`w-full h-full group`}
buttonContainerClassName="w-full"
buttonClassName={`min-h-8 ${issue?.module_ids?.length ? `` : `text-custom-text-400`}`}
buttonVariant="transparent-with-text"
hideIcon={false}
dropdownArrow={true}
dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline"
showTooltip={true}
/>
{isUpdating && <Spinner className="h-4 w-4" />}
</div>
);

View File

@ -29,13 +29,19 @@ export type TIssueOperations = {
remove: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
addIssueToCycle?: (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => Promise<void>;
removeIssueFromCycle?: (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => Promise<void>;
addIssueToModule?: (workspaceSlug: string, projectId: string, moduleId: string, issueIds: string[]) => Promise<void>;
addModulesToIssue?: (workspaceSlug: string, projectId: string, issueId: string, moduleIds: string[]) => Promise<void>;
removeIssueFromModule?: (
workspaceSlug: string,
projectId: string,
moduleId: string,
issueId: string
) => Promise<void>;
removeModulesFromIssue?: (
workspaceSlug: string,
projectId: string,
issueId: string,
moduleIds: string[]
) => Promise<void>;
};
export type TIssueDetailRoot = {
@ -57,8 +63,9 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = (props) => {
removeIssue,
addIssueToCycle,
removeIssueFromCycle,
addIssueToModule,
addModulesToIssue,
removeIssueFromModule,
removeModulesFromIssue,
} = useIssueDetail();
const {
issues: { removeIssue: removeArchivedIssue },
@ -150,9 +157,9 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = (props) => {
});
}
},
addIssueToModule: async (workspaceSlug: string, projectId: string, moduleId: string, issueIds: string[]) => {
addModulesToIssue: async (workspaceSlug: string, projectId: string, issueId: string, moduleIds: string[]) => {
try {
await addIssueToModule(workspaceSlug, projectId, moduleId, issueIds);
await addModulesToIssue(workspaceSlug, projectId, issueId, moduleIds);
setToastAlert({
title: "Module added to issue successfully",
type: "success",
@ -182,6 +189,27 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = (props) => {
});
}
},
removeModulesFromIssue: async (
workspaceSlug: string,
projectId: string,
issueId: string,
moduleIds: string[]
) => {
try {
await removeModulesFromIssue(workspaceSlug, projectId, issueId, moduleIds);
setToastAlert({
title: "Module removed from issue successfully",
type: "success",
message: "Module removed from issue successfully",
});
} catch (error) {
setToastAlert({
title: "Module remove from issue failed",
type: "error",
message: "Module remove from issue failed",
});
}
},
}),
[
is_archived,
@ -191,8 +219,9 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = (props) => {
removeArchivedIssue,
addIssueToCycle,
removeIssueFromCycle,
addIssueToModule,
addModulesToIssue,
removeIssueFromModule,
removeModulesFromIssue,
setToastAlert,
]
);

View File

@ -286,7 +286,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
)}
{projectDetails?.module_view && (
<div className="flex items-center gap-2 h-8">
<div className="flex items-center gap-2 min-h-8 h-full">
<div className="flex items-center gap-1 w-2/5 flex-shrink-0 text-sm text-custom-text-300">
<DiceIcon className="h-4 w-4 flex-shrink-0" />
<span>Module</span>

View File

@ -46,11 +46,7 @@ export const ModuleEmptyState: React.FC<Props> = observer((props) => {
const issueIds = data.map((i) => i.id);
await issues
.addIssueToModule(workspaceSlug.toString(), projectId?.toString(), moduleId.toString(), issueIds)
.then((res) => {
updateIssue(workspaceSlug, projectId, res.id, res);
fetchIssue(workspaceSlug, projectId, res.id);
})
.addIssuesToModule(workspaceSlug.toString(), projectId?.toString(), moduleId.toString(), issueIds)
.catch(() =>
setToastAlert({
type: "error",
@ -69,7 +65,7 @@ export const ModuleEmptyState: React.FC<Props> = observer((props) => {
projectId={projectId}
isOpen={moduleIssuesListModal}
handleClose={() => setModuleIssuesListModal(false)}
searchParams={{ module: true }}
searchParams={{ module: moduleId != undefined ? [moduleId.toString()] : [] }}
handleOnSubmit={handleAddIssuesToModule}
/>
<div className="grid h-full w-full place-items-center">

View File

@ -44,7 +44,7 @@ export interface IBaseKanBanLayout {
showLoader?: boolean;
viewId?: string;
storeType?: TCreateModalStoreTypes;
addIssuesToView?: (issueIds: string[]) => Promise<TIssue>;
addIssuesToView?: (issueIds: string[]) => Promise<any>;
canEditPropertiesBasedOnProject?: (projectId: string) => boolean;
}

View File

@ -56,7 +56,7 @@ export const HeaderGroupByCard: FC<IHeaderGroupByCard> = observer((props) => {
const { setToastAlert } = useToast();
const renderExistingIssueModal = moduleId || cycleId;
const ExistingIssuesListModalPayload = moduleId ? { module: true } : { cycle: true };
const ExistingIssuesListModalPayload = moduleId ? { module: [moduleId.toString()] } : { cycle: true };
const handleAddIssuesToView = async (data: ISearchIssueResponse[]) => {
if (!workspaceSlug || !projectId) return;

View File

@ -53,7 +53,7 @@ export const ModuleKanBanLayout: React.FC = observer(() => {
storeType={EIssuesStoreType.MODULE}
addIssuesToView={(issueIds: string[]) => {
if (!workspaceSlug || !projectId || !moduleId) throw new Error();
return issues.addIssueToModule(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), issueIds);
return issues.addIssuesToModule(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), issueIds);
}}
/>
);

View File

@ -49,7 +49,7 @@ interface IBaseListRoot {
};
viewId?: string;
storeType: TCreateModalStoreTypes;
addIssuesToView?: (issueIds: string[]) => Promise<TIssue>;
addIssuesToView?: (issueIds: string[]) => Promise<any>;
canEditPropertiesBasedOnProject?: (projectId: string) => boolean;
}

View File

@ -37,7 +37,7 @@ export const HeaderGroupByCard = observer(
const { setToastAlert } = useToast();
const renderExistingIssueModal = moduleId || cycleId;
const ExistingIssuesListModalPayload = moduleId ? { module: true } : { cycle: true };
const ExistingIssuesListModalPayload = moduleId ? { module: [moduleId.toString()] } : { cycle: true };
const handleAddIssuesToView = async (data: ISearchIssueResponse[]) => {
if (!workspaceSlug || !projectId) return;

View File

@ -51,7 +51,7 @@ export const ModuleListLayout: React.FC = observer(() => {
storeType={EIssuesStoreType.MODULE}
addIssuesToView={(issueIds: string[]) => {
if (!workspaceSlug || !projectId || !moduleId) throw new Error();
return issues.addIssueToModule(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), issueIds);
return issues.addIssuesToModule(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), issueIds);
}}
/>
);

View File

@ -20,7 +20,7 @@ import {
CycleDropdown,
DateDropdown,
EstimateDropdown,
ModuleDropdown,
ModuleSelectDropdown,
PriorityDropdown,
ProjectDropdown,
ProjectMemberDropdown,
@ -44,7 +44,7 @@ const defaultValues: Partial<TIssue> = {
assignee_ids: [],
label_ids: [],
cycle_id: null,
module_id: null,
module_ids: null,
start_date: null,
target_date: null,
};
@ -541,21 +541,24 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
)}
/>
)}
{projectDetails?.module_view && (
{projectDetails?.module_view && workspaceSlug && (
<Controller
control={control}
name="module_id"
name="module_ids"
render={({ field: { value, onChange } }) => (
<div className="h-7">
<ModuleDropdown
<ModuleSelectDropdown
workspaceSlug={workspaceSlug.toString()}
projectId={projectId}
value={value}
value={value || undefined}
onChange={(moduleId) => {
onChange(moduleId);
handleFormChange();
}}
buttonVariant="border-with-text"
tabIndex={13}
multiple={true}
showCount={true}
/>
</div>
)}

View File

@ -108,11 +108,11 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = observer((prop
fetchCycleDetails(workspaceSlug, activeProjectId, cycleId);
};
const addIssueToModule = async (issue: TIssue, moduleId: string) => {
const addIssueToModule = async (issue: TIssue, moduleIds: string[]) => {
if (!workspaceSlug || !activeProjectId) return;
await moduleIssues.addIssueToModule(workspaceSlug, activeProjectId, moduleId, [issue.id]);
fetchModuleDetails(workspaceSlug, activeProjectId, moduleId);
await moduleIssues.addModulesToIssue(workspaceSlug, activeProjectId, issue.id, moduleIds);
moduleIds.forEach((moduleId) => fetchModuleDetails(workspaceSlug, activeProjectId, moduleId));
};
const handleCreateMoreToggleChange = (value: boolean) => {
@ -139,8 +139,8 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = observer((prop
if (payload.cycle_id && payload.cycle_id !== "" && storeType !== EIssuesStoreType.CYCLE)
await addIssueToCycle(response, payload.cycle_id);
if (payload.module_id && payload.module_id !== "" && storeType !== EIssuesStoreType.MODULE)
await addIssueToModule(response, payload.module_id);
if (payload.module_ids && payload.module_ids.length > 0 && storeType !== EIssuesStoreType.MODULE)
await addIssueToModule(response, payload.module_ids);
setToastAlert({
type: "success",
@ -278,7 +278,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = observer((prop
data={{
...data,
cycle_id: data?.cycle_id ? data?.cycle_id : cycleId ? cycleId : null,
module_id: data?.module_id ? data?.module_id : moduleId ? moduleId : null,
module_ids: data?.module_ids ? data?.module_ids : moduleId ? [moduleId] : null,
}}
onChange={handleFormChange}
onClose={handleClose}
@ -292,7 +292,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = observer((prop
data={{
...data,
cycle_id: data?.cycle_id ? data?.cycle_id : cycleId ? cycleId : null,
module_id: data?.module_id ? data?.module_id : moduleId ? moduleId : null,
module_ids: data?.module_ids ? data?.module_ids : moduleId ? [moduleId] : null,
}}
onClose={() => handleClose(false)}
isCreateMoreToggleEnabled={createMore}

View File

@ -203,7 +203,7 @@ export const PeekOverviewProperties: FC<IPeekOverviewProperties> = observer((pro
)}
{projectDetails?.module_view && (
<div className="flex w-full items-center gap-3 h-8">
<div className="flex w-full items-center gap-3 min-h-8 h-full">
<div className="flex items-center gap-1 w-1/4 flex-shrink-0 text-sm text-custom-text-300">
<DiceIcon className="h-4 w-4 flex-shrink-0" />
<span>Module</span>

View File

@ -28,8 +28,19 @@ export type TIssuePeekOperations = {
remove: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
addIssueToCycle: (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => Promise<void>;
removeIssueFromCycle: (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => Promise<void>;
addIssueToModule: (workspaceSlug: string, projectId: string, moduleId: string, issueIds: string[]) => Promise<void>;
removeIssueFromModule: (workspaceSlug: string, projectId: string, moduleId: string, issueId: string) => Promise<void>;
addModulesToIssue?: (workspaceSlug: string, projectId: string, issueId: string, moduleIds: string[]) => Promise<void>;
removeIssueFromModule?: (
workspaceSlug: string,
projectId: string,
moduleId: string,
issueId: string
) => Promise<void>;
removeModulesFromIssue?: (
workspaceSlug: string,
projectId: string,
issueId: string,
moduleIds: string[]
) => Promise<void>;
};
export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
@ -48,7 +59,8 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
removeIssue,
issue: { getIssueById, fetchIssue },
} = useIssueDetail();
const { addIssueToCycle, removeIssueFromCycle, addIssueToModule, removeIssueFromModule } = useIssueDetail();
const { addIssueToCycle, removeIssueFromCycle, addModulesToIssue, removeIssueFromModule, removeModulesFromIssue } =
useIssueDetail();
// state
const [loader, setLoader] = useState(false);
@ -143,9 +155,9 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
});
}
},
addIssueToModule: async (workspaceSlug: string, projectId: string, moduleId: string, issueIds: string[]) => {
addModulesToIssue: async (workspaceSlug: string, projectId: string, issueId: string, moduleIds: string[]) => {
try {
await addIssueToModule(workspaceSlug, projectId, moduleId, issueIds);
await addModulesToIssue(workspaceSlug, projectId, issueId, moduleIds);
setToastAlert({
title: "Module added to issue successfully",
type: "success",
@ -175,6 +187,27 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
});
}
},
removeModulesFromIssue: async (
workspaceSlug: string,
projectId: string,
issueId: string,
moduleIds: string[]
) => {
try {
await removeModulesFromIssue(workspaceSlug, projectId, issueId, moduleIds);
setToastAlert({
title: "Module removed from issue successfully",
type: "success",
message: "Module removed from issue successfully",
});
} catch (error) {
setToastAlert({
title: "Module remove from issue failed",
type: "error",
message: "Module remove from issue failed",
});
}
},
}),
[
is_archived,
@ -184,8 +217,9 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
removeArchivedIssue,
addIssueToCycle,
removeIssueFromCycle,
addIssueToModule,
addModulesToIssue,
removeIssueFromModule,
removeModulesFromIssue,
setToastAlert,
onIssueUpdate,
]

View File

@ -47,7 +47,7 @@ export const AppProvider: FC<IAppProvider> = observer((props) => {
<InstanceLayout>
<StoreWrapper>
<CrispWrapper user={currentUser}>
<PosthogWrapper
{/* <PosthogWrapper
user={currentUser}
workspaceRole={currentWorkspaceRole}
projectRole={currentProjectRole}
@ -55,7 +55,8 @@ export const AppProvider: FC<IAppProvider> = observer((props) => {
posthogHost={envConfig?.posthog_host || null}
>
<SWRConfig value={SWR_CONFIG}>{children}</SWRConfig>
</PosthogWrapper>
</PosthogWrapper> */}
<SWRConfig value={SWR_CONFIG}>{children}</SWRConfig>
</CrispWrapper>
</StoreWrapper>
</InstanceLayout>

View File

@ -1,7 +1,7 @@
// services
import { APIService } from "services/api.service";
// types
import type { IModule, TIssue, ILinkDetails, ModuleLink, TIssueMap } from "@plane/types";
import type { IModule, TIssue, ILinkDetails, ModuleLink } from "@plane/types";
import { API_BASE_URL } from "helpers/common.helper";
export class ModuleService extends APIService {
@ -63,22 +63,7 @@ export class ModuleService extends APIService {
}
async getModuleIssues(workspaceSlug: string, projectId: string, moduleId: string, queries?: any): Promise<TIssue[]> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/module-issues/`, {
params: queries,
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async getModuleIssuesWithParams(
workspaceSlug: string,
projectId: string,
moduleId: string,
queries?: any
): Promise<TIssue[] | { [key: string]: TIssue[] }> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/module-issues/`, {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/issues/`, {
params: queries,
})
.then((response) => response?.data)
@ -92,15 +77,21 @@ export class ModuleService extends APIService {
projectId: string,
moduleId: string,
data: { issues: string[] }
): Promise<
{
issue: string;
issue_detail: TIssue;
module: string;
module_detail: IModule;
}[]
> {
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/module-issues/`, data)
): Promise<void> {
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/issues/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async addModulesToIssue(
workspaceSlug: string,
projectId: string,
issueId: string,
data: { modules: string[] }
): Promise<void> {
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/modules/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
@ -111,17 +102,53 @@ export class ModuleService extends APIService {
workspaceSlug: string,
projectId: string,
moduleId: string,
bridgeId: string
issueId: string
): Promise<any> {
return this.delete(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/module-issues/${bridgeId}/`
)
return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/issues/${issueId}/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async removeIssuesFromModuleBulk(
workspaceSlug: string,
projectId: string,
moduleId: string,
issueIds: string[]
): Promise<any> {
const promiseDataUrls: any = [];
issueIds.forEach((issueId) => {
promiseDataUrls.push(
this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/issues/${issueId}/`)
);
});
return await Promise.all(promiseDataUrls)
.then((response) => response)
.catch((error) => {
throw error?.response?.data;
});
}
async removeModulesFromIssueBulk(
workspaceSlug: string,
projectId: string,
issueId: string,
moduleIds: string[]
): Promise<any> {
const promiseDataUrls: any = [];
moduleIds.forEach((moduleId) => {
promiseDataUrls.push(
this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/issues/${issueId}/`)
);
});
return await Promise.all(promiseDataUrls)
.then((response) => response)
.catch((error) => {
throw error?.response?.data;
});
}
async createModuleLink(
workspaceSlug: string,
projectId: string,

View File

@ -12,13 +12,14 @@ export interface IIssueStoreActions {
removeIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise<TIssue>;
addIssueToCycle: (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => Promise<TIssue>;
removeIssueFromCycle: (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => Promise<TIssue>;
addIssueToModule: (workspaceSlug: string, projectId: string, moduleId: string, issueIds: string[]) => Promise<any>;
removeIssueFromModule: (
addModulesToIssue: (workspaceSlug: string, projectId: string, issueId: string, moduleIds: string[]) => Promise<any>;
removeModulesFromIssue: (
workspaceSlug: string,
projectId: string,
moduleId: string,
issueId: string
) => Promise<TIssue>;
issueId: string,
moduleIds: string[]
) => Promise<void>;
removeIssueFromModule: (workspaceSlug: string, projectId: string, moduleId: string, issueId: string) => Promise<void>;
}
export interface IIssueStore extends IIssueStoreActions {
@ -143,15 +144,26 @@ export class IssueStore implements IIssueStore {
return cycle;
};
addIssueToModule = async (workspaceSlug: string, projectId: string, moduleId: string, issueIds: string[]) => {
const _module = await this.rootIssueDetailStore.rootIssueStore.moduleIssues.addIssueToModule(
addModulesToIssue = async (workspaceSlug: string, projectId: string, issueId: string, moduleIds: string[]) => {
const _module = await this.rootIssueDetailStore.rootIssueStore.moduleIssues.addModulesToIssue(
workspaceSlug,
projectId,
moduleId,
issueIds
issueId,
moduleIds
);
if (issueIds && issueIds.length > 0)
await this.rootIssueDetailStore.activity.fetchActivities(workspaceSlug, projectId, issueIds[0]);
if (moduleIds && moduleIds.length > 0)
await this.rootIssueDetailStore.activity.fetchActivities(workspaceSlug, projectId, issueId);
return _module;
};
removeModulesFromIssue = async (workspaceSlug: string, projectId: string, issueId: string, moduleIds: string[]) => {
const _module = await this.rootIssueDetailStore.rootIssueStore.moduleIssues.removeModulesFromIssue(
workspaceSlug,
projectId,
issueId,
moduleIds
);
await this.rootIssueDetailStore.activity.fetchActivities(workspaceSlug, projectId, issueId);
return _module;
};

View File

@ -143,8 +143,10 @@ export class IssueDetail implements IIssueDetail {
this.issue.addIssueToCycle(workspaceSlug, projectId, cycleId, issueIds);
removeIssueFromCycle = async (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) =>
this.issue.removeIssueFromCycle(workspaceSlug, projectId, cycleId, issueId);
addIssueToModule = async (workspaceSlug: string, projectId: string, moduleId: string, issueIds: string[]) =>
this.issue.addIssueToModule(workspaceSlug, projectId, moduleId, issueIds);
addModulesToIssue = async (workspaceSlug: string, projectId: string, issueId: string, moduleIds: string[]) =>
this.issue.addModulesToIssue(workspaceSlug, projectId, issueId, moduleIds);
removeModulesFromIssue = async (workspaceSlug: string, projectId: string, issueId: string, moduleIds: string[]) =>
this.issue.removeModulesFromIssue(workspaceSlug, projectId, issueId, moduleIds);
removeIssueFromModule = async (workspaceSlug: string, projectId: string, moduleId: string, issueId: string) =>
this.issue.removeIssueFromModule(workspaceSlug, projectId, moduleId, issueId);

View File

@ -52,13 +52,21 @@ export interface IModuleIssues {
data: TIssue,
moduleId?: string | undefined
) => Promise<TIssue | undefined>;
addIssueToModule: (workspaceSlug: string, projectId: string, moduleId: string, issueIds: string[]) => Promise<any>;
removeIssueFromModule: (
addIssuesToModule: (workspaceSlug: string, projectId: string, moduleId: string, issueIds: string[]) => Promise<void>;
removeIssuesFromModule: (
workspaceSlug: string,
projectId: string,
moduleId: string,
issueId: string
) => Promise<TIssue>;
issueIds: string[]
) => Promise<void>;
addModulesToIssue: (workspaceSlug: string, projectId: string, issueId: string, moduleIds: string[]) => Promise<void>;
removeModulesFromIssue: (
workspaceSlug: string,
projectId: string,
issueId: string,
moduleIds: string[]
) => Promise<void>;
removeIssueFromModule: (workspaceSlug: string, projectId: string, moduleId: string, issueId: string) => Promise<void>;
}
export class ModuleIssues extends IssueHelperStore implements IModuleIssues {
@ -90,7 +98,10 @@ export class ModuleIssues extends IssueHelperStore implements IModuleIssues {
updateIssue: action,
removeIssue: action,
quickAddIssue: action,
addIssueToModule: action,
addIssuesToModule: action,
removeIssuesFromModule: action,
addModulesToIssue: action,
removeModulesFromIssue: action,
removeIssueFromModule: action,
});
@ -175,7 +186,7 @@ export class ModuleIssues extends IssueHelperStore implements IModuleIssues {
if (!moduleId) throw new Error("Module Id is required");
const response = await this.rootIssueStore.projectIssues.createIssue(workspaceSlug, projectId, data);
await this.addIssueToModule(workspaceSlug, projectId, moduleId, [response.id]);
await this.addIssuesToModule(workspaceSlug, projectId, moduleId, [response.id]);
return response;
} catch (error) {
@ -253,7 +264,7 @@ export class ModuleIssues extends IssueHelperStore implements IModuleIssues {
}
};
addIssueToModule = async (workspaceSlug: string, projectId: string, moduleId: string, issueIds: string[]) => {
addIssuesToModule = async (workspaceSlug: string, projectId: string, moduleId: string, issueIds: string[]) => {
try {
const issueToModule = await this.moduleService.addIssuesToModule(workspaceSlug, projectId, moduleId, {
issues: issueIds,
@ -261,11 +272,16 @@ export class ModuleIssues extends IssueHelperStore implements IModuleIssues {
runInAction(() => {
update(this.issues, moduleId, (moduleIssueIds = []) => {
uniq(concat(moduleIssueIds, issueIds));
if (!moduleIssueIds) return [...issueIds];
else return uniq(concat(moduleIssueIds, issueIds));
});
});
issueIds.forEach((issueId) => {
this.rootStore.issues.updateIssue(issueId, { module_id: moduleId });
update(this.rootStore.issues.issuesMap, [issueId, "module_ids"], (issueModuleIds = []) => {
if (issueModuleIds.includes(moduleId)) return issueModuleIds;
else return uniq(concat(issueModuleIds, [moduleId]));
});
});
return issueToModule;
@ -274,14 +290,96 @@ export class ModuleIssues extends IssueHelperStore implements IModuleIssues {
}
};
removeIssuesFromModule = async (workspaceSlug: string, projectId: string, moduleId: string, issueIds: string[]) => {
try {
runInAction(() => {
issueIds.forEach((issueId) => {
pull(this.issues[moduleId], issueId);
});
});
runInAction(() => {
issueIds.forEach((issueId) => {
update(this.rootStore.issues.issuesMap, [issueId, "module_ids"], (issueModuleIds = []) => {
if (issueModuleIds.includes(moduleId)) return pull(issueModuleIds, moduleId);
else return uniq(concat(issueModuleIds, [moduleId]));
});
});
});
const response = await this.moduleService.removeIssuesFromModuleBulk(
workspaceSlug,
projectId,
moduleId,
issueIds
);
return response;
} catch (error) {
throw error;
}
};
addModulesToIssue = async (workspaceSlug: string, projectId: string, issueId: string, moduleIds: string[]) => {
try {
const issueToModule = await this.moduleService.addModulesToIssue(workspaceSlug, projectId, issueId, {
modules: moduleIds,
});
runInAction(() => {
moduleIds.forEach((moduleId) => {
update(this.issues, moduleId, (moduleIssueIds = []) => {
if (moduleIssueIds.includes(issueId)) return moduleIssueIds;
else return uniq(concat(moduleIssueIds, [issueId]));
});
});
update(this.rootStore.issues.issuesMap, [issueId, "module_ids"], (issueModuleIds = []) =>
uniq(concat(issueModuleIds, moduleIds))
);
});
return issueToModule;
} catch (error) {
throw error;
}
};
removeModulesFromIssue = async (workspaceSlug: string, projectId: string, issueId: string, moduleIds: string[]) => {
try {
runInAction(() => {
moduleIds.forEach((moduleId) => {
update(this.issues, moduleId, (moduleIssueIds = []) => {
if (moduleIssueIds.includes(issueId)) return moduleIssueIds;
else return uniq(concat(moduleIssueIds, [issueId]));
});
update(this.rootStore.issues.issuesMap, [issueId, "module_ids"], (issueModuleIds = []) =>
pull(issueModuleIds, moduleId)
);
});
});
const response = await this.moduleService.removeModulesFromIssueBulk(
workspaceSlug,
projectId,
issueId,
moduleIds
);
return response;
} catch (error) {
throw error;
}
};
removeIssueFromModule = async (workspaceSlug: string, projectId: string, moduleId: string, issueId: string) => {
try {
runInAction(() => {
pull(this.issues[moduleId], issueId);
update(this.rootStore.issues.issuesMap, [issueId, "module_ids"], (issueModuleIds = []) =>
pull(issueModuleIds, moduleId)
);
});
this.rootStore.issues.updateIssue(issueId, { module_id: null });
const response = await this.moduleService.removeIssueFromModule(workspaceSlug, projectId, moduleId, issueId);
return response;