release: v0.25.1

* fix: issue activity for project id validation (#6668)

* fix: work item attachment count mutation (#6670)

* updated the action to modify the release build assets (#6669)

* feat: russian translation (#6666)

* chore: ru translation updated (#6672)

* fix: state drop down refactor

* fix: intake work item creation refactor

* fix: cleanup for deprecated functions

* fix: date range picker on cycles and modules list (#6676)

* fix: Handled workspace switcher closing on click

* fix: replaced date range picker with date picker at some places

* chore: add common translation keys (#6688)

* chore: add missing translation keys

* chore: add russian translation keys

* fix: issue activity task (#6689)

* changed github workflow action ubuntu version to `ubuntu-22.04` (#6683)

* chore: update russian translation (#6682)

* chore: update russian translation

* chore: rename issues to work items in russian translation

* [PE-275] chore: editor line spacing variables (#6678)

* chore: variable editor line spacing

* chore: variable list spacing

---------

Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>

* [WEB-3475] fix: cycle dates dropdown (#6690)

* fix: Handled workspace switcher closing on click

* fix: Cycle date picker

* fix: Made onSelect optional in range range component

* fix: module date picker (#6691)

* fix: Handled workspace switcher closing on click

* fix: reverted module date picker changes

* chore: extended sidebar improvement (#6693)

* feat: italian translations (#6692)

* Create translations.json - ITALIAN translation (#6667)

* chore: italian translation updated

* feat: italian translation added

* fix: module end date translation

---------

Co-authored-by: Nicolas Bossi <nicolasbossi@gmail.com>
Co-authored-by: gakshita <akshitagoyal1516@gmail.com>

* fix: attachment item created by (#6695)

* fix: module flicker issue on property updation (#6699)

* [WEB-3477] fix: mutation issue on moving work items for a manually ended cycle (#6696)

* fix: package version update

* fix: esbuild version fix

* fix: package license repliation

* [WEB-3488] improvement: assignee validation for work item creation (#6701)

* fix: work item assignee update validation (#6704)

---------

Co-authored-by: Nikhil <118773738+pablohashescobar@users.noreply.github.com>
Co-authored-by: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com>
Co-authored-by: Manish Gupta <59428681+mguptahub@users.noreply.github.com>
Co-authored-by: Nikita Mitasov <32384814+ch4og@users.noreply.github.com>
Co-authored-by: Akshita Goyal <36129505+gakshita@users.noreply.github.com>
Co-authored-by: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com>
Co-authored-by: Akshat Jain <akshatjain9782@gmail.com>
Co-authored-by: Lakhan Baheti <94619783+1akhanBaheti@users.noreply.github.com>
Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
Co-authored-by: Nicolas Bossi <nicolasbossi@gmail.com>
Co-authored-by: gakshita <akshitagoyal1516@gmail.com>
Co-authored-by: Prateek Shourya <prateekshourya29@gmail.com>
This commit is contained in:
sriram veeraghanta 2025-03-05 19:15:33 +05:30 committed by GitHub
commit e61ff879c4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
71 changed files with 5094 additions and 1263 deletions

View file

@ -88,7 +88,7 @@ jobs:
full_build_push:
if: ${{ needs.branch_build_setup.outputs.do_full_build == 'true' }}
runs-on: ubuntu-20.04
runs-on: ubuntu-22.04
needs: [branch_build_setup]
env:
BUILD_TYPE: full
@ -148,7 +148,7 @@ jobs:
slim_build_push:
if: ${{ needs.branch_build_setup.outputs.do_slim_build == 'true' }}
runs-on: ubuntu-20.04
runs-on: ubuntu-22.04
needs: [branch_build_setup]
env:
BUILD_TYPE: slim

View file

@ -299,32 +299,6 @@ jobs:
buildx-platforms: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
attach_assets_to_build:
if: ${{ needs.branch_build_setup.outputs.build_type == 'Release' }}
name: Attach Assets to Release
runs-on: ubuntu-22.04
needs: [branch_build_setup]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Update Assets
run: |
cp ./deploy/selfhost/install.sh deploy/selfhost/setup.sh
- name: Attach Assets
id: attach_assets
uses: actions/upload-artifact@v4
with:
name: selfhost-assets
retention-days: 2
path: |
${{ github.workspace }}/deploy/selfhost/setup.sh
${{ github.workspace }}/deploy/selfhost/swarm.sh
${{ github.workspace }}/deploy/selfhost/restore.sh
${{ github.workspace }}/deploy/selfhost/docker-compose.yml
${{ github.workspace }}/deploy/selfhost/variables.env
publish_release:
if: ${{ needs.branch_build_setup.outputs.build_type == 'Release' }}
name: Build Release
@ -338,7 +312,6 @@ jobs:
branch_build_push_live,
branch_build_push_apiserver,
branch_build_push_proxy,
attach_assets_to_build,
]
env:
REL_VERSION: ${{ needs.branch_build_setup.outputs.release_version }}
@ -349,6 +322,8 @@ jobs:
- name: Update Assets
run: |
cp ./deploy/selfhost/install.sh deploy/selfhost/setup.sh
sed -i 's/${APP_RELEASE:-stable}/${APP_RELEASE:-'${REL_VERSION}'}/g' deploy/selfhost/docker-compose.yml
sed -i 's/APP_RELEASE=stable/APP_RELEASE='${REL_VERSION}'/g' deploy/selfhost/variables.env
- name: Create Release
id: create_release

View file

@ -51,7 +51,7 @@ jobs:
uses: actions/checkout@v4
full_build_push:
runs-on: ubuntu-20.04
runs-on: ubuntu-22.04
needs: [branch_build_setup]
env:
BUILD_TYPE: full

View file

@ -11,7 +11,7 @@ env:
jobs:
sync_changes:
runs-on: ubuntu-20.04
runs-on: ubuntu-22.04
permissions:
pull-requests: write
contents: read

View file

@ -1,6 +1,8 @@
{
"name": "admin",
"version": "0.25.0",
"description": "Admin UI for Plane",
"version": "0.25.1",
"license": "AGPL-3.0",
"private": true,
"scripts": {
"dev": "turbo run develop",

View file

@ -1,4 +1,7 @@
{
"name": "plane-api",
"version": "0.25.0"
"version": "0.25.1",
"license": "AGPL-3.0",
"private": true,
"description": "API server powering Plane's backend"
}

View file

@ -80,6 +80,7 @@ class IssueSerializer(BaseSerializer):
data["assignees"] = ProjectMember.objects.filter(
project_id=self.context.get("project_id"),
is_active=True,
role__gte=15,
member_id__in=data["assignees"],
).values_list("member_id", flat=True)
@ -158,8 +159,13 @@ class IssueSerializer(BaseSerializer):
pass
else:
try:
# Then assign it to default assignee
if default_assignee_id is not None:
# Then assign it to default assignee, if it is a valid assignee
if default_assignee_id is not None and ProjectMember.objects.filter(
member_id=default_assignee_id,
project_id=project_id,
role__gte=15,
is_active=True
).exists():
IssueAssignee.objects.create(
assignee_id=default_assignee_id,
issue=issue,

View file

@ -121,8 +121,6 @@ from .exporter import ExporterHistorySerializer
from .webhook import WebhookSerializer, WebhookLogSerializer
from .dashboard import DashboardSerializer, WidgetSerializer
from .favorite import UserFavoriteSerializer
from .draft import (

View file

@ -1,21 +0,0 @@
# Module imports
from .base import BaseSerializer
from plane.db.models import DeprecatedDashboard, DeprecatedWidget
# Third party frameworks
from rest_framework import serializers
class DashboardSerializer(BaseSerializer):
class Meta:
model = DeprecatedDashboard
fields = "__all__"
class WidgetSerializer(BaseSerializer):
is_visible = serializers.BooleanField(read_only=True)
widget_filters = serializers.JSONField(read_only=True)
class Meta:
model = DeprecatedWidget
fields = ["id", "key", "is_visible", "widget_filters"]

View file

@ -36,6 +36,7 @@ from plane.db.models import (
State,
IssueVersion,
IssueDescriptionVersion,
ProjectMember,
)
@ -119,6 +120,17 @@ class IssueCreateSerializer(BaseSerializer):
raise serializers.ValidationError("Start date cannot exceed target date")
return data
def get_valid_assignees(self, assignees, project_id):
if not assignees:
return []
return ProjectMember.objects.filter(
project_id=project_id,
role__gte=15,
is_active=True,
member_id__in=assignees
).values_list('member_id', flat=True)
def create(self, validated_data):
assignees = validated_data.pop("assignee_ids", None)
labels = validated_data.pop("label_ids", None)
@ -134,27 +146,33 @@ class IssueCreateSerializer(BaseSerializer):
created_by_id = issue.created_by_id
updated_by_id = issue.updated_by_id
if assignees is not None and len(assignees):
valid_assignee_ids = self.get_valid_assignees(assignees, project_id)
if valid_assignee_ids is not None and len(valid_assignee_ids):
try:
IssueAssignee.objects.bulk_create(
[
IssueAssignee(
assignee=user,
assignee_id=user_id,
issue=issue,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for user in assignees
for user_id in valid_assignee_ids
],
batch_size=10,
)
except IntegrityError:
pass
else:
# Then assign it to default assignee
if default_assignee_id is not None:
# Then assign it to default assignee, if it is a valid assignee
if default_assignee_id is not None and ProjectMember.objects.filter(
member_id=default_assignee_id,
project_id=project_id,
role__gte=15,
is_active=True
).exists():
try:
IssueAssignee.objects.create(
assignee_id=default_assignee_id,
@ -198,20 +216,21 @@ class IssueCreateSerializer(BaseSerializer):
created_by_id = instance.created_by_id
updated_by_id = instance.updated_by_id
if assignees is not None:
valid_assignee_ids = self.get_valid_assignees(assignees, project_id)
if valid_assignee_ids is not None:
IssueAssignee.objects.filter(issue=instance).delete()
try:
IssueAssignee.objects.bulk_create(
[
IssueAssignee(
assignee=user,
assignee_id=user_id,
issue=instance,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for user in assignees
for user_id in valid_assignee_ids
],
batch_size=10,
ignore_conflicts=True,

View file

@ -2,7 +2,6 @@ from .analytic import urlpatterns as analytic_urls
from .api import urlpatterns as api_urls
from .asset import urlpatterns as asset_urls
from .cycle import urlpatterns as cycle_urls
from .dashboard import urlpatterns as dashboard_urls
from .estimate import urlpatterns as estimate_urls
from .external import urlpatterns as external_urls
from .intake import urlpatterns as intake_urls
@ -23,7 +22,6 @@ urlpatterns = [
*analytic_urls,
*asset_urls,
*cycle_urls,
*dashboard_urls,
*estimate_urls,
*external_urls,
*intake_urls,

View file

@ -1,23 +0,0 @@
from django.urls import path
from plane.app.views import DashboardEndpoint, WidgetsEndpoint
urlpatterns = [
path(
"workspaces/<str:slug>/dashboard/",
DashboardEndpoint.as_view(),
name="dashboard",
),
path(
"workspaces/<str:slug>/dashboard/<uuid:dashboard_id>/",
DashboardEndpoint.as_view(),
name="dashboard",
),
path(
"dashboard/<uuid:dashboard_id>/widgets/<uuid:widget_id>/",
WidgetsEndpoint.as_view(),
name="widgets",
),
]

View file

@ -210,8 +210,6 @@ from .webhook.base import (
WebhookSecretRegenerateEndpoint,
)
from .dashboard.base import DashboardEndpoint, WidgetsEndpoint
from .error_404 import custom_404_view
from .notification.base import MarkAllReadNotificationViewSet

View file

@ -1,812 +0,0 @@
# Django imports
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import (
Case,
CharField,
Count,
Exists,
F,
Func,
IntegerField,
JSONField,
OuterRef,
Prefetch,
Q,
Subquery,
UUIDField,
Value,
When,
)
from django.db.models.functions import Coalesce
from django.utils import timezone
from rest_framework import status
# Third Party imports
from rest_framework.response import Response
from plane.app.serializers import (
DashboardSerializer,
IssueActivitySerializer,
IssueSerializer,
WidgetSerializer,
)
from plane.db.models import (
DeprecatedDashboard,
DeprecatedDashboardWidget,
Issue,
IssueActivity,
FileAsset,
IssueLink,
IssueRelation,
Project,
DeprecatedWidget,
WorkspaceMember,
CycleIssue,
)
from plane.utils.issue_filters import issue_filters
# Module imports
from .. import BaseAPIView
def dashboard_overview_stats(self, request, slug):
assigned_issues = (
Issue.issue_objects.filter(
(Q(assignees__in=[request.user]) & Q(issue_assignee__deleted_at__isnull=True)),
project__project_projectmember__is_active=True,
project__project_projectmember__member=request.user,
workspace__slug=slug,
)
.filter(
Q(
project__project_projectmember__role=5,
project__guest_view_all_features=True,
)
| Q(
project__project_projectmember__role=5,
project__guest_view_all_features=False,
created_by=self.request.user,
)
|
# For other roles (role < 5), show all issues
Q(project__project_projectmember__role__gt=5),
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.count()
)
pending_issues_count = (
Issue.issue_objects.filter(
~Q(state__group__in=["completed", "cancelled"]),
target_date__lt=timezone.now().date(),
project__project_projectmember__is_active=True,
project__project_projectmember__member=request.user,
workspace__slug=slug,
assignees__in=[request.user],
)
.filter(
Q(
project__project_projectmember__role=5,
project__guest_view_all_features=True,
)
| Q(
project__project_projectmember__role=5,
project__guest_view_all_features=False,
created_by=self.request.user,
)
|
# For other roles (role < 5), show all issues
Q(project__project_projectmember__role__gt=5),
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.count()
)
created_issues_count = (
Issue.issue_objects.filter(
workspace__slug=slug,
project__project_projectmember__is_active=True,
project__project_projectmember__member=request.user,
created_by_id=request.user.id,
)
.filter(
Q(
project__project_projectmember__role=5,
project__guest_view_all_features=True,
)
| Q(
project__project_projectmember__role=5,
project__guest_view_all_features=False,
created_by=self.request.user,
)
|
# For other roles (role < 5), show all issues
Q(project__project_projectmember__role__gt=5),
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.count()
)
completed_issues_count = (
Issue.issue_objects.filter(
(
Q(assignees__in=[request.user])
& Q(issue_assignee__deleted_at__isnull=True)
),
workspace__slug=slug,
project__project_projectmember__is_active=True,
project__project_projectmember__member=request.user,
state__group="completed",
)
.filter(
Q(
project__project_projectmember__role=5,
project__guest_view_all_features=True,
)
| Q(
project__project_projectmember__role=5,
project__guest_view_all_features=False,
created_by=self.request.user,
)
|
# For other roles (role < 5), show all issues
Q(project__project_projectmember__role__gt=5),
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.count()
)
return Response(
{
"assigned_issues_count": assigned_issues,
"pending_issues_count": pending_issues_count,
"completed_issues_count": completed_issues_count,
"created_issues_count": created_issues_count,
},
status=status.HTTP_200_OK,
)
def dashboard_assigned_issues(self, request, slug):
filters = issue_filters(request.query_params, "GET")
issue_type = request.GET.get("issue_type", None)
# get all the assigned issues
assigned_issues = (
Issue.issue_objects.filter(
(
Q(assignees__in=[request.user])
& Q(issue_assignee__deleted_at__isnull=True)
),
workspace__slug=slug,
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
)
.filter(**filters)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.prefetch_related(
Prefetch(
"issue_relation",
queryset=IssueRelation.objects.select_related(
"related_issue"
).select_related("issue"),
)
)
.annotate(
cycle_id=Subquery(
CycleIssue.objects.filter(
issue=OuterRef("id"), deleted_at__isnull=True
).values("cycle_id")[:1]
)
)
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=FileAsset.objects.filter(
issue_id=OuterRef("id"),
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
)
.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")
)
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=Q(
~Q(labels__id__isnull=True)
& Q(label_issue__deleted_at__isnull=True)
),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=Q(
~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True)
& Q(issue_assignee__deleted_at__isnull=True)
),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=Q(
~Q(issue_module__module_id__isnull=True)
& Q(issue_module__module__archived_at__isnull=True)
& Q(issue_module__deleted_at__isnull=True)
),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
)
if WorkspaceMember.objects.filter(
workspace__slug=slug, member=request.user, role=5, is_active=True
).exists():
assigned_issues = assigned_issues.filter(created_by=request.user)
# Priority Ordering
priority_order = ["urgent", "high", "medium", "low", "none"]
assigned_issues = assigned_issues.annotate(
priority_order=Case(
*[When(priority=p, then=Value(i)) for i, p in enumerate(priority_order)],
output_field=CharField(),
)
).order_by("priority_order")
if issue_type == "pending":
pending_issues_count = assigned_issues.filter(
state__group__in=["backlog", "started", "unstarted"]
).count()
pending_issues = assigned_issues.filter(
state__group__in=["backlog", "started", "unstarted"]
)[:5]
return Response(
{
"issues": IssueSerializer(
pending_issues, many=True, expand=self.expand
).data,
"count": pending_issues_count,
},
status=status.HTTP_200_OK,
)
if issue_type == "completed":
completed_issues_count = assigned_issues.filter(
state__group__in=["completed"]
).count()
completed_issues = assigned_issues.filter(state__group__in=["completed"])[:5]
return Response(
{
"issues": IssueSerializer(
completed_issues, many=True, expand=self.expand
).data,
"count": completed_issues_count,
},
status=status.HTTP_200_OK,
)
if issue_type == "overdue":
overdue_issues_count = assigned_issues.filter(
state__group__in=["backlog", "unstarted", "started"],
target_date__lt=timezone.now(),
).count()
overdue_issues = assigned_issues.filter(
state__group__in=["backlog", "unstarted", "started"],
target_date__lt=timezone.now(),
)[:5]
return Response(
{
"issues": IssueSerializer(
overdue_issues, many=True, expand=self.expand
).data,
"count": overdue_issues_count,
},
status=status.HTTP_200_OK,
)
if issue_type == "upcoming":
upcoming_issues_count = assigned_issues.filter(
state__group__in=["backlog", "unstarted", "started"],
target_date__gte=timezone.now(),
).count()
upcoming_issues = assigned_issues.filter(
state__group__in=["backlog", "unstarted", "started"],
target_date__gte=timezone.now(),
)[:5]
return Response(
{
"issues": IssueSerializer(
upcoming_issues, many=True, expand=self.expand
).data,
"count": upcoming_issues_count,
},
status=status.HTTP_200_OK,
)
return Response(
{"error": "Please specify a valid issue type"},
status=status.HTTP_400_BAD_REQUEST,
)
def dashboard_created_issues(self, request, slug):
filters = issue_filters(request.query_params, "GET")
issue_type = request.GET.get("issue_type", None)
# get all the assigned issues
created_issues = (
Issue.issue_objects.filter(
workspace__slug=slug,
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
created_by=request.user,
)
.filter(**filters)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(
cycle_id=Subquery(
CycleIssue.objects.filter(
issue=OuterRef("id"), deleted_at__isnull=True
).values("cycle_id")[:1]
)
)
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=FileAsset.objects.filter(
issue_id=OuterRef("id"),
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
)
.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")
)
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=Q(
~Q(labels__id__isnull=True)
& Q(label_issue__deleted_at__isnull=True)
),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=Q(
~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True)
& Q(issue_assignee__deleted_at__isnull=True)
),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=Q(
~Q(issue_module__module_id__isnull=True)
& Q(issue_module__module__archived_at__isnull=True)
& Q(issue_module__deleted_at__isnull=True)
),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
.order_by("created_at")
)
# Priority Ordering
priority_order = ["urgent", "high", "medium", "low", "none"]
created_issues = created_issues.annotate(
priority_order=Case(
*[When(priority=p, then=Value(i)) for i, p in enumerate(priority_order)],
output_field=CharField(),
)
).order_by("priority_order")
if issue_type == "pending":
pending_issues_count = created_issues.filter(
state__group__in=["backlog", "started", "unstarted"]
).count()
pending_issues = created_issues.filter(
state__group__in=["backlog", "started", "unstarted"]
)[:5]
return Response(
{
"issues": IssueSerializer(
pending_issues, many=True, expand=self.expand
).data,
"count": pending_issues_count,
},
status=status.HTTP_200_OK,
)
if issue_type == "completed":
completed_issues_count = created_issues.filter(
state__group__in=["completed"]
).count()
completed_issues = created_issues.filter(state__group__in=["completed"])[:5]
return Response(
{
"issues": IssueSerializer(completed_issues, many=True).data,
"count": completed_issues_count,
},
status=status.HTTP_200_OK,
)
if issue_type == "overdue":
overdue_issues_count = created_issues.filter(
state__group__in=["backlog", "unstarted", "started"],
target_date__lt=timezone.now(),
).count()
overdue_issues = created_issues.filter(
state__group__in=["backlog", "unstarted", "started"],
target_date__lt=timezone.now(),
)[:5]
return Response(
{
"issues": IssueSerializer(overdue_issues, many=True).data,
"count": overdue_issues_count,
},
status=status.HTTP_200_OK,
)
if issue_type == "upcoming":
upcoming_issues_count = created_issues.filter(
state__group__in=["backlog", "unstarted", "started"],
target_date__gte=timezone.now(),
).count()
upcoming_issues = created_issues.filter(
state__group__in=["backlog", "unstarted", "started"],
target_date__gte=timezone.now(),
)[:5]
return Response(
{
"issues": IssueSerializer(upcoming_issues, many=True).data,
"count": upcoming_issues_count,
},
status=status.HTTP_200_OK,
)
return Response(
{"error": "Please specify a valid issue type"},
status=status.HTTP_400_BAD_REQUEST,
)
def dashboard_issues_by_state_groups(self, request, slug):
filters = issue_filters(request.query_params, "GET")
state_order = ["backlog", "unstarted", "started", "completed", "cancelled"]
extra_filters = {}
if WorkspaceMember.objects.filter(
workspace__slug=slug, member=request.user, role=5, is_active=True
).exists():
extra_filters = {"created_by": request.user}
issues_by_state_groups = (
Issue.issue_objects.filter(
workspace__slug=slug,
project__project_projectmember__is_active=True,
project__project_projectmember__member=request.user,
assignees__in=[request.user],
)
.filter(**filters, **extra_filters)
.values("state__group")
.annotate(count=Count("id"))
)
# default state
all_groups = {state: 0 for state in state_order}
# Update counts for existing groups
for entry in issues_by_state_groups:
all_groups[entry["state__group"]] = entry["count"]
# Prepare output including all groups with their counts
output_data = [
{"state": group, "count": count} for group, count in all_groups.items()
]
return Response(output_data, status=status.HTTP_200_OK)
def dashboard_issues_by_priority(self, request, slug):
filters = issue_filters(request.query_params, "GET")
priority_order = ["urgent", "high", "medium", "low", "none"]
extra_filters = {}
if WorkspaceMember.objects.filter(
workspace__slug=slug, member=request.user, role=5, is_active=True
).exists():
extra_filters = {"created_by": request.user}
issues_by_priority = (
Issue.issue_objects.filter(
workspace__slug=slug,
project__project_projectmember__is_active=True,
project__project_projectmember__member=request.user,
assignees__in=[request.user],
)
.filter(**filters, **extra_filters)
.values("priority")
.annotate(count=Count("id"))
)
# default priority
all_groups = {priority: 0 for priority in priority_order}
# Update counts for existing groups
for entry in issues_by_priority:
all_groups[entry["priority"]] = entry["count"]
# Prepare output including all groups with their counts
output_data = [
{"priority": group, "count": count} for group, count in all_groups.items()
]
return Response(output_data, status=status.HTTP_200_OK)
def dashboard_recent_activity(self, request, slug):
queryset = IssueActivity.objects.filter(
~Q(field__in=["comment", "vote", "reaction", "draft"]),
workspace__slug=slug,
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
project__archived_at__isnull=True,
actor=request.user,
).select_related("actor", "workspace", "issue", "project")[:8]
return Response(
IssueActivitySerializer(queryset, many=True).data, status=status.HTTP_200_OK
)
def dashboard_recent_projects(self, request, slug):
project_ids = (
IssueActivity.objects.filter(
workspace__slug=slug,
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
project__archived_at__isnull=True,
actor=request.user,
)
.values_list("project_id", flat=True)
.distinct()
)
# Extract project IDs from the recent projects
unique_project_ids = set(project_id for project_id in project_ids)
# Fetch additional projects only if needed
if len(unique_project_ids) < 4:
additional_projects = Project.objects.filter(
project_projectmember__member=request.user,
project_projectmember__is_active=True,
archived_at__isnull=True,
workspace__slug=slug,
).exclude(id__in=unique_project_ids)
# Append additional project IDs to the existing list
unique_project_ids.update(additional_projects.values_list("id", flat=True))
return Response(list(unique_project_ids)[:4], status=status.HTTP_200_OK)
def dashboard_recent_collaborators(self, request, slug):
project_members_with_activities = (
WorkspaceMember.objects.filter(workspace__slug=slug, is_active=True)
.annotate(
active_issue_count=Count(
Case(
When(
member__issue_assignee__issue__state__group__in=[
"unstarted",
"started",
],
member__issue_assignee__issue__workspace__slug=slug,
member__issue_assignee__issue__project__project_projectmember__member=request.user,
member__issue_assignee__issue__project__project_projectmember__is_active=True,
then=F("member__issue_assignee__issue__id"),
),
distinct=True,
output_field=IntegerField(),
),
distinct=True,
),
user_id=F("member_id"),
)
.values("user_id", "active_issue_count")
.order_by("-active_issue_count")
.distinct()
)
return Response((project_members_with_activities), status=status.HTTP_200_OK)
class DashboardEndpoint(BaseAPIView):
def create(self, request, slug):
serializer = DashboardSerializer(data=request.data)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def patch(self, request, slug, pk):
serializer = DashboardSerializer(data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def delete(self, request, slug, pk):
serializer = DashboardSerializer(data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_204_NO_CONTENT)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def get(self, request, slug, dashboard_id=None):
if not dashboard_id:
dashboard_type = request.GET.get("dashboard_type", None)
if dashboard_type == "home":
dashboard, created = DeprecatedDashboard.objects.get_or_create(
type_identifier=dashboard_type,
owned_by=request.user,
is_default=True,
)
if created:
widgets_to_fetch = [
"overview_stats",
"assigned_issues",
"created_issues",
"issues_by_state_groups",
"issues_by_priority",
"recent_activity",
"recent_projects",
"recent_collaborators",
]
updated_dashboard_widgets = []
for widget_key in widgets_to_fetch:
widget = DeprecatedWidget.objects.filter(
key=widget_key
).values_list("id", flat=True)
if widget:
updated_dashboard_widgets.append(
DeprecatedDashboardWidget(
widget_id=widget, dashboard_id=dashboard.id
)
)
DeprecatedDashboardWidget.objects.bulk_create(
updated_dashboard_widgets, batch_size=100
)
widgets = (
DeprecatedWidget.objects.annotate(
is_visible=Exists(
DeprecatedDashboardWidget.objects.filter(
widget_id=OuterRef("pk"),
dashboard_id=dashboard.id,
is_visible=True,
)
)
)
.annotate(
dashboard_filters=Subquery(
DeprecatedDashboardWidget.objects.filter(
widget_id=OuterRef("pk"),
dashboard_id=dashboard.id,
filters__isnull=False,
)
.exclude(filters={})
.values("filters")[:1]
)
)
.annotate(
widget_filters=Case(
When(
dashboard_filters__isnull=False,
then=F("dashboard_filters"),
),
default=F("filters"),
output_field=JSONField(),
)
)
)
return Response(
{
"dashboard": DashboardSerializer(dashboard).data,
"widgets": WidgetSerializer(widgets, many=True).data,
},
status=status.HTTP_200_OK,
)
return Response(
{"error": "Please specify a valid dashboard type"},
status=status.HTTP_400_BAD_REQUEST,
)
widget_key = request.GET.get("widget_key", "overview_stats")
WIDGETS_MAPPER = {
"overview_stats": dashboard_overview_stats,
"assigned_issues": dashboard_assigned_issues,
"created_issues": dashboard_created_issues,
"issues_by_state_groups": dashboard_issues_by_state_groups,
"issues_by_priority": dashboard_issues_by_priority,
"recent_activity": dashboard_recent_activity,
"recent_projects": dashboard_recent_projects,
"recent_collaborators": dashboard_recent_collaborators,
}
func = WIDGETS_MAPPER.get(widget_key)
if func is not None:
response = func(self, request=request, slug=slug)
if isinstance(response, Response):
return response
return Response(
{"error": "Please specify a valid widget key"},
status=status.HTTP_400_BAD_REQUEST,
)
class WidgetsEndpoint(BaseAPIView):
def patch(self, request, dashboard_id, widget_id):
dashboard_widget = DeprecatedDashboardWidget.objects.filter(
widget_id=widget_id, dashboard_id=dashboard_id
).first()
dashboard_widget.is_visible = request.data.get(
"is_visible", dashboard_widget.is_visible
)
dashboard_widget.sort_order = request.data.get(
"sort_order", dashboard_widget.sort_order
)
dashboard_widget.filters = request.data.get("filters", dashboard_widget.filters)
dashboard_widget.save()
return Response({"message": "successfully updated"}, status=status.HTTP_200_OK)

View file

@ -34,6 +34,7 @@ from plane.bgtasks.webhook_task import webhook_activity
from plane.utils.issue_relation_mapper import get_inverse_relation
from plane.utils.valid_uuid import is_valid_uuid
# Track Changes in name
def track_name(
requested_data,
@ -852,7 +853,7 @@ def delete_cycle_issue_activity(
issues = requested_data.get("issues")
for issue in issues:
current_issue = Issue.objects.filter(pk=issue).first()
if issue:
if current_issue:
current_issue.updated_at = timezone.now()
current_issue.save(update_fields=["updated_at"])
issue_activities.append(
@ -1569,13 +1570,12 @@ def issue_activity(
issue_activities = []
# check if project_id is valid
if not is_valid_uuid(project_id):
if not is_valid_uuid(str(project_id)):
return
project = Project.objects.get(pk=project_id)
workspace_id = project.workspace_id
if issue_id is not None:
if origin:
ri = redis_instance()

View file

@ -0,0 +1,41 @@
# Generated by Django 4.2.18 on 2025-02-25 15:48
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("db", "0091_issuecomment_edited_at_and_more"),
]
operations = [
migrations.AlterUniqueTogether(
name="deprecateddashboardwidget",
unique_together=None,
),
migrations.RemoveField(
model_name="deprecateddashboardwidget",
name="created_by",
),
migrations.RemoveField(
model_name="deprecateddashboardwidget",
name="dashboard",
),
migrations.RemoveField(
model_name="deprecateddashboardwidget",
name="updated_by",
),
migrations.RemoveField(
model_name="deprecateddashboardwidget",
name="widget",
),
migrations.DeleteModel(
name="DeprecatedDashboard",
),
migrations.DeleteModel(
name="DeprecatedDashboardWidget",
),
migrations.DeleteModel(
name="DeprecatedWidget",
),
]

View file

@ -3,7 +3,6 @@ from .api import APIActivityLog, APIToken
from .asset import FileAsset
from .base import BaseModel
from .cycle import Cycle, CycleIssue, CycleUserProperties
from .dashboard import DeprecatedDashboard, DeprecatedDashboardWidget, DeprecatedWidget
from .deploy_board import DeployBoard
from .draft import (
DraftIssue,

View file

@ -1,92 +0,0 @@
import uuid
# Django imports
from django.db import models
# Module imports
from ..mixins import TimeAuditModel
from .base import BaseModel
class DeprecatedDashboard(BaseModel):
DASHBOARD_CHOICES = (
("workspace", "Workspace"),
("project", "Project"),
("home", "Home"),
("team", "Team"),
("user", "User"),
)
name = models.CharField(max_length=255)
description_html = models.TextField(blank=True, default="<p></p>")
identifier = models.UUIDField(null=True)
owned_by = models.ForeignKey(
"db.User", on_delete=models.CASCADE, related_name="dashboards"
)
is_default = models.BooleanField(default=False)
type_identifier = models.CharField(
max_length=30,
choices=DASHBOARD_CHOICES,
verbose_name="Dashboard Type",
default="home",
)
logo_props = models.JSONField(default=dict)
def __str__(self):
"""Return name of the dashboard"""
return f"{self.name}"
class Meta:
verbose_name = "DeprecatedDashboard"
verbose_name_plural = "DeprecatedDashboards"
db_table = "deprecated_dashboards"
ordering = ("-created_at",)
class DeprecatedWidget(TimeAuditModel):
id = models.UUIDField(
default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True
)
key = models.CharField(max_length=255)
filters = models.JSONField(default=dict)
logo_props = models.JSONField(default=dict)
def __str__(self):
"""Return name of the widget"""
return f"{self.key}"
class Meta:
verbose_name = "DeprecatedWidget"
verbose_name_plural = "DeprecatedWidgets"
db_table = "deprecated_widgets"
ordering = ("-created_at",)
class DeprecatedDashboardWidget(BaseModel):
widget = models.ForeignKey(
DeprecatedWidget, on_delete=models.CASCADE, related_name="dashboard_widgets"
)
dashboard = models.ForeignKey(
DeprecatedDashboard, on_delete=models.CASCADE, related_name="dashboard_widgets"
)
is_visible = models.BooleanField(default=True)
sort_order = models.FloatField(default=65535)
filters = models.JSONField(default=dict)
properties = models.JSONField(default=dict)
def __str__(self):
"""Return name of the dashboard"""
return f"{self.dashboard.name} {self.widget.key}"
class Meta:
unique_together = ("widget", "dashboard", "deleted_at")
constraints = [
models.UniqueConstraint(
fields=["widget", "dashboard"],
condition=models.Q(deleted_at__isnull=True),
name="dashboard_widget_unique_widget_dashboard_when_deleted_at_null",
)
]
verbose_name = "Deprecated Dashboard Widget"
verbose_name_plural = "Deprecated Dashboard Widgets"
db_table = "deprecated_dashboard_widgets"
ordering = ("-created_at",)

View file

@ -1,7 +1,8 @@
{
"name": "live",
"version": "0.25.0",
"description": "",
"version": "0.25.1",
"license": "AGPL-3.0",
"description": "A realtime collaborative server powers Plane's rich text editor",
"main": "./src/server.ts",
"private": true,
"type": "module",
@ -14,7 +15,6 @@
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@hocuspocus/extension-database": "^2.15.0",
"@hocuspocus/extension-logger": "^2.15.0",

View file

@ -1,6 +1,8 @@
{
"name": "plane",
"description": "Open-source project management that unlocks customer value",
"repository": "https://github.com/makeplane/plane.git",
"version": "0.25.0",
"version": "0.25.1",
"license": "AGPL-3.0",
"private": true,
"workspaces": [
@ -28,6 +30,5 @@
"nanoid": "3.3.8",
"esbuild": "0.25.0"
},
"packageManager": "yarn@1.22.22",
"name": "plane"
"packageManager": "yarn@1.22.22"
}

View file

@ -1,6 +1,7 @@
{
"name": "@plane/constants",
"version": "0.25.0",
"version": "0.25.1",
"private": true,
"main": "./src/index.ts"
"main": "./src/index.ts",
"license": "AGPL-3.0"
}

View file

@ -1,7 +1,8 @@
{
"name": "@plane/editor",
"version": "0.25.0",
"version": "0.25.1",
"description": "Core Editor that powers Plane",
"license": "AGPL-3.0",
"private": true,
"main": "./dist/index.mjs",
"module": "./dist/index.mjs",

View file

@ -1,5 +1,5 @@
import { FC, ReactNode } from "react";
import { Editor } from "@tiptap/react";
import { FC, ReactNode } from "react";
// plane utils
import { cn } from "@plane/utils";
// constants
@ -71,7 +71,7 @@ export const EditorContainer: FC<EditorContainerProps> = (props) => {
onClick={handleContainerClick}
onMouseLeave={handleContainerMouseLeave}
className={cn(
"editor-container cursor-text relative",
`editor-container cursor-text relative line-spacing-${displayConfig.lineSpacing ?? DEFAULT_DISPLAY_CONFIG.lineSpacing}`,
{
"active-editor": editor?.isFocused && editor?.isEditable,
},

View file

@ -4,6 +4,7 @@ import { TDisplayConfig } from "@/types";
export const DEFAULT_DISPLAY_CONFIG: TDisplayConfig = {
fontSize: "large-font",
fontStyle: "sans-serif",
lineSpacing: "regular",
};
export const ACCEPTED_FILE_MIME_TYPES = ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/gif"];

View file

@ -26,12 +26,12 @@ export const CoreEditorExtensionsWithoutProps = [
StarterKit.configure({
bulletList: {
HTMLAttributes: {
class: "list-disc pl-7 space-y-2",
class: "list-disc pl-7 space-y-[--list-spacing-y]",
},
},
orderedList: {
HTMLAttributes: {
class: "list-decimal pl-7 space-y-2",
class: "list-decimal pl-7 space-y-[--list-spacing-y]",
},
},
listItem: {

View file

@ -55,12 +55,12 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
StarterKit.configure({
bulletList: {
HTMLAttributes: {
class: "list-disc pl-7 space-y-2",
class: "list-disc pl-7 space-y-[--list-spacing-y]",
},
},
orderedList: {
HTMLAttributes: {
class: "list-decimal pl-7 space-y-2",
class: "list-decimal pl-7 space-y-[--list-spacing-y]",
},
},
listItem: {

View file

@ -46,12 +46,12 @@ export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
StarterKit.configure({
bulletList: {
HTMLAttributes: {
class: "list-disc pl-7 space-y-2",
class: "list-disc pl-7 space-y-[--list-spacing-y]",
},
},
orderedList: {
HTMLAttributes: {
class: "list-decimal pl-7 space-y-2",
class: "list-decimal pl-7 space-y-[--list-spacing-y]",
},
},
listItem: {

View file

@ -23,7 +23,10 @@ export type TEditorFontStyle = "sans-serif" | "serif" | "monospace";
export type TEditorFontSize = "small-font" | "large-font";
export type TEditorLineSpacing = "regular" | "small";
export type TDisplayConfig = {
fontStyle?: TEditorFontStyle;
fontSize?: TEditorFontSize;
lineSpacing?: TEditorLineSpacing;
};

View file

@ -252,7 +252,7 @@ ul[data-type="taskList"] li[data-checked="true"] {
div[data-type="horizontalRule"] {
line-height: 0;
padding: 0.25rem 0;
padding: var(--divider-padding-top) 0 var(--divider-padding-bottom) 0;
margin-top: 0;
margin-bottom: 0;
@ -335,10 +335,10 @@ p.editor-paragraph-block {
/* tailwind typography */
.prose :where(h1.editor-heading-block):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
&:not(:first-child) {
padding-top: 28px;
padding-top: var(--heading-1-padding-top);
}
padding-bottom: 4px;
padding-bottom: var(--heading-1-padding-bottom);
font-size: var(--font-size-h1);
line-height: var(--line-height-h1);
font-weight: 600;
@ -346,10 +346,10 @@ p.editor-paragraph-block {
.prose :where(h2.editor-heading-block):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
&:not(:first-child) {
padding-top: 28px;
padding-top: var(--heading-2-padding-top);
}
padding-bottom: 4px;
padding-bottom: var(--heading-2-padding-bottom);
font-size: var(--font-size-h2);
line-height: var(--line-height-h2);
font-weight: 600;
@ -357,10 +357,10 @@ p.editor-paragraph-block {
.prose :where(h3.editor-heading-block):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
&:not(:first-child) {
padding-top: 28px;
padding-top: var(--heading-3-padding-top);
}
padding-bottom: 4px;
padding-bottom: var(--heading-3-padding-bottom);
font-size: var(--font-size-h3);
line-height: var(--line-height-h3);
font-weight: 600;
@ -368,10 +368,10 @@ p.editor-paragraph-block {
.prose :where(h4.editor-heading-block):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
&:not(:first-child) {
padding-top: 28px;
padding-top: var(--heading-4-padding-top);
}
padding-bottom: 4px;
padding-bottom: var(--heading-4-padding-bottom);
font-size: var(--font-size-h4);
line-height: var(--line-height-h4);
font-weight: 600;
@ -379,10 +379,10 @@ p.editor-paragraph-block {
.prose :where(h5.editor-heading-block):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
&:not(:first-child) {
padding-top: 20px;
padding-top: var(--heading-5-padding-top);
}
padding-bottom: 4px;
padding-bottom: var(--heading-5-padding-bottom);
font-size: var(--font-size-h5);
line-height: var(--line-height-h5);
font-weight: 600;
@ -390,10 +390,10 @@ p.editor-paragraph-block {
.prose :where(h6.editor-heading-block):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
&:not(:first-child) {
padding-top: 20px;
padding-top: var(--heading-6-padding-top);
}
padding-bottom: 4px;
padding-bottom: var(--heading-6-padding-bottom);
font-size: var(--font-size-h6);
line-height: var(--line-height-h6);
font-weight: 600;
@ -405,16 +405,16 @@ p.editor-paragraph-block {
}
&:not(:first-child) {
padding-top: 4px;
padding-top: var(--paragraph-padding-top);
}
&:not(td p.editor-paragraph-block, th p.editor-paragraph-block) {
&:last-child {
padding-bottom: 4px;
padding-bottom: var(--paragraph-padding-bottom);
}
&:not(:last-child) {
padding-bottom: 8px;
padding-bottom: var(--paragraph-padding-between);
}
}
@ -423,7 +423,7 @@ p.editor-paragraph-block {
}
p.editor-paragraph-block + p.editor-paragraph-block {
padding-top: 8px !important;
padding-top: var(--paragraph-padding-between) !important;
}
.prose :where(ol):not(:where([class~="not-prose"], [class~="not-prose"] *)) li p.editor-paragraph-block,

View file

@ -57,4 +57,48 @@
--font-style: monospace;
}
/* end font styles */
/* spacing */
&.line-spacing-regular {
--heading-1-padding-top: 28px;
--heading-1-padding-bottom: 4px;
--heading-2-padding-top: 28px;
--heading-2-padding-bottom: 4px;
--heading-3-padding-top: 28px;
--heading-3-padding-bottom: 4px;
--heading-4-padding-top: 28px;
--heading-4-padding-bottom: 4px;
--heading-5-padding-top: 20px;
--heading-5-padding-bottom: 4px;
--heading-6-padding-top: 20px;
--heading-6-padding-bottom: 4px;
--paragraph-padding-top: 4px;
--paragraph-padding-bottom: 4px;
--paragraph-padding-between: 8px;
--list-spacing-y: 8px;
--divider-padding-top: 4px;
--divider-padding-bottom: 4px;
}
&.line-spacing-small {
--heading-1-padding-top: 16px;
--heading-1-padding-bottom: 4px;
--heading-2-padding-top: 16px;
--heading-2-padding-bottom: 4px;
--heading-3-padding-top: 16px;
--heading-3-padding-bottom: 4px;
--heading-4-padding-top: 16px;
--heading-4-padding-bottom: 4px;
--heading-5-padding-top: 12px;
--heading-5-padding-bottom: 4px;
--heading-6-padding-top: 12px;
--heading-6-padding-bottom: 4px;
--paragraph-padding-top: 2px;
--paragraph-padding-bottom: 2px;
--paragraph-padding-between: 4px;
--list-spacing-y: 0px;
--divider-padding-top: 0px;
--divider-padding-bottom: 4px;
}
/* end spacing */
}

View file

@ -1,7 +1,8 @@
{
"name": "@plane/eslint-config",
"private": true,
"version": "0.25.0",
"version": "0.25.1",
"license": "AGPL-3.0",
"files": [
"library.js",
"next.js",

View file

@ -1,6 +1,7 @@
{
"name": "@plane/hooks",
"version": "0.25.0",
"version": "0.25.1",
"license": "AGPL-3.0",
"description": "React hooks that are shared across multiple apps internally",
"private": true,
"main": "./dist/index.js",

View file

@ -1,6 +1,7 @@
{
"name": "@plane/i18n",
"version": "0.25.0",
"version": "0.25.1",
"license": "AGPL-3.0",
"description": "I18n shared across multiple apps internally",
"private": true,
"main": "./src/index.ts",

View file

@ -8,6 +8,8 @@ export const SUPPORTED_LANGUAGES: ILanguageOption[] = [
{ label: "Español", value: "es" },
{ label: "日本語", value: "ja" },
{ label: "中文", value: "zh-CN" },
{ label: "Русский", value: "ru" },
{ label: "Italian", value: "it" },
];
export const STORAGE_KEY = "userLanguage";

View file

@ -461,6 +461,8 @@
"states": "States",
"state": "State",
"state_groups": "State groups",
"state_group": "State group",
"priorities": "Priorities",
"priority": "Priority",
"team_project": "Team project",
"project": "Project",
@ -469,12 +471,16 @@
"module": "Module",
"modules": "Modules",
"labels": "Labels",
"label": "Label",
"assignees": "Assignees",
"assignee": "Assignee",
"created_by": "Created by",
"none": "None",
"link": "Link",
"estimates": "Estimates",
"estimate": "Estimate",
"created_at": "Created at",
"completed_at": "Completed at",
"layout": "Layout",
"filters": "Filters",
"display": "Display",
@ -517,7 +523,6 @@
"add_more": "Add more",
"defaults": "Defaults",
"add_label": "Add label",
"estimates": "Estimates",
"customize_time_range": "Customize time range",
"loading": "Loading",
"attachments": "Attachments",
@ -656,8 +661,6 @@
"select": "Select",
"upgrade": "Upgrade",
"add_seats": "Add Seats",
"label": "Label",
"priorities": "Priorities",
"projects": "Projects",
"workspace": "Workspace",
"workspaces": "Workspaces",

View file

@ -633,6 +633,8 @@
"states": "Estados",
"state": "Estado",
"state_groups": "Grupos de estados",
"state_group": "Grupos de estado",
"priorities": "Prioridades",
"priority": "Prioridad",
"team_project": "Proyecto de equipo",
"project": "Proyecto",
@ -641,12 +643,16 @@
"module": "Módulo",
"modules": "Módulos",
"labels": "Etiquetas",
"label": "Etiqueta",
"assignees": "Asignados",
"assignee": "Asignado",
"created_by": "Creado por",
"none": "Ninguno",
"link": "Enlace",
"estimates": "Estimaciones",
"estimate": "Estimación",
"created_at": "Creado en",
"completed_at": "Completado en",
"layout": "Diseño",
"filters": "Filtros",
"display": "Mostrar",
@ -689,7 +695,6 @@
"add_more": "Agregar más",
"defaults": "Valores predeterminados",
"add_label": "Agregar etiqueta",
"estimates": "Estimaciones",
"customize_time_range": "Personalizar rango de tiempo",
"loading": "Cargando",
"attachments": "Archivos adjuntos",
@ -827,8 +832,6 @@
"select": "Seleccionar",
"upgrade": "Mejorar",
"add_seats": "Agregar asientos",
"label": "Etiqueta",
"priorities": "Prioridades",
"projects": "Proyectos",
"workspace": "Espacio de trabajo",
"workspaces": "Espacios de trabajo",

View file

@ -631,6 +631,8 @@
"states": "États",
"state": "État",
"state_groups": "Groupes d'états",
"state_group": "Groupe d'état",
"priorities": "Priorités",
"priority": "Priorité",
"team_project": "Projet d'équipe",
"project": "Projet",
@ -639,12 +641,16 @@
"module": "Module",
"modules": "Modules",
"labels": "Étiquettes",
"label": "Étiquette",
"assignees": "Assignés",
"assignee": "Assigné",
"created_by": "Créé par",
"none": "Aucun",
"link": "Lien",
"estimates": "Estimations",
"estimate": "Estimation",
"created_at": "Créé le",
"completed_at": "Terminé le",
"layout": "Disposition",
"filters": "Filtres",
"display": "Affichage",
@ -687,7 +693,6 @@
"add_more": "Ajouter plus",
"defaults": "Par défaut",
"add_label": "Ajouter une étiquette",
"estimates": "Estimations",
"customize_time_range": "Personnaliser la plage de temps",
"loading": "Chargement",
"attachments": "Pièces jointes",
@ -825,8 +830,6 @@
"select": "Sélectionner",
"upgrade": "Mettre à niveau",
"add_seats": "Ajouter des sièges",
"label": "Étiquette",
"priorities": "Priorités",
"projects": "Projets",
"workspace": "Espace de travail",
"workspaces": "Espaces de travail",

File diff suppressed because it is too large Load diff

View file

@ -631,6 +631,8 @@
"states": "ステータス",
"state": "ステータス",
"state_groups": "ステータスグループ",
"state_group": "ステート グループ",
"priorities": "優先度",
"priority": "優先度",
"team_project": "チームプロジェクト",
"project": "プロジェクト",
@ -639,12 +641,16 @@
"module": "モジュール",
"modules": "モジュール",
"labels": "ラベル",
"label": "ラベル",
"assignees": "担当者",
"assignee": "担当者",
"created_by": "作成者",
"none": "なし",
"link": "リンク",
"estimates": "見積もり",
"estimate": "見積もり",
"created_at": "クリエイテッド アット",
"completed_at": "コンプリーテッド アット",
"layout": "レイアウト",
"filters": "フィルター",
"display": "表示",
@ -687,7 +693,6 @@
"add_more": "さらに追加",
"defaults": "デフォルト",
"add_label": "ラベルを追加",
"estimates": "見積もり",
"customize_time_range": "期間をカスタマイズ",
"loading": "読み込み中",
"attachments": "添付ファイル",
@ -825,8 +830,6 @@
"select": "選択",
"upgrade": "アップグレード",
"add_seats": "シートを追加",
"label": "ラベル",
"priorities": "優先度",
"projects": "プロジェクト",
"workspace": "ワークスペース",
"workspaces": "ワークスペース",

File diff suppressed because it is too large Load diff

View file

@ -631,6 +631,8 @@
"states": "状态",
"state": "状态",
"state_groups": "状态组",
"state_group": "状态组",
"priorities": "优先级",
"priority": "优先级",
"team_project": "团队项目",
"project": "项目",
@ -639,12 +641,16 @@
"module": "模块",
"modules": "模块",
"labels": "标签",
"label": "标签",
"assignees": "负责人",
"assignee": "负责人",
"created_by": "创建者",
"none": "无",
"link": "链接",
"estimates": "估算",
"estimate": "估算",
"created_at": "创建于",
"completed_at": "完成于",
"layout": "布局",
"filters": "筛选",
"display": "显示",
@ -687,7 +693,6 @@
"add_more": "添加更多",
"defaults": "默认值",
"add_label": "添加标签",
"estimates": "估算",
"customize_time_range": "自定义时间范围",
"loading": "加载中",
"attachments": "附件",
@ -825,8 +830,6 @@
"select": "选择",
"upgrade": "升级",
"add_seats": "添加席位",
"label": "标签",
"priorities": "优先级",
"projects": "项目",
"workspace": "工作区",
"workspaces": "工作区",

View file

@ -147,6 +147,10 @@ export class TranslationStore {
return import("../locales/ja/translations.json");
case "zh-CN":
return import("../locales/zh-CN/translations.json");
case "ru":
return import("../locales/ru/translations.json");
case "it":
return import("../locales/it/translations.json");
default:
throw new Error(`Unsupported language: ${language}`);
}

View file

@ -1,4 +1,4 @@
export type TLanguage = "en" | "fr" | "es" | "ja" | "zh-CN";
export type TLanguage = "en" | "fr" | "es" | "ja" | "zh-CN" | "ru" | "it";
export interface ILanguageOption {
label: string;

View file

@ -1,6 +1,7 @@
{
"name": "@plane/logger",
"version": "0.25.0",
"version": "0.25.1",
"license": "AGPL-3.0",
"description": "Logger shared across multiple apps internally",
"private": true,
"main": "./src/index.ts",

View file

@ -1,7 +1,8 @@
{
"name": "@plane/propel",
"version": "0.25.0",
"version": "0.25.1",
"private": true,
"license": "AGPL-3.0",
"scripts": {
"lint": "eslint src --ext .ts,.tsx",
"lint:errors": "eslint src --ext .ts,.tsx --quiet"

View file

@ -1,6 +1,7 @@
{
"name": "@plane/services",
"version": "0.25.0",
"version": "0.25.1",
"license": "AGPL-3.0",
"private": true,
"main": "./src/index.ts",
"scripts": {

View file

@ -1,6 +1,7 @@
{
"name": "@plane/shared-state",
"version": "0.25.0",
"version": "0.25.1",
"license": "AGPL-3.0",
"description": "Shared state shared across multiple apps internally",
"private": true,
"main": "./src/index.ts",

View file

@ -1,6 +1,7 @@
{
"name": "@plane/tailwind-config",
"version": "0.25.0",
"version": "0.25.1",
"license": "AGPL-3.0",
"description": "common tailwind configuration across monorepo",
"main": "tailwind.config.js",
"private": true,

View file

@ -1,6 +1,7 @@
{
"name": "@plane/types",
"version": "0.25.0",
"version": "0.25.1",
"license": "AGPL-3.0",
"private": true,
"types": "./src/index.d.ts",
"main": "./src/index.d.ts"

View file

@ -1,6 +1,7 @@
{
"name": "@plane/typescript-config",
"version": "0.25.0",
"version": "0.25.1",
"license": "AGPL-3.0",
"private": true,
"files": [
"base.json",

View file

@ -2,12 +2,12 @@
"name": "@plane/ui",
"description": "UI components shared across multiple apps internally",
"private": true,
"version": "0.25.0",
"version": "0.25.1",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"sideEffects": false,
"license": "MIT",
"license": "AGPL-3.0",
"files": [
"dist/**"
],

View file

@ -1,7 +1,8 @@
{
"name": "@plane/utils",
"version": "0.25.0",
"version": "0.25.1",
"description": "Helper functions shared across multiple apps internally",
"license": "AGPL-3.0",
"private": true,
"main": "./dist/index.js",
"module": "./dist/index.mjs",

View file

@ -1,7 +1,8 @@
{
"name": "space",
"version": "0.25.0",
"version": "0.25.1",
"private": true,
"license": "AGPL-3.0",
"scripts": {
"dev": "turbo run develop",
"develop": "next dev -p 3002",

View file

@ -108,7 +108,7 @@ export const ExtendedProjectSidebar = observer(() => {
<div
ref={extendedProjectSidebarRef}
className={cn(
"fixed top-0 h-full z-[19] flex flex-col gap-2 w-[300px] transform transition-all duration-300 ease-in-out bg-custom-sidebar-background-100 border-r border-custom-sidebar-border-200 shadow-md",
"absolute top-0 h-full z-[19] flex flex-col gap-2 w-[300px] transform transition-all duration-300 ease-in-out bg-custom-sidebar-background-100 border-r border-custom-sidebar-border-200 shadow-md",
{
"translate-x-0 opacity-100 pointer-events-auto": extendedProjectSidebarCollapsed,
"-translate-x-full opacity-0 pointer-events-none": !extendedProjectSidebarCollapsed,

View file

@ -104,7 +104,7 @@ export const ExtendedAppSidebar = observer(() => {
<div
ref={extendedSidebarRef}
className={cn(
"fixed top-0 h-full z-[19] flex flex-col gap-0.5 w-[300px] transform transition-all duration-300 ease-in-out bg-custom-sidebar-background-100 border-r border-custom-sidebar-border-200 p-4 shadow-md pb-6",
"absolute top-0 h-full z-[19] flex flex-col gap-0.5 w-[300px] transform transition-all duration-300 ease-in-out bg-custom-sidebar-background-100 border-r border-custom-sidebar-border-200 p-4 shadow-md pb-6",
{
"translate-x-0 opacity-100 pointer-events-auto": extendedSidebarCollapsed,
"-translate-x-full opacity-0 pointer-events-none": !extendedSidebarCollapsed,

View file

@ -2,20 +2,21 @@ import { observer } from "mobx-react";
import { Check } from "lucide-react";
import { Combobox } from "@headlessui/react";
type Props = {
export type TStateOptionProps = {
projectId: string | null | undefined;
option: {
value: string | undefined;
query: string;
content: JSX.Element;
};
filterAvailableStateIds: boolean;
selectedValue: string | null | undefined;
className?: string;
filterAvailableStateIds?: boolean;
isForWorkItemCreation?: boolean;
alwaysAllowStateChange?: boolean;
};
export const StateOption = observer((props: Props) => {
export const StateOption = observer((props: TStateOptionProps) => {
const { option, className = "" } = props;
return (

View file

@ -101,10 +101,10 @@ export const CycleSidebarHeader: FC<Props> = observer((props) => {
});
};
const submitChanges = (data: Partial<ICycle>, changedProperty: string) => {
const submitChanges = async (data: Partial<ICycle>, changedProperty: string) => {
if (!workspaceSlug || !projectId || !cycleDetails.id) return;
updateCycleDetails(workspaceSlug.toString(), projectId.toString(), cycleDetails.id.toString(), data)
await updateCycleDetails(workspaceSlug.toString(), projectId.toString(), cycleDetails.id.toString(), data)
.then((res) => {
captureCycleEvent({
eventName: CYCLE_UPDATED,
@ -146,22 +146,21 @@ export const CycleSidebarHeader: FC<Props> = observer((props) => {
};
const handleDateChange = async (startDate: Date | undefined, endDate: Date | undefined) => {
if (!startDate || !endDate) return;
let isDateValid = false;
const payload = {
start_date: renderFormattedPayloadDate(startDate),
end_date: renderFormattedPayloadDate(endDate),
start_date: renderFormattedPayloadDate(startDate) || null,
end_date: renderFormattedPayloadDate(endDate) || null,
};
if (cycleDetails?.start_date && cycleDetails.end_date)
if (payload?.start_date && payload.end_date) {
isDateValid = await dateChecker({
...payload,
cycle_id: cycleDetails.id,
});
else isDateValid = await dateChecker(payload);
} else {
isDateValid = true;
}
if (isDateValid) {
submitChanges(payload, "date_range");
setToast({
@ -175,8 +174,8 @@ export const CycleSidebarHeader: FC<Props> = observer((props) => {
title: t("project_cycles.action.update.failed.title"),
message: t("project_cycles.action.update.error.already_exists"),
});
reset({ ...cycleDetails });
}
return isDateValid;
};
const isEditingAllowed = allowPermissions(
@ -296,16 +295,18 @@ export const CycleSidebarHeader: FC<Props> = observer((props) => {
render={({ field: { value: endDateValue, onChange: onChangeEndDate } }) => (
<DateRangeDropdown
className="h-7"
buttonVariant="transparent-with-text"
buttonVariant="border-with-text"
minDate={new Date()}
value={{
from: getDate(startDateValue),
to: getDate(endDateValue),
}}
onSelect={(val) => {
onChangeStartDate(val?.from ? renderFormattedPayloadDate(val.from) : null);
onChangeEndDate(val?.to ? renderFormattedPayloadDate(val.to) : null);
handleDateChange(val?.from, val?.to);
onSelect={async (val) => {
const isDateValid = await handleDateChange(val?.from, val?.to);
if (isDateValid) {
onChangeStartDate(val?.from ? renderFormattedPayloadDate(val.from) : null);
onChangeEndDate(val?.to ? renderFormattedPayloadDate(val.to) : null);
}
}}
placeholder={{
from: "Start date",

View file

@ -3,31 +3,21 @@
import React, { FC, MouseEvent, useEffect, useMemo, useState } from "react";
import { observer } from "mobx-react";
import { useParams, usePathname, useSearchParams } from "next/navigation";
import { Controller, useForm } from "react-hook-form";
import { useForm } from "react-hook-form";
import { Eye, Users } from "lucide-react";
// types
import { CYCLE_FAVORITED, CYCLE_UNFAVORITED, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { ICycle, TCycleGroups } from "@plane/types";
// ui
import {
Avatar,
AvatarGroup,
FavoriteStar,
LayersIcon,
TOAST_TYPE,
Tooltip,
TransferIcon,
setPromiseToast,
setToast,
} from "@plane/ui";
import { Avatar, AvatarGroup, FavoriteStar, LayersIcon, Tooltip, TransferIcon, setPromiseToast } from "@plane/ui";
// components
import { CycleQuickActions, TransferIssuesModal } from "@/components/cycles";
import { DateRangeDropdown } from "@/components/dropdowns";
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
// constants
// helpers
import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper";
import { getDate } from "@/helpers/date-time.helper";
import { getFileURL } from "@/helpers/file.helper";
// hooks
import { generateQueryParams } from "@/helpers/router.helper";
@ -36,11 +26,6 @@ import { useAppRouter } from "@/hooks/use-app-router";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web components
import { CycleAdditionalActions } from "@/plane-web/components/cycles";
// plane web constants
// services
import { CycleService } from "@/services/cycle.service";
const cycleService = new CycleService();
type Props = {
workspaceSlug: string;
@ -77,7 +62,7 @@ export const CycleListItemAction: FC<Props> = observer((props) => {
const { getUserDetails } = useMember();
// form
const { control, reset } = useForm({
const { control, reset, getValues } = useForm({
defaultValues,
});
@ -98,7 +83,6 @@ export const CycleListItemAction: FC<Props> = observer((props) => {
workspaceSlug,
projectId
);
const renderIcon = Boolean(cycleDetails.start_date) || Boolean(cycleDetails.end_date);
// handlers
const handleAddToFavorites = (e: MouseEvent<HTMLButtonElement>) => {
@ -157,54 +141,6 @@ export const CycleListItemAction: FC<Props> = observer((props) => {
});
};
const submitChanges = (data: Partial<ICycle>) => {
if (!workspaceSlug || !projectId || !cycleId) return;
updateCycleDetails(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), data);
};
const dateChecker = async (payload: any) => {
try {
const res = await cycleService.cycleDateCheck(workspaceSlug as string, projectId as string, payload);
return res.status;
} catch {
return false;
}
};
const handleDateChange = async (startDate: Date | undefined, endDate: Date | undefined) => {
if (!startDate || !endDate) return;
let isDateValid = false;
const payload = {
start_date: renderFormattedPayloadDate(startDate),
end_date: renderFormattedPayloadDate(endDate),
};
if (cycleDetails && cycleDetails.start_date && cycleDetails.end_date)
isDateValid = await dateChecker({
...payload,
cycle_id: cycleDetails.id,
});
else isDateValid = await dateChecker(payload);
if (isDateValid) {
submitChanges(payload);
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("project_cycles.action.update.success.title"),
message: t("project_cycles.action.update.success.description"),
});
} else {
setToast({
type: TOAST_TYPE.ERROR,
title: t("project_cycles.action.update.failed.title"),
message: t("project_cycles.action.update.error.already_exists"),
});
reset({ ...cycleDetails });
}
};
const createdByDetails = cycleDetails.created_by ? getUserDetails(cycleDetails.created_by) : undefined;
useEffect(() => {
@ -214,10 +150,6 @@ export const CycleListItemAction: FC<Props> = observer((props) => {
});
}, [cycleDetails, reset]);
const isArchived = Boolean(cycleDetails.archived_at);
const isCompleted = cycleStatus === "completed";
const isDisabled = !isEditingAllowed || isArchived || isCompleted;
// handlers
const openCycleOverview = (e: MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => {
e.preventDefault();
@ -266,39 +198,27 @@ export const CycleListItemAction: FC<Props> = observer((props) => {
</div>
)}
{!isActive && (
<Controller
control={control}
name="start_date"
render={({ field: { value: startDateValue, onChange: onChangeStartDate } }) => (
<Controller
control={control}
name="end_date"
render={({ field: { value: endDateValue, onChange: onChangeEndDate } }) => (
<DateRangeDropdown
buttonContainerClassName={`h-6 w-full flex ${isDisabled ? "cursor-not-allowed" : "cursor-pointer"} items-center gap-1.5 text-custom-text-300 border-[0.5px] border-custom-border-300 rounded text-xs`}
buttonVariant="transparent-with-text"
minDate={new Date()}
value={{
from: getDate(startDateValue),
to: getDate(endDateValue),
}}
onSelect={(val) => {
onChangeStartDate(val?.from ? renderFormattedPayloadDate(val.from) : null);
onChangeEndDate(val?.to ? renderFormattedPayloadDate(val.to) : null);
handleDateChange(val?.from, val?.to);
}}
placeholder={{
from: "Start date",
to: "End date",
}}
required={cycleDetails.status !== "draft"}
disabled={isDisabled}
hideIcon={{ from: renderIcon ?? true, to: renderIcon }}
/>
)}
/>
)}
{!isActive && cycleDetails.start_date && (
<DateRangeDropdown
buttonVariant={"transparent-with-text"}
buttonContainerClassName={`h-6 w-full cursor-auto flex items-center gap-1.5 text-custom-text-300 rounded text-xs [&>div]:hover:bg-transparent`}
buttonClassName="p-0"
minDate={new Date()}
value={{
from: getDate(cycleDetails.start_date),
to: getDate(cycleDetails.end_date),
}}
placeholder={{
from: "Start date",
to: "End date",
}}
showTooltip
required={cycleDetails.status !== "draft"}
disabled
hideIcon={{
from: false,
to: false,
}}
/>
)}

View file

@ -35,7 +35,7 @@ type Props = {
};
minDate?: Date;
maxDate?: Date;
onSelect: (range: DateRange | undefined) => void;
onSelect?: (range: DateRange | undefined) => void;
placeholder?: {
from?: string;
to?: string;
@ -204,11 +204,7 @@ export const DateRangeDropdown: React.FC<Props> = (props) => {
classNames={{ root: `p-3 rounded-md` }}
selected={dateRange}
onSelect={(val) => {
onSelect(val);
setDateRange({
from: val?.from ?? undefined,
to: val?.to ?? undefined,
});
onSelect?.(val);
}}
mode="range"
disabled={disabledDays}

View file

@ -36,6 +36,7 @@ type Props = TDropdownProps & {
stateIds?: string[];
filterAvailableStateIds?: boolean;
isForWorkItemCreation?: boolean;
alwaysAllowStateChange?: boolean;
};
export const StateDropdown: React.FC<Props> = observer((props) => {
@ -59,8 +60,6 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
value,
renderByDefault = true,
stateIds,
filterAvailableStateIds = true,
isForWorkItemCreation = false,
} = props;
// states
const [query, setQuery] = useState("");
@ -235,13 +234,11 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
filteredOptions.length > 0 ? (
filteredOptions.map((option) => (
<StateOption
{...props}
key={option.value}
option={option}
projectId={projectId}
filterAvailableStateIds={filterAvailableStateIds}
selectedValue={value}
className="flex w-full cursor-pointer select-none items-center justify-between gap-2 truncate rounded px-1 py-1.5"
isForWorkItemCreation={isForWorkItemCreation}
/>
))
) : (

View file

@ -61,6 +61,7 @@ export const InboxIssueProperties: FC<TInboxIssueProperties> = observer((props)
projectId={projectId}
buttonVariant="border-with-text"
tabIndex={getIndex("state_id")}
isForWorkItemCreation={!data?.id}
/>
</div>

View file

@ -66,7 +66,7 @@ export const IssueAttachmentsListItem: FC<TIssueAttachmentsListItem> = observer(
</div>
<div className="flex items-center gap-3">
{attachment?.updated_by && (
{attachment?.created_by && (
<>
<Tooltip
isMobile={isMobile}

View file

@ -26,7 +26,7 @@ export const IssueDetailWidgetCollapsibles: FC<Props> = observer((props) => {
const {
issue: { getIssueById },
subIssues: { subIssuesByIssueId },
attachment: { getAttachmentsUploadStatusByIssueId },
attachment: { getAttachmentsCountByIssueId, getAttachmentsUploadStatusByIssueId },
relation: { getRelationCountByIssueId },
} = useIssueDetail();
@ -41,8 +41,8 @@ export const IssueDetailWidgetCollapsibles: FC<Props> = observer((props) => {
const shouldRenderRelations = issueRelationsCount > 0;
const shouldRenderLinks = !!issue?.link_count && issue?.link_count > 0;
const attachmentUploads = getAttachmentsUploadStatusByIssueId(issueId);
const shouldRenderAttachments =
(!!issue?.attachment_count && issue?.attachment_count > 0) || (!!attachmentUploads && attachmentUploads.length > 0);
const attachmentsCount = getAttachmentsCountByIssueId(issueId);
const shouldRenderAttachments = attachmentsCount > 0 || (!!attachmentUploads && attachmentUploads.length > 0);
return (
<div className="flex flex-col">

View file

@ -244,6 +244,7 @@ export const ListGroup = observer((props: Props) => {
const isDragAllowed = !!group_by && DRAG_ALLOWED_GROUPS.includes(group_by);
const canOverlayBeVisible = isWorkflowDropDisabled || orderBy !== "sort_order" || !!group.isDropDisabled;
const isDropDisabled = isWorkflowDropDisabled || !!group.isDropDisabled;
const isGroupByCreatedBy = group_by === "created_by";
const shouldExpand = (!!groupIssueCount && isExpanded) || !group_by;
@ -253,7 +254,7 @@ export const ListGroup = observer((props: Props) => {
ref={groupRef}
className={cn(`relative flex flex-shrink-0 flex-col border-[1px] border-transparent`, {
"border-custom-primary-100": isDraggingOverColumn,
"border-custom-error-200": isDraggingOverColumn && !!group.isDropDisabled,
"border-custom-error-200": isDraggingOverColumn && isDropDisabled,
})}
>
<Row
@ -283,7 +284,7 @@ export const ListGroup = observer((props: Props) => {
<GroupDragOverlay
dragColumnOrientation={dragColumnOrientation}
canOverlayBeVisible={canOverlayBeVisible}
isDropDisabled={isWorkflowDropDisabled || !!group.isDropDisabled}
isDropDisabled={isDropDisabled}
workflowDisabledSource={workflowDisabledSource}
dropErrorMessage={group.dropErrorMessage}
orderBy={orderBy}

View file

@ -411,50 +411,49 @@ export const ModuleAnalyticsSidebar: React.FC<Props> = observer((props) => {
/>
)}
<div className="flex items-center justify-start gap-1">
<div className="flex w-2/5 items-center justify-start gap-2 text-custom-text-300">
<CalendarClock className="h-4 w-4" />
<span className="text-base">{t("date_range")}</span>
</div>
<div className="h-7 w-3/5">
<Controller
control={control}
name="start_date"
render={({ field: { value: startDateValue, onChange: onChangeStartDate } }) => (
<Controller
control={control}
name="target_date"
render={({ field: { value: endDateValue, onChange: onChangeEndDate } }) => {
const startDate = getDate(startDateValue);
const endDate = getDate(endDateValue);
return (
<DateRangeDropdown
buttonContainerClassName="w-full"
buttonVariant="background-with-text"
value={{
from: startDate,
to: endDate,
}}
onSelect={(val) => {
onChangeStartDate(val?.from ? renderFormattedPayloadDate(val.from) : null);
onChangeEndDate(val?.to ? renderFormattedPayloadDate(val.to) : null);
handleDateChange(val?.from, val?.to);
}}
placeholder={{
from: t("start_date"),
to: t("target_date"),
}}
disabled={!isEditingAllowed || isArchived}
/>
);
}}
/>
)}
/>
</div>
</div>
<div className="flex flex-col gap-5 pb-6 pt-2.5">
<div className="flex items-center justify-start gap-1">
<div className="flex w-2/5 items-center justify-start gap-2 text-custom-text-300">
<CalendarClock className="h-4 w-4" />
<span className="text-base">{t("date_range")}</span>
</div>
<div className="h-7">
<Controller
control={control}
name="start_date"
render={({ field: { value: startDateValue, onChange: onChangeStartDate } }) => (
<Controller
control={control}
name="target_date"
render={({ field: { value: endDateValue, onChange: onChangeEndDate } }) => {
const startDate = getDate(startDateValue);
const endDate = getDate(endDateValue);
return (
<DateRangeDropdown
buttonContainerClassName="w-full"
buttonVariant="background-with-text"
value={{
from: startDate,
to: endDate,
}}
onSelect={(val) => {
onChangeStartDate(val?.from ? renderFormattedPayloadDate(val.from) : null);
onChangeEndDate(val?.to ? renderFormattedPayloadDate(val.to) : null);
handleDateChange(val?.from, val?.to);
}}
placeholder={{
from: t("start_date"),
to: t("end_date"),
}}
disabled={!isEditingAllowed || isArchived}
/>
);
}}
/>
)}
/>
</div>
</div>
<div className="flex items-center justify-start gap-1">
<div className="flex w-2/5 items-center justify-start gap-2 text-custom-text-300">
<SquareUser className="h-4 w-4" />

View file

@ -6,7 +6,13 @@ import { useParams } from "next/navigation";
// icons
import { SquareUser } from "lucide-react";
// types
import { MODULE_STATUS, MODULE_FAVORITED, MODULE_UNFAVORITED , EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import {
MODULE_STATUS,
MODULE_FAVORITED,
MODULE_UNFAVORITED,
EUserPermissions,
EUserPermissionsLevel,
} from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { IModule } from "@plane/types";
// ui

View file

@ -179,7 +179,9 @@ export class CycleStore implements ICycleStore {
const endDate = getDate(c.end_date);
const hasEndDatePassed = endDate && isPast(endDate);
const isEndDateToday = endDate && isToday(endDate);
return c.project_id === projectId && hasEndDatePassed && !isEndDateToday && !c?.archived_at;
return (
c.project_id === projectId && ((hasEndDatePassed && !isEndDateToday) || c.status?.toLowerCase() === "completed")
);
});
completedCycles = sortBy(completedCycles, [(c) => c.sort_order]);
const completedCycleIds = completedCycles.map((c) => c.id);
@ -195,7 +197,9 @@ export class CycleStore implements ICycleStore {
let incompleteCycles = Object.values(this.cycleMap ?? {}).filter((c) => {
const endDate = getDate(c.end_date);
const hasEndDatePassed = endDate && isPast(endDate);
return c.project_id === projectId && !hasEndDatePassed && !c?.archived_at;
return (
c.project_id === projectId && !hasEndDatePassed && !c?.archived_at && c.status?.toLowerCase() !== "completed"
);
});
incompleteCycles = sortBy(incompleteCycles, [(c) => c.sort_order]);
const incompleteCycleIds = incompleteCycles.map((c) => c.id);

View file

@ -51,6 +51,7 @@ export interface IIssueAttachmentStore extends IIssueAttachmentStoreActions {
getAttachmentsUploadStatusByIssueId: (issueId: string) => TAttachmentUploadStatus[] | undefined;
getAttachmentsByIssueId: (issueId: string) => string[] | undefined;
getAttachmentById: (attachmentId: string) => TIssueAttachment | undefined;
getAttachmentsCountByIssueId: (issueId: string) => number;
}
export class IssueAttachmentStore implements IIssueAttachmentStore {
@ -109,6 +110,11 @@ export class IssueAttachmentStore implements IIssueAttachmentStore {
return this.attachmentMap[attachmentId] ?? undefined;
};
getAttachmentsCountByIssueId = (issueId: string) => {
const attachments = this.getAttachmentsByIssueId(issueId);
return attachments?.length ?? 0;
};
// actions
addAttachments = (issueId: string, attachments: TIssueAttachment[]) => {
if (attachments && attachments.length > 0) {
@ -155,12 +161,14 @@ export class IssueAttachmentStore implements IIssueAttachmentStore {
this.debouncedUpdateProgress(issueId, tempId, progressPercentage);
}
);
const issueAttachmentsCount = this.getAttachmentsByIssueId(issueId)?.length ?? 0;
if (response && response.id) {
runInAction(() => {
update(this.attachments, [issueId], (attachmentIds = []) => uniq(concat(attachmentIds, [response.id])));
set(this.attachmentMap, response.id, response);
this.rootIssueStore.issues.updateIssue(issueId, {
attachment_count: this.getAttachmentsCountByIssueId(issueId),
});
});
}
@ -182,7 +190,6 @@ export class IssueAttachmentStore implements IIssueAttachmentStore {
issueId,
attachmentId
);
const issueAttachmentsCount = this.getAttachmentsByIssueId(issueId)?.length ?? 1;
runInAction(() => {
update(this.attachments, [issueId], (attachmentIds = []) => {
@ -191,7 +198,7 @@ export class IssueAttachmentStore implements IIssueAttachmentStore {
});
delete this.attachmentMap[attachmentId];
this.rootIssueStore.issues.updateIssue(issueId, {
attachment_count: issueAttachmentsCount - 1, // decrement attachment count
attachment_count: this.getAttachmentsCountByIssueId(issueId),
});
});

View file

@ -425,7 +425,6 @@ export class ModulesStore implements IModuleStore {
set(this.moduleMap, [moduleId], { ...originalModuleDetails, ...data });
});
const response = await this.moduleService.patchModule(workspaceSlug, projectId, moduleId, data);
this.fetchModuleDetails(workspaceSlug, projectId, moduleId);
return response;
} catch (error) {
console.error("Failed to update module in module store", error);

View file

@ -1,7 +1,8 @@
{
"name": "web",
"version": "0.25.0",
"version": "0.25.1",
"private": true,
"license": "AGPL-3.0",
"scripts": {
"dev": "turbo run develop",
"develop": "next dev --port 3000",

View file

@ -10381,11 +10381,6 @@ react-confetti@^6.1.0:
dependencies:
tween-functions "^1.2.0"
react-day-picker@8.10.1:
version "8.10.1"
resolved "https://registry.yarnpkg.com/react-day-picker/-/react-day-picker-8.10.1.tgz#4762ec298865919b93ec09ba69621580835b8e80"
integrity sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA==
react-day-picker@9.5.0:
version "9.5.0"
resolved "https://registry.yarnpkg.com/react-day-picker/-/react-day-picker-9.5.0.tgz#2ae36e85d6506026d72e350f49b5607d011cfd6f"