Compare commits

..

29 Commits

Author SHA1 Message Date
sriram veeraghanta 5f5ab2b75d fix: moving nginx to caddy 2025-03-09 16:19:09 +05:30
Anmol Singh Bhatia f8997446e2 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>
2025-03-03 19:56:39 +05:30
Anmol Singh Bhatia 9487c02954 chore: extended sidebar improvement (#6693) 2025-03-03 18:12:03 +05:30
Akshita Goyal 0188cabbde fix: module date picker (#6691)
* fix: Handled workspace switcher closing on click

* fix: reverted module date picker changes
2025-03-03 17:56:39 +05:30
Akshita Goyal 392a6e0137 [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
2025-03-03 17:39:07 +05:30
Lakhan Baheti 7e62c60748 [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>
2025-03-03 17:01:47 +05:30
Nikita Mitasov 9bff707fb5 chore: update russian translation (#6682)
* chore: update russian translation

* chore: rename issues to work items in russian translation
2025-03-03 16:15:06 +05:30
Akshat Jain 0d599ef2dc changed github workflow action ubuntu version to ubuntu-22.04 (#6683) 2025-03-03 16:10:23 +05:30
Nikhil e183a0cc63 fix: issue activity task (#6689) 2025-03-03 16:06:02 +05:30
Aaryan Khandelwal 958a3676af chore: add common translation keys (#6688)
* chore: add missing translation keys

* chore: add russian translation keys
2025-03-03 13:31:09 +05:30
Akshita Goyal 59a0925d34 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
2025-02-25 21:21:02 +05:30
sriram veeraghanta fbbf58481d fix: cleanup for deprecated functions 2025-02-25 21:20:00 +05:30
sriram veeraghanta 6356bb1dbb fix: intake work item creation refactor 2025-02-25 17:56:11 +05:30
sriram veeraghanta d08bce35a3 fix: state drop down refactor 2025-02-25 15:39:29 +05:30
Anmol Singh Bhatia 9297448ec8 chore: ru translation updated (#6672) 2025-02-25 15:10:43 +05:30
Nikita Mitasov 5329326602 feat: russian translation (#6666) 2025-02-25 13:02:05 +05:30
Manish Gupta 062fc9dbc0 updated the action to modify the release build assets (#6669) 2025-02-25 12:59:27 +05:30
Anmol Singh Bhatia fde8630c5b fix: work item attachment count mutation (#6670) 2025-02-25 12:54:51 +05:30
Nikhil aa0b2c0be4 fix: issue activity for project id validation (#6668) 2025-02-25 12:32:36 +05:30
sriram veeraghanta f70eae2f3b fix: package version update 2025-02-24 20:37:55 +05:30
sriram veeraghanta cf8823fa96 Merge pull request #6664 from makeplane/canary
release: v0.25.0
2025-02-24 20:34:31 +05:30
Bavisetti Narayan 5f3d02606c [WEB-3449] chore: changed the logic from utc to project date conversion (#6663)
* chore: changed the logic from utc to project date conversion

* chore: changed the cycle to project timezone
2025-02-24 19:51:13 +05:30
sriram veeraghanta aeed6590b7 chore: selfhost docker compose updated by adding comments 2025-02-24 19:44:01 +05:30
Akshat Jain 1f18b08655 feat: docker swarm support for plane community using swarm.sh file (#6406)
* added swarm stack in selfhost

* synced docker-compose and swarm-compose

* updated the BRANCH variable

* fixes

* fix: swarm script upgrade function and `APP_RELEASE` variable

* fix: remove network from compose file and fix swarm script

* removed property restart from docker compose file

* added restart_policy condition in docker compose

* fix: changed restart policy to `any`

* changed `restart_policy` from `any` to `on-failure`

* updated selfhost readme

* chore: added migrator, redis, minio, db and mq service logs and also added redeployStack in script file

* add sleep in redeployStack service

* fixed typo in swarm and install script

* updated coderabbit suggestions

* added Replica Envs for services

* removed additional replica envs from docker compose file

---------

Co-authored-by: Manish Gupta <59428681+mguptahub@users.noreply.github.com>
2025-02-24 17:09:06 +05:30
Akshita Goyal e4dd2a6c07 [WEB-3452] Fix: date picker auto close (#6662)
* fix: Handled workspace switcher closing on click

* fix: removed auto close for date range picker
2025-02-24 15:10:22 +05:30
Vipin Chaudhary bcb9c73634 [WEB-3411] fix: archive permission (#6661)
* fix restore typo

* fix archive permissions

* fix restore typo
2025-02-24 14:33:55 +05:30
sriram veeraghanta 952eee8d55 fix: minor refactoring changes for state dropdowns 2025-02-24 14:14:24 +05:30
Nikhil da469dac18 chore: error handling (#6657)
* fix: ai completions

* fix: reset password endpoints

* fix: intake issue list

* fix: identifier validation, uuid validation
2025-02-24 13:36:21 +05:30
sriram veeraghanta 9ed4591edc Merge pull request #6183 from makeplane/canary
release: v0.24.1
2024-12-10 21:43:09 +05:30
123 changed files with 6267 additions and 1632 deletions
+2 -2
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
+3 -26
View File
@@ -299,31 +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/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
@@ -337,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 }}
@@ -348,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
@@ -362,6 +338,7 @@ jobs:
generate_release_notes: true
files: |
${{ 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
+1 -1
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
+1 -1
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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "admin",
"version": "0.24.1",
"version": "0.25.0",
"private": true,
"scripts": {
"dev": "turbo run develop",
+1 -1
View File
@@ -1,4 +1,4 @@
{
"name": "plane-api",
"version": "0.24.1"
"version": "0.25.0"
}
@@ -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 (
@@ -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"]
-2
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,
-23
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",
),
]
-2
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
-812
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)
+8 -6
View File
@@ -3,7 +3,7 @@ import os
from typing import List, Dict, Tuple
# Third party import
import litellm
from openai import OpenAI
import requests
from rest_framework import status
@@ -116,12 +116,14 @@ def get_llm_response(task, prompt, api_key: str, model: str, provider: str) -> T
if provider.lower() == "gemini":
model = f"gemini/{model}"
response = litellm.completion(
client = OpenAI(api_key=api_key)
chat_completion = client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": final_text}],
api_key=api_key,
messages=[
{"role": "user", "content": final_text}
]
)
text = response.choices[0].message.content.strip()
text = chat_completion.choices[0].message.content
return text, None
except Exception as e:
log_exception(e)
@@ -175,7 +177,7 @@ class WorkspaceGPTIntegrationEndpoint(BaseAPIView):
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def post(self, request, slug):
api_key, model, provider = get_llm_config()
if not api_key or not model or not provider:
return Response(
{"error": "LLM provider API key and model are required"},
+5 -2
View File
@@ -174,14 +174,17 @@ class IntakeIssueViewSet(BaseViewSet):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def list(self, request, slug, project_id):
intake_id = Intake.objects.filter(
intake = Intake.objects.filter(
workspace__slug=slug, project_id=project_id
).first()
if not intake:
return Response({"error": "Intake not found"}, status=status.HTTP_404_NOT_FOUND)
project = Project.objects.get(pk=project_id)
filters = issue_filters(request.GET, "GET", "issue__")
intake_issue = (
IntakeIssue.objects.filter(
intake_id=intake_id.id, project_id=project_id, **filters
intake_id=intake.id, project_id=project_id, **filters
)
.select_related("issue")
.prefetch_related("issue__labels")
+17 -3
View File
@@ -547,7 +547,7 @@ class IssueViewSet(BaseViewSet):
)
"""
if the role is guest and guest_view_all_features is false and owned by is not
if the role is guest and guest_view_all_features is false and owned by is not
the requesting user then dont show the issue
"""
@@ -1116,8 +1116,22 @@ class IssueMetaEndpoint(BaseAPIView):
class IssueDetailIdentifierEndpoint(BaseAPIView):
def strict_str_to_int(self, s):
if not s.isdigit() and not (s.startswith('-') and s[1:].isdigit()):
raise ValueError("Invalid integer string")
return int(s)
def get(self, request, slug, project_identifier, issue_identifier):
# Check if the issue identifier is a valid integer
try:
issue_identifier = self.strict_str_to_int(issue_identifier)
except ValueError:
return Response(
{"error": "Invalid issue identifier"},
status=status.HTTP_400_BAD_REQUEST,
)
# Fetch the project
project = Project.objects.get(
identifier__iexact=project_identifier,
@@ -1240,7 +1254,7 @@ class IssueDetailIdentifierEndpoint(BaseAPIView):
)
"""
if the role is guest and guest_view_all_features is false and owned by is not
if the role is guest and guest_view_all_features is false and owned by is not
the requesting user then dont show the issue
"""
+1 -1
View File
@@ -280,7 +280,7 @@ class ModuleIssueViewSet(BaseViewSet):
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=json.dumps(
{"module_name": module_issue.first().module.name}
{"module_name": module_issue.first().module.name if (module_issue.first() and module_issue.first().module) else None}
),
epoch=int(timezone.now().timestamp()),
notification=True,
@@ -100,8 +100,20 @@ class ResetPasswordEndpoint(View):
def post(self, request, uidb64, token):
try:
# Decode the id from the uidb64
id = smart_str(urlsafe_base64_decode(uidb64))
user = User.objects.get(id=id)
try:
id = smart_str(urlsafe_base64_decode(uidb64))
user = User.objects.get(id=id)
except (ValueError, User.DoesNotExist):
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD_TOKEN"],
error_message="INVALID_PASSWORD_TOKEN",
)
params = exc.get_error_dict()
url = urljoin(
base_host(request=request, is_app=True),
"accounts/reset-password?" + urlencode(params),
)
return HttpResponseRedirect(url)
# check if the token is valid for the user
if not PasswordResetTokenGenerator().check_token(user, token):
@@ -9,10 +9,10 @@ from celery import shared_task
from django.core.serializers.json import DjangoJSONEncoder
from django.utils import timezone
from plane.app.serializers import IssueActivitySerializer
from plane.bgtasks.notification_task import notifications
# Module imports
from plane.app.serializers import IssueActivitySerializer
from plane.bgtasks.notification_task import notifications
from plane.db.models import (
CommentReaction,
Cycle,
@@ -32,6 +32,7 @@ from plane.settings.redis import redis_instance
from plane.utils.exception_logger import log_exception
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
@@ -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(
@@ -1568,6 +1569,10 @@ def issue_activity(
try:
issue_activities = []
# check if project_id is valid
if not is_valid_uuid(str(project_id)):
return
project = Project.objects.get(pk=project_id)
workspace_id = project.workspace_id
@@ -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",
),
]
-1
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,
-92
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",)
+25 -31
View File
@@ -11,7 +11,6 @@ from django.core.exceptions import ValidationError
from django.utils import timezone
from django.contrib.auth.hashers import make_password
from django.contrib.auth import logout
from django.utils.http import url_has_allowed_host_and_scheme
# Third party imports
from rest_framework.response import Response
@@ -249,12 +248,11 @@ class InstanceAdminSignInEndpoint(View):
error_code=AUTHENTICATION_ERROR_CODES["INSTANCE_NOT_CONFIGURED"],
error_message="INSTANCE_NOT_CONFIGURED",
)
base_url = base_host(request=request, is_admin=True)
redirect_url = urljoin(base_url, "?" + urlencode(exc.get_error_dict()))
if url_has_allowed_host_and_scheme(redirect_url, allowed_hosts=None):
return HttpResponseRedirect(redirect_url)
else:
return HttpResponseRedirect('/')
url = urljoin(
base_host(request=request, is_admin=True),
"?" + urlencode(exc.get_error_dict()),
)
return HttpResponseRedirect(url)
# Get email and password
email = request.POST.get("email", False)
@@ -267,12 +265,11 @@ class InstanceAdminSignInEndpoint(View):
error_message="REQUIRED_ADMIN_EMAIL_PASSWORD",
payload={"email": email},
)
base_url = base_host(request=request, is_admin=True)
redirect_url = urljoin(base_url, "?" + urlencode(exc.get_error_dict()))
if url_has_allowed_host_and_scheme(redirect_url, allowed_hosts=None):
return HttpResponseRedirect(redirect_url)
else:
return HttpResponseRedirect('/')
url = urljoin(
base_host(request=request, is_admin=True),
"?" + urlencode(exc.get_error_dict()),
)
return HttpResponseRedirect(url)
# Validate the email
email = email.strip().lower()
@@ -284,12 +281,11 @@ class InstanceAdminSignInEndpoint(View):
error_message="INVALID_ADMIN_EMAIL",
payload={"email": email},
)
base_url = base_host(request=request, is_admin=True)
redirect_url = urljoin(base_url, "?" + urlencode(exc.get_error_dict()))
if url_has_allowed_host_and_scheme(redirect_url, allowed_hosts=None):
return HttpResponseRedirect(redirect_url)
else:
return HttpResponseRedirect('/')
url = urljoin(
base_host(request=request, is_admin=True),
"?" + urlencode(exc.get_error_dict()),
)
return HttpResponseRedirect(url)
# Fetch the user
user = User.objects.filter(email=email).first()
@@ -301,12 +297,11 @@ class InstanceAdminSignInEndpoint(View):
error_message="ADMIN_USER_DOES_NOT_EXIST",
payload={"email": email},
)
base_url = base_host(request=request, is_admin=True)
redirect_url = urljoin(base_url, "?" + urlencode(exc.get_error_dict()))
if url_has_allowed_host_and_scheme(redirect_url, allowed_hosts=None):
return HttpResponseRedirect(redirect_url)
else:
return HttpResponseRedirect('/')
url = urljoin(
base_host(request=request, is_admin=True),
"?" + urlencode(exc.get_error_dict()),
)
return HttpResponseRedirect(url)
# is_active
if not user.is_active:
@@ -314,12 +309,11 @@ class InstanceAdminSignInEndpoint(View):
error_code=AUTHENTICATION_ERROR_CODES["ADMIN_USER_DEACTIVATED"],
error_message="ADMIN_USER_DEACTIVATED",
)
base_url = base_host(request=request, is_admin=True)
redirect_url = urljoin(base_url, "?" + urlencode(exc.get_error_dict()))
if url_has_allowed_host_and_scheme(redirect_url, allowed_hosts=None):
return HttpResponseRedirect(redirect_url)
else:
return HttpResponseRedirect('/')
url = urljoin(
base_host(request=request, is_admin=True),
"?" + urlencode(exc.get_error_dict()),
)
return HttpResponseRedirect(url)
# Check password of the user
if not user.check_password(password):
+1 -1
View File
@@ -77,7 +77,7 @@ def convert_to_utc(
current_datetime_in_project_tz = timezone.now().astimezone(local_tz)
current_datetime_in_utc = current_datetime_in_project_tz.astimezone(pytz.utc)
if utc_datetime.date() == current_datetime_in_utc.date():
if localized_datetime.date() == current_datetime_in_project_tz.date():
return current_datetime_in_utc
return utc_datetime
+8
View File
@@ -0,0 +1,8 @@
import uuid
def is_valid_uuid(uuid_str):
try:
uuid.UUID(uuid_str, version=4)
return True
except ValueError:
return False
+3 -3
View File
@@ -4,7 +4,7 @@
Django==4.2.18
# rest framework
djangorestframework==3.15.2
# postgres
# postgres
psycopg==3.1.18
psycopg-binary==3.1.18
psycopg-c==3.1.18
@@ -37,7 +37,7 @@ uvicorn==0.29.0
# sockets
channels==4.1.0
# ai
litellm==1.51.0
openai==1.63.2
# slack
slack-sdk==3.27.1
# apm
@@ -66,4 +66,4 @@ PyJWT==2.8.0
opentelemetry-api==1.28.1
opentelemetry-sdk==1.28.1
opentelemetry-instrumentation-django==0.49b1
opentelemetry-exporter-otlp==1.28.1
opentelemetry-exporter-otlp==1.28.1
+1 -1
View File
@@ -2,4 +2,4 @@
# debug toolbar
django-debug-toolbar==4.3.0
# formatter
ruff==0.4.2
ruff==0.9.7
+136 -14
View File
@@ -55,18 +55,30 @@ Installing plane is a very easy and minimal step process.
- User context used must have access to docker services. In most cases, use sudo su to switch as root user
- Use the terminal (or gitbash) window to run all the future steps
### Downloading Latest Stable Release
### Downloading Latest Release
```
mkdir plane-selfhost
cd plane-selfhost
```
#### For *Docker Compose* based setup
```
curl -fsSL -o setup.sh https://github.com/makeplane/plane/releases/latest/download/setup.sh
chmod +x setup.sh
```
#### For *Docker Swarm* based setup
```
curl -fsSL -o setup.sh https://github.com/makeplane/plane/releases/latest/download/swarm.sh
chmod +x setup.sh
```
---
### Proceed with setup
@@ -77,8 +89,9 @@ Lets get started by running the `./setup.sh` command.
This will prompt you with the below options.
#### Docker Compose
```bash
Select a Action you want to perform:
Select an Action you want to perform:
1) Install (x86_64)
2) Start
3) Stop
@@ -87,17 +100,42 @@ Select a Action you want to perform:
6) View Logs
7) Backup Data
8) Exit
Action [2]: 1
```
For the 1st time setup, type "1" as action input.
This will create a create a folder `plane-app` or `plane-app-preview` (in case of preview deployment) and will download 2 files inside that
This will create a folder `plane-app` and will download 2 files inside that
- `docker-compose.yaml`
- `plane.env`
Again the `options [1-8]` will be popped up and this time hit `8` to exit.
Again the `options [1-8]` will be popped up, and this time hit `8` to exit.
#### Docker Swarm
```bash
Select an Action you want to perform:
1) Deploy Stack
2) Remove Stack
3) View Stack Status
4) Redeploy Stack
5) Upgrade
6) View Logs
7) Exit
Action [3]: 1
```
For the 1st time setup, type "1" as action input.
This will create a create a folder `plane-app` and will download 2 files inside that
- `docker-compose.yaml`
- `plane.env`
Again the `options [1-7]` will be popped up, and this time hit `7` to exit.
---
@@ -116,7 +154,7 @@ There are many other settings you can play with, but we suggest you configure `E
---
### Continue with setup - Start Server
### Continue with setup - Start Server (Docker Compose)
Lets again run the `./setup.sh` command. You will again be prompted with the below options. This time select `2` to start the sevices
@@ -147,9 +185,11 @@ You have successfully self hosted `Plane` instance. Access the application by go
---
### Stopping the Server
### Stopping the Server / Remove Stack
In case you want to make changes to `.env` variables, we suggest you to stop the services before doing that.
In case you want to make changes to `plane.env` variables, we suggest you to stop the services before doing that.
#### Docker Compose
Lets again run the `./setup.sh` command. You will again be prompted with the below options. This time select `3` to stop the sevices
@@ -171,14 +211,34 @@ If all goes well, you must see something like this
![Stop Services](images/stopped.png)
#### Docker Swarm
Lets again run the `./setup.sh` command. You will again be prompted with the below options. This time select `2` to stop the sevices
```bash
Select an Action you want to perform:
1) Deploy Stack
2) Remove Stack
3) View Stack Status
4) Redeploy Stack
5) Upgrade
6) View Logs
7) Exit
Action [3]: 2
```
If all goes well, you will see the confirmation from docker cli
---
### Restarting the Server
### Restarting the Server / Redeploy Stack
In case you want to make changes to `.env` variables, without stopping the server or you noticed some abnormalies in services, you can restart the services with RESTART option.
In case you want to make changes to `plane.env` variables, without stopping the server or you noticed some abnormalies in services, you can restart the services with `RESTART` / `REDEPLOY` option.
Lets again run the `./setup.sh` command. You will again be prompted with the below options. This time select `4` to restart the sevices
#### Docker Compose
```bash
Select a Action you want to perform:
1) Install (x86_64)
@@ -197,14 +257,32 @@ If all goes well, you must see something like this
![Restart Services](images/restart.png)
#### Docker Swarm
```bash
1) Deploy Stack
2) Remove Stack
3) View Stack Status
4) Redeploy Stack
5) Upgrade
6) View Logs
7) Exit
Action [3]: 4
```
If all goes well, you will see the confirmation from docker cli
---
### Upgrading Plane Version
### Upgrading Plane Version
It is always advised to keep Plane up to date with the latest release.
Lets again run the `./setup.sh` command. You will again be prompted with the below options. This time select `5` to upgrade the release.
#### Docker Compose
```bash
Select a Action you want to perform:
1) Install (x86_64)
@@ -231,13 +309,41 @@ Once done, choose `8` to exit from prompt.
Once done with making changes in `plane.env` file, jump on to `Start Server`
#### Docker Swarm
Lets again run the `./setup.sh` command. You will again be prompted with the below options. This time select `5` to upgrade the release.
```bash
1) Deploy Stack
2) Remove Stack
3) View Stack Status
4) Redeploy Stack
5) Upgrade
6) View Logs
7) Exit
Action [3]: 5
```
By choosing this, it will stop the services and then will download the latest `docker-compose.yaml` and `plane.env`.
Once done, choose `7` to exit from prompt.
> It is very important for you to validate the `plane.env` for the new changes.
Once done with making changes in `plane.env` file, jump on to `Redeploy Stack`
---
### View Logs
There would a time when you might want to check what is happening inside the API, Worker or any other container.
Lets again run the `./setup.sh` command. You will again be prompted with the below options. This time select `6` to view logs.
Lets again run the `./setup.sh` command. You will again be prompted with the below options.
This time select `6` to view logs.
#### Docker Compose
```bash
Select a Action you want to perform:
@@ -253,7 +359,22 @@ Select a Action you want to perform:
Action [2]: 6
```
#### Docker Swarm
```bash
1) Deploy Stack
2) Remove Stack
3) View Stack Status
4) Redeploy Stack
5) Upgrade
6) View Logs
7) Exit
Action [3]: 6
```
#### Service Menu Options for Logs
This will further open sub-menu with list of services
```bash
Select a Service you want to view the logs for:
@@ -267,9 +388,10 @@ Select a Service you want to view the logs for:
8) Redis
9) Postgres
10) Minio
11) RabbitMQ
0) Back to Main Menu
Service:
Service: 3
```
Select any of the service to view the logs e.g. `3`. Expect something similar to this
@@ -323,7 +445,7 @@ Similarly, you can view the logs of other services.
---
### Backup Data
### Backup Data (Docker Compose)
There would a time when you might want to backup your data from docker volumes to external storage like S3 or drives.
@@ -355,7 +477,7 @@ Backup completed successfully. Backup files are stored in /....../plane-app/back
---
### Restore Data
### Restore Data (Docker Compose)
When you want to restore the previously backed-up data, follow the instructions below.
+55 -48
View File
@@ -15,7 +15,7 @@ x-redis-env: &redis-env
x-minio-env: &minio-env
MINIO_ROOT_USER: ${AWS_ACCESS_KEY_ID:-access-key}
MINIO_ROOT_PASSWORD: ${AWS_SECRET_ACCESS_KEY:-secret-key}
x-aws-s3-env: &aws-s3-env
AWS_REGION: ${AWS_REGION:-}
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-access-key}
@@ -28,8 +28,7 @@ x-proxy-env: &proxy-env
BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads}
FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT:-5242880}
x-mq-env: &mq-env
# RabbitMQ Settings
x-mq-env: &mq-env # RabbitMQ Settings
RABBITMQ_HOST: ${RABBITMQ_HOST:-plane-mq}
RABBITMQ_PORT: ${RABBITMQ_PORT:-5672}
RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER:-plane}
@@ -50,33 +49,27 @@ x-app-env: &app-env
USE_MINIO: ${USE_MINIO:-1}
DATABASE_URL: ${DATABASE_URL:-postgresql://plane:plane@plane-db/plane}
SECRET_KEY: ${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5}
ADMIN_BASE_URL: ${ADMIN_BASE_URL}
SPACE_BASE_URL: ${SPACE_BASE_URL}
APP_BASE_URL: ${APP_BASE_URL}
AMQP_URL: ${AMQP_URL:-amqp://plane:plane@plane-mq:5672/plane}
services:
web:
image: ${DOCKERHUB_USER:-makeplane}/plane-frontend:${APP_RELEASE:-stable}
platform: ${DOCKER_PLATFORM:-}
pull_policy: if_not_present
restart: unless-stopped
command: node web/server.js web
deploy:
replicas: ${WEB_REPLICAS:-1}
restart_policy:
condition: on-failure
depends_on:
- api
- worker
space:
image: ${DOCKERHUB_USER:-makeplane}/plane-space:${APP_RELEASE:-stable}
platform: ${DOCKER_PLATFORM:-}
pull_policy: if_not_present
restart: unless-stopped
command: node space/server.js space
deploy:
replicas: ${SPACE_REPLICAS:-1}
restart_policy:
condition: on-failure
depends_on:
- api
- worker
@@ -84,42 +77,39 @@ services:
admin:
image: ${DOCKERHUB_USER:-makeplane}/plane-admin:${APP_RELEASE:-stable}
platform: ${DOCKER_PLATFORM:-}
pull_policy: if_not_present
restart: unless-stopped
command: node admin/server.js admin
deploy:
replicas: ${ADMIN_REPLICAS:-1}
restart_policy:
condition: on-failure
depends_on:
- api
- web
live:
image: ${DOCKERHUB_USER:-makeplane}/plane-live:${APP_RELEASE:-stable}
platform: ${DOCKER_PLATFORM:-}
pull_policy: if_not_present
restart: unless-stopped
command: node live/dist/server.js live
environment:
<<: [ *live-env ]
<<: [*live-env]
deploy:
replicas: ${LIVE_REPLICAS:-1}
restart_policy:
condition: on-failure
depends_on:
- api
- web
api:
image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable}
platform: ${DOCKER_PLATFORM:-}
pull_policy: if_not_present
restart: unless-stopped
command: ./bin/docker-entrypoint-api.sh
deploy:
replicas: ${API_REPLICAS:-1}
restart_policy:
condition: on-failure
volumes:
- logs_api:/code/plane/logs
environment:
<<: [ *app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env ]
<<: [*app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env]
depends_on:
- plane-db
- plane-redis
@@ -127,14 +117,15 @@ services:
worker:
image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable}
platform: ${DOCKER_PLATFORM:-}
pull_policy: if_not_present
restart: unless-stopped
command: ./bin/docker-entrypoint-worker.sh
deploy:
replicas: ${WORKER_REPLICAS:-1}
restart_policy:
condition: on-failure
volumes:
- logs_worker:/code/plane/logs
environment:
<<: [ *app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env ]
<<: [*app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env]
depends_on:
- api
- plane-db
@@ -143,14 +134,15 @@ services:
beat-worker:
image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable}
platform: ${DOCKER_PLATFORM:-}
pull_policy: if_not_present
restart: unless-stopped
command: ./bin/docker-entrypoint-beat.sh
deploy:
replicas: ${BEAT_WORKER_REPLICAS:-1}
restart_policy:
condition: on-failure
volumes:
- logs_beat-worker:/code/plane/logs
environment:
<<: [ *app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env ]
<<: [*app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env]
depends_on:
- api
- plane-db
@@ -159,23 +151,27 @@ services:
migrator:
image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable}
platform: ${DOCKER_PLATFORM:-}
pull_policy: if_not_present
restart: "no"
command: ./bin/docker-entrypoint-migrator.sh
deploy:
replicas: 1
restart_policy:
condition: on-failure
volumes:
- logs_migrator:/code/plane/logs
environment:
<<: [ *app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env ]
<<: [*app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env]
depends_on:
- plane-db
- plane-redis
# Comment this if you already have a database running
plane-db:
image: postgres:15.7-alpine
pull_policy: if_not_present
restart: unless-stopped
command: postgres -c 'max_connections=1000'
deploy:
replicas: 1
restart_policy:
condition: on-failure
environment:
<<: *db-env
volumes:
@@ -183,24 +179,32 @@ services:
plane-redis:
image: valkey/valkey:7.2.5-alpine
pull_policy: if_not_present
restart: unless-stopped
deploy:
replicas: 1
restart_policy:
condition: on-failure
volumes:
- redisdata:/data
plane-mq:
image: rabbitmq:3.13.6-management-alpine
restart: always
deploy:
replicas: 1
restart_policy:
condition: on-failure
environment:
<<: *mq-env
volumes:
- rabbitmq_data:/var/lib/rabbitmq
# Comment this if you using any external s3 compatible storage
plane-minio:
image: minio/minio:latest
pull_policy: if_not_present
restart: unless-stopped
command: server /export --console-address ":9090"
deploy:
replicas: 1
restart_policy:
condition: on-failure
environment:
<<: *minio-env
volumes:
@@ -209,13 +213,17 @@ services:
# Comment this if you already have a reverse proxy running
proxy:
image: ${DOCKERHUB_USER:-makeplane}/plane-proxy:${APP_RELEASE:-stable}
platform: ${DOCKER_PLATFORM:-}
pull_policy: if_not_present
restart: unless-stopped
ports:
- ${NGINX_PORT}:80
- target: 80
published: ${NGINX_PORT:-80}
protocol: tcp
mode: host
environment:
<<: *proxy-env
deploy:
replicas: 1
restart_policy:
condition: on-failure
depends_on:
- web
- api
@@ -224,7 +232,6 @@ services:
volumes:
pgdata:
redisdata:
uploads:
logs_api:
logs_worker:
+5 -2
View File
@@ -457,12 +457,13 @@ function viewLogs(){
echo " 8) Redis"
echo " 9) Postgres"
echo " 10) Minio"
echo " 11) RabbitMQ"
echo " 0) Back to Main Menu"
echo
read -p "Service: " DOCKER_SERVICE_NAME
until (( DOCKER_SERVICE_NAME >= 0 && DOCKER_SERVICE_NAME <= 10 )); do
echo "Invalid selection. Please enter a number between 1 and 11."
until (( DOCKER_SERVICE_NAME >= 0 && DOCKER_SERVICE_NAME <= 11 )); do
echo "Invalid selection. Please enter a number between 0 and 11."
read -p "Service: " DOCKER_SERVICE_NAME
done
@@ -481,6 +482,7 @@ function viewLogs(){
8) viewSpecificLogs "plane-redis";;
9) viewSpecificLogs "plane-db";;
10) viewSpecificLogs "plane-minio";;
11) viewSpecificLogs "plane-mq";;
0) askForAction;;
*) echo "INVALID SERVICE NAME SUPPLIED";;
esac
@@ -499,6 +501,7 @@ function viewLogs(){
redis) viewSpecificLogs "plane-redis";;
postgres) viewSpecificLogs "plane-db";;
minio) viewSpecificLogs "plane-minio";;
rabbitmq) viewSpecificLogs "plane-mq";;
*) echo "INVALID SERVICE NAME SUPPLIED";;
esac
else
+612
View File
@@ -0,0 +1,612 @@
#!/bin/bash
BRANCH=${BRANCH:-master}
SERVICE_FOLDER=plane-app
SCRIPT_DIR=$PWD
PLANE_INSTALL_DIR=$PWD/$SERVICE_FOLDER
export APP_RELEASE="stable"
export DOCKERHUB_USER=makeplane
export GH_REPO=makeplane/plane
export RELEASE_DOWNLOAD_URL="https://github.com/$GH_REPO/releases/download"
export FALLBACK_DOWNLOAD_URL="https://raw.githubusercontent.com/$GH_REPO/$BRANCH/deploy/selfhost"
OS_NAME=$(uname)
# Create necessary directories
mkdir -p $PLANE_INSTALL_DIR/archive
DOCKER_FILE_PATH=$PLANE_INSTALL_DIR/docker-compose.yml
DOCKER_ENV_PATH=$PLANE_INSTALL_DIR/plane.env
function print_header() {
clear
cat <<"EOF"
--------------------------------------------
____ _ /////////
| _ \| | __ _ _ __ ___ /////////
| |_) | |/ _` | '_ \ / _ \ ///// /////
| __/| | (_| | | | | __/ ///// /////
|_| |_|\__,_|_| |_|\___| ////
////
--------------------------------------------
Project management tool from the future
--------------------------------------------
EOF
}
function checkLatestRelease(){
echo "Checking for the latest release..." >&2
local latest_release=$(curl -s https://api.github.com/repos/$GH_REPO/releases/latest | grep -o '"tag_name": "[^"]*"' | sed 's/"tag_name": "//;s/"//g')
if [ -z "$latest_release" ]; then
echo "Failed to check for the latest release. Exiting..." >&2
exit 1
fi
echo $latest_release
}
# Function to read stack name from env file
function readStackName() {
if [ -f "$DOCKER_ENV_PATH" ]; then
local saved_stack_name=$(grep "^STACK_NAME=" "$DOCKER_ENV_PATH" | cut -d'=' -f2)
if [ -n "$saved_stack_name" ]; then
stack_name=$saved_stack_name
return 1
fi
fi
return 0
}
# Function to get stack name (either from env or user input)
function getStackName() {
read -p "Enter stack name [plane]: " input_stack_name
if [ -z "$input_stack_name" ]; then
input_stack_name="plane"
fi
stack_name=$input_stack_name
updateEnvFile "STACK_NAME" "$stack_name" "$DOCKER_ENV_PATH"
echo "Using stack name: $stack_name"
}
function syncEnvFile(){
echo "Syncing environment variables..." >&2
if [ -f "$PLANE_INSTALL_DIR/plane.env.bak" ]; then
# READ keys of plane.env and update the values from plane.env.bak
while IFS= read -r line
do
# ignore if the line is empty or starts with #
if [ -z "$line" ] || [[ $line == \#* ]]; then
continue
fi
key=$(echo "$line" | cut -d'=' -f1)
value=$(getEnvValue "$key" "$PLANE_INSTALL_DIR/plane.env.bak")
if [ -n "$value" ]; then
updateEnvFile "$key" "$value" "$DOCKER_ENV_PATH"
fi
done < "$DOCKER_ENV_PATH"
value=$(getEnvValue "STACK_NAME" "$PLANE_INSTALL_DIR/plane.env.bak")
if [ -n "$value" ]; then
updateEnvFile "STACK_NAME" "$value" "$DOCKER_ENV_PATH"
fi
fi
echo "Environment variables synced successfully" >&2
rm -f $PLANE_INSTALL_DIR/plane.env.bak
}
function getEnvValue() {
local key=$1
local file=$2
if [ -z "$key" ] || [ -z "$file" ]; then
echo "Invalid arguments supplied"
exit 1
fi
if [ -f "$file" ]; then
grep -q "^$key=" "$file"
if [ $? -eq 0 ]; then
local value
value=$(grep "^$key=" "$file" | cut -d'=' -f2)
echo "$value"
else
echo ""
fi
fi
}
function updateEnvFile() {
local key=$1
local value=$2
local file=$3
if [ -z "$key" ] || [ -z "$value" ] || [ -z "$file" ]; then
echo "Invalid arguments supplied"
exit 1
fi
if [ -f "$file" ]; then
# check if key exists in the file
grep -q "^$key=" "$file"
if [ $? -ne 0 ]; then
echo "$key=$value" >> "$file"
return
else
if [ "$OS_NAME" == "Darwin" ]; then
value=$(echo "$value" | sed 's/|/\\|/g')
sed -i '' "s|^$key=.*|$key=$value|g" "$file"
else
sed -i "s/^$key=.*/$key=$value/g" "$file"
fi
fi
else
echo "File not found: $file"
exit 1
fi
}
function download() {
cd $SCRIPT_DIR || exit 1
TS=$(date +%s)
if [ -f "$PLANE_INSTALL_DIR/docker-compose.yml" ]
then
mv $PLANE_INSTALL_DIR/docker-compose.yml $PLANE_INSTALL_DIR/archive/$TS.docker-compose.yml
fi
echo $RELEASE_DOWNLOAD_URL
echo $FALLBACK_DOWNLOAD_URL
echo $APP_RELEASE
RESPONSE=$(curl -H 'Cache-Control: no-cache, no-store' -s -w "HTTPSTATUS:%{http_code}" "$RELEASE_DOWNLOAD_URL/$APP_RELEASE/docker-compose.yml?$(date +%s)")
BODY=$(echo "$RESPONSE" | sed -e 's/HTTPSTATUS\:.*//g')
STATUS=$(echo "$RESPONSE" | tr -d '\n' | sed -e 's/.*HTTPSTATUS://')
if [ "$STATUS" -eq 200 ]; then
echo "$BODY" > $PLANE_INSTALL_DIR/docker-compose.yml
else
# Fallback to download from the raw github url
RESPONSE=$(curl -H 'Cache-Control: no-cache, no-store' -s -w "HTTPSTATUS:%{http_code}" "$FALLBACK_DOWNLOAD_URL/docker-compose.yml?$(date +%s)")
BODY=$(echo "$RESPONSE" | sed -e 's/HTTPSTATUS\:.*//g')
STATUS=$(echo "$RESPONSE" | tr -d '\n' | sed -e 's/.*HTTPSTATUS://')
if [ "$STATUS" -eq 200 ]; then
echo "$BODY" > $PLANE_INSTALL_DIR/docker-compose.yml
else
echo "Failed to download docker-compose.yml. HTTP Status: $STATUS"
echo "URL: $RELEASE_DOWNLOAD_URL/$APP_RELEASE/docker-compose.yml"
mv $PLANE_INSTALL_DIR/archive/$TS.docker-compose.yml $PLANE_INSTALL_DIR/docker-compose.yml
exit 1
fi
fi
RESPONSE=$(curl -H 'Cache-Control: no-cache, no-store' -s -w "HTTPSTATUS:%{http_code}" "$RELEASE_DOWNLOAD_URL/$APP_RELEASE/variables.env?$(date +%s)")
BODY=$(echo "$RESPONSE" | sed -e 's/HTTPSTATUS\:.*//g')
STATUS=$(echo "$RESPONSE" | tr -d '\n' | sed -e 's/.*HTTPSTATUS://')
if [ "$STATUS" -eq 200 ]; then
echo "$BODY" > $PLANE_INSTALL_DIR/variables-upgrade.env
else
# Fallback to download from the raw github url
RESPONSE=$(curl -H 'Cache-Control: no-cache, no-store' -s -w "HTTPSTATUS:%{http_code}" "$FALLBACK_DOWNLOAD_URL/variables.env?$(date +%s)")
BODY=$(echo "$RESPONSE" | sed -e 's/HTTPSTATUS\:.*//g')
STATUS=$(echo "$RESPONSE" | tr -d '\n' | sed -e 's/.*HTTPSTATUS://')
if [ "$STATUS" -eq 200 ]; then
echo "$BODY" > $PLANE_INSTALL_DIR/variables-upgrade.env
else
echo "Failed to download variables.env. HTTP Status: $STATUS"
echo "URL: $RELEASE_DOWNLOAD_URL/$APP_RELEASE/variables.env"
mv $PLANE_INSTALL_DIR/archive/$TS.docker-compose.yml $PLANE_INSTALL_DIR/docker-compose.yml
exit 1
fi
fi
if [ -f "$DOCKER_ENV_PATH" ];
then
cp "$DOCKER_ENV_PATH" "$PLANE_INSTALL_DIR/archive/$TS.env"
cp "$DOCKER_ENV_PATH" "$PLANE_INSTALL_DIR/plane.env.bak"
fi
mv $PLANE_INSTALL_DIR/variables-upgrade.env $DOCKER_ENV_PATH
syncEnvFile
updateEnvFile "APP_RELEASE" "$APP_RELEASE" "$DOCKER_ENV_PATH"
}
function deployStack() {
# Check if docker compose file and env file exist
if [ ! -f "$DOCKER_FILE_PATH" ] || [ ! -f "$DOCKER_ENV_PATH" ]; then
echo "Configuration files not found"
echo "Downloading it now......"
APP_RELEASE=$(checkLatestRelease)
download
fi
if [ -z "$stack_name" ]; then
getStackName
fi
echo "Starting ${stack_name} stack..."
# Pull envs
if [ -f "$DOCKER_ENV_PATH" ]; then
set -o allexport; source $DOCKER_ENV_PATH; set +o allexport;
else
echo "Environment file not found: $DOCKER_ENV_PATH"
exit 1
fi
# Deploy the stack
docker stack deploy -c $DOCKER_FILE_PATH $stack_name
echo "Waiting for services to be deployed..."
sleep 10
# Check migrator service
local migrator_service=$(docker service ls --filter name=${stack_name}_migrator -q)
if [ -n "$migrator_service" ]; then
echo ">> Waiting for Data Migration to finish"
while docker service ls --filter name=${stack_name}_migrator | grep -q "running"; do
echo -n "."
sleep 1
done
echo ""
# Get the most recent container for the migrator service
local migrator_container=$(docker ps -a --filter name=${stack_name}_migrator --latest -q)
if [ -n "$migrator_container" ]; then
# Get the exit code of the container
local exit_code=$(docker inspect --format='{{.State.ExitCode}}' $migrator_container)
if [ "$exit_code" != "0" ]; then
echo "Server failed to start ❌"
echo "Migration failed with exit code: $exit_code"
echo "Please check the logs for the 'migrator' service and resolve the issue(s)."
echo "Stop the services by running the command: ./swarm.sh stop"
exit 1
else
echo " Data Migration completed successfully ✅"
fi
else
echo "Warning: Could not find migrator container to check exit status"
fi
fi
# Check API service
local api_service=$(docker service ls --filter name=${stack_name}_api -q)
while docker service ls --filter name=${stack_name}_api | grep -q "running"; do
local running_container=$(docker ps --filter "name=${stack_name}_api" --filter "status=running" -q)
if [ -n "$running_container" ]; then
if docker container logs $running_container 2>/dev/null | grep -q "Application Startup Complete"; then
break
fi
fi
sleep 2
done
if [ -z "$api_service" ]; then
echo "Plane Server failed to start ❌"
echo "Please check the logs for the 'api' service and resolve the issue(s)."
echo "Stop the services by running the command: ./swarm.sh stop"
exit 1
fi
echo " Plane Server started successfully ✅"
echo ""
echo " You can access the application at $WEB_URL"
echo ""
}
function removeStack() {
if [ -z "$stack_name" ]; then
echo "Stack name not found"
exit 1
fi
echo "Removing ${stack_name} stack..."
docker stack rm "$stack_name"
echo "Waiting for services to be removed..."
while docker stack ls | grep -q "$stack_name"; do
sleep 1
done
sleep 20
echo "Services stopped successfully ✅"
}
function viewStatus() {
echo "Checking status of ${stack_name} stack..."
if [ -z "$stack_name" ]; then
echo "Stack name not found"
exit 1
fi
docker stack ps "$stack_name"
}
function redeployStack() {
removeStack
echo "ReDeploying ${stack_name} stack..."
deployStack
}
function upgrade() {
echo "Checking status of ${stack_name} stack..."
if [ -z "$stack_name" ]; then
echo "Stack name not found"
exit 1
fi
local latest_release=$(checkLatestRelease)
echo ""
echo "Current release: $APP_RELEASE"
if [ "$latest_release" == "$APP_RELEASE" ]; then
echo ""
echo "You are already using the latest release"
exit 0
fi
echo "Latest release: $latest_release"
echo ""
# Check for confirmation to upgrade
echo "Do you want to upgrade to the latest release ($latest_release)?"
read -p "Continue? [y/N]: " confirm
if [[ ! "$confirm" =~ ^[Yy]$ ]]; then
echo "Exiting..."
exit 0
fi
export APP_RELEASE=$latest_release
# check if stack exists
echo "Upgrading ${stack_name} stack..."
# check env file and take backup
if [ -f "$DOCKER_ENV_PATH" ]; then
cp "$DOCKER_ENV_PATH" "${DOCKER_ENV_PATH}.bak"
fi
download
redeployStack
}
function viewSpecificLogs() {
local service=$1
# Input validation
if [ -z "$service" ]; then
echo "Error: Please specify a service name"
return 1
fi
# Main loop for service logs
while true; do
# Get all running containers for the service
local running_containers=$(docker ps --filter "name=${stack_name}_${service}" --filter "status=running" -q)
# If no running containers found, try service logs
if [ -z "$running_containers" ]; then
echo "No running containers found for ${stack_name}_${service}, checking service logs..."
if docker service inspect ${stack_name}_${service} >/dev/null 2>&1; then
echo "Press Ctrl+C or 'q' to exit logs"
docker service logs ${stack_name}_${service} -f
break
else
echo "Error: No running containers or services found for ${stack_name}_${service}"
return 1
fi
return
fi
# If multiple containers are running, let user choose
if [ $(echo "$running_containers" | grep -v '^$' | wc -l) -gt 1 ]; then
clear
echo "Multiple containers found for ${stack_name}_${service}:"
local i=1
# Use regular arrays instead of associative arrays
container_ids=()
container_names=()
while read -r container_id; do
if [ -n "$container_id" ]; then
local container_name=$(docker inspect --format '{{.Name}}' "$container_id" | sed 's/\///')
container_ids[$i]=$container_id
container_names[$i]=$container_name
echo "[$i] ${container_names[$i]} (${container_ids[$i]})"
i=$((i+1))
fi
done <<< "$running_containers"
echo -e "\nPlease select a container number:"
read -r selection
if [[ "$selection" =~ ^[0-9]+$ ]] && [ -n "${container_ids[$selection]}" ]; then
local selected_container=${container_ids[$selection]}
clear
echo "Showing logs for container: ${container_names[$selection]}"
echo "Press Ctrl+C or 'q' to return to container selection"
# Start watching logs in the background
docker container logs -f "$selected_container" &
local log_pid=$!
while true; do
read -r -n 1 input
if [[ $input == "q" ]]; then
kill $log_pid 2>/dev/null
wait $log_pid 2>/dev/null
break
fi
done
clear
else
echo "Error: Invalid selection"
sleep 2
fi
else
# Single container case
local container_name=$(docker inspect --format '{{.Name}}' "$running_containers" | sed 's/\///')
echo "Showing logs for container: $container_name"
echo "Press Ctrl+C or 'q' to exit logs"
docker container logs -f "$running_containers" &
local log_pid=$!
while true; do
read -r -n 1 input
if [[ $input == "q" ]]; then
kill $log_pid 2>/dev/null
wait $log_pid 2>/dev/null
break
fi
done
break
fi
done
}
function viewLogs(){
ARG_SERVICE_NAME=$2
if [ -z "$ARG_SERVICE_NAME" ];
then
echo
echo "Select a Service you want to view the logs for:"
echo " 1) Web"
echo " 2) Space"
echo " 3) API"
echo " 4) Worker"
echo " 5) Beat-Worker"
echo " 6) Migrator"
echo " 7) Proxy"
echo " 8) Redis"
echo " 9) Postgres"
echo " 10) Minio"
echo " 11) RabbitMQ"
echo " 0) Back to Main Menu"
echo
read -p "Service: " DOCKER_SERVICE_NAME
until (( DOCKER_SERVICE_NAME >= 0 && DOCKER_SERVICE_NAME <= 11 )); do
echo "Invalid selection. Please enter a number between 0 and 11."
read -p "Service: " DOCKER_SERVICE_NAME
done
if [ -z "$DOCKER_SERVICE_NAME" ];
then
echo "INVALID SERVICE NAME SUPPLIED"
else
case $DOCKER_SERVICE_NAME in
1) viewSpecificLogs "web";;
2) viewSpecificLogs "space";;
3) viewSpecificLogs "api";;
4) viewSpecificLogs "worker";;
5) viewSpecificLogs "beat-worker";;
6) viewSpecificLogs "migrator";;
7) viewSpecificLogs "proxy";;
8) viewSpecificLogs "plane-redis";;
9) viewSpecificLogs "plane-db";;
10) viewSpecificLogs "plane-minio";;
11) viewSpecificLogs "plane-mq";;
0) askForAction;;
*) echo "INVALID SERVICE NAME SUPPLIED";;
esac
fi
elif [ -n "$ARG_SERVICE_NAME" ];
then
ARG_SERVICE_NAME=$(echo "$ARG_SERVICE_NAME" | tr '[:upper:]' '[:lower:]')
case $ARG_SERVICE_NAME in
web) viewSpecificLogs "web";;
space) viewSpecificLogs "space";;
api) viewSpecificLogs "api";;
worker) viewSpecificLogs "worker";;
beat-worker) viewSpecificLogs "beat-worker";;
migrator) viewSpecificLogs "migrator";;
proxy) viewSpecificLogs "proxy";;
redis) viewSpecificLogs "plane-redis";;
postgres) viewSpecificLogs "plane-db";;
minio) viewSpecificLogs "plane-minio";;
rabbitmq) viewSpecificLogs "plane-mq";;
*) echo "INVALID SERVICE NAME SUPPLIED";;
esac
else
echo "INVALID SERVICE NAME SUPPLIED"
fi
}
function askForAction() {
# Rest of askForAction remains the same but use $stack_name instead of $STACK_NAME
local DEFAULT_ACTION=$1
if [ -z "$DEFAULT_ACTION" ]; then
echo
echo "Select an Action you want to perform:"
echo " 1) Deploy Stack"
echo " 2) Remove Stack"
echo " 3) View Stack Status"
echo " 4) Redeploy Stack"
echo " 5) Upgrade"
echo " 6) View Logs"
echo " 7) Exit"
echo
read -p "Action [3]: " ACTION
until [[ -z "$ACTION" || "$ACTION" =~ ^[1-6]$ ]]; do
echo "$ACTION: invalid selection."
read -p "Action [3]: " ACTION
done
if [ -z "$ACTION" ]; then
ACTION=3
fi
echo
fi
if [ "$ACTION" == "1" ] || [ "$DEFAULT_ACTION" == "deploy" ]; then
deployStack
elif [ "$ACTION" == "2" ] || [ "$DEFAULT_ACTION" == "remove" ]; then
removeStack
elif [ "$ACTION" == "3" ] || [ "$DEFAULT_ACTION" == "status" ]; then
viewStatus
elif [ "$ACTION" == "4" ] || [ "$DEFAULT_ACTION" == "redeploy" ]; then
redeployStack
elif [ "$ACTION" == "5" ] || [ "$DEFAULT_ACTION" == "upgrade" ]; then
upgrade
elif [ "$ACTION" == "6" ] || [ "$DEFAULT_ACTION" == "logs" ]; then
viewLogs "$@"
elif [ "$ACTION" == "7" ] || [ "$DEFAULT_ACTION" == "exit" ]; then
exit 0
else
echo "INVALID ACTION SUPPLIED"
fi
}
# Initialize stack name at script start
if [ -z "$stack_name" ]; then
readStackName
fi
# Sync environment variables
if [ -f "$DOCKER_ENV_PATH" ]; then
DOCKERHUB_USER=$(getEnvValue "DOCKERHUB_USER" "$DOCKER_ENV_PATH")
APP_RELEASE=$(getEnvValue "APP_RELEASE" "$DOCKER_ENV_PATH")
if [ -z "$DOCKERHUB_USER" ]; then
DOCKERHUB_USER=makeplane
updateEnvFile "DOCKERHUB_USER" "$DOCKERHUB_USER" "$DOCKER_ENV_PATH"
fi
if [ -z "$APP_RELEASE" ]; then
APP_RELEASE=stable
updateEnvFile "APP_RELEASE" "$APP_RELEASE" "$DOCKER_ENV_PATH"
fi
fi
# Main execution
print_header
askForAction "$@"
+3
View File
@@ -5,6 +5,9 @@ WEB_REPLICAS=1
SPACE_REPLICAS=1
ADMIN_REPLICAS=1
API_REPLICAS=1
WORKER_REPLICAS=1
BEAT_WORKER_REPLICAS=1
LIVE_REPLICAS=1
NGINX_PORT=80
WEB_URL=http://${APP_DOMAIN}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "live",
"version": "0.24.1",
"version": "0.25.0",
"description": "",
"main": "./src/server.ts",
"private": true,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"repository": "https://github.com/makeplane/plane.git",
"version": "0.24.1",
"version": "0.25.0",
"license": "AGPL-3.0",
"private": true,
"workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/constants",
"version": "0.24.1",
"version": "0.25.0",
"private": true,
"main": "./src/index.ts"
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/editor",
"version": "0.24.1",
"version": "0.25.0",
"description": "Core Editor that powers Plane",
"private": true,
"main": "./dist/index.mjs",
@@ -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,
},
@@ -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"];
@@ -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: {
@@ -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: {
@@ -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: {
+3
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;
};
+17 -17
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,
+44
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 */
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@plane/eslint-config",
"private": true,
"version": "0.24.1",
"version": "0.25.0",
"files": [
"library.js",
"next.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/hooks",
"version": "0.24.1",
"version": "0.25.0",
"description": "React hooks that are shared across multiple apps internally",
"private": true,
"main": "./dist/index.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/i18n",
"version": "0.24.1",
"version": "0.25.0",
"description": "I18n shared across multiple apps internally",
"private": true,
"main": "./src/index.ts",
+2
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";
+18 -4
View File
@@ -287,7 +287,7 @@
"Cancel": "Cancel",
"edit": "Edit",
"archive": "Archive",
"restor": "Restore",
"restore": "Restore",
"open_in_new_tab": "Open in new tab",
"delete": "Delete",
"deleting": "Deleting",
@@ -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",
@@ -613,6 +618,7 @@
"open_in_new_tab": "Open in new tab",
"copy_link": "Copy link",
"archive": "Archive",
"restore": "Restore",
"delete": "Delete",
"remove_relation": "Remove relation",
"subscribe": "Subscribe",
@@ -655,8 +661,6 @@
"select": "Select",
"upgrade": "Upgrade",
"add_seats": "Add Seats",
"label": "Label",
"priorities": "Priorities",
"projects": "Projects",
"workspace": "Workspace",
"workspaces": "Workspaces",
@@ -675,6 +679,16 @@
"disconnecting": "Disconnecting",
"installing": "Installing",
"install": "Install",
"reset": "Reset",
"live": "Live",
"change_history": "Change History",
"coming_soon": "Coming soon",
"members": "Members",
"you": "You",
"upgrade_cta": {
"higher_subscription": "Upgrade to higher subscription",
"talk_to_sales": "Talk to sales"
},
"category": "Category",
"categories": "Categories",
"saving": "Saving",
+19 -4
View File
@@ -215,6 +215,7 @@
"activity": "Actividad",
"appearance": "Apariencia",
"notifications": "Notificaciones",
"connections": "Conexiones",
"workspaces": "Espacios de trabajo",
"create_workspace": "Crear espacio de trabajo",
"invitations": "Invitaciones",
@@ -291,6 +292,7 @@
"workspace_logo": "Logo del espacio de trabajo",
"new_issue": "Nuevo elemento de trabajo",
"your_work": "Tu trabajo",
"workspace_dashboards": "Paneles de control",
"drafts": "Borradores",
"projects": "Proyectos",
"views": "Vistas",
@@ -457,7 +459,7 @@
"Cancel": "Cancelar",
"edit": "Editar",
"archive": "Archivar",
"restor": "Restaurar",
"restore": "Restaurar",
"open_in_new_tab": "Abrir en nueva pestaña",
"delete": "Eliminar",
"deleting": "Eliminando",
@@ -631,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",
@@ -639,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",
@@ -687,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",
@@ -825,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",
@@ -845,6 +850,16 @@
"disconnecting": "Desconectando",
"installing": "Instalando",
"install": "Instalar",
"reset": "Reiniciar",
"live": "En vivo",
"change_history": "Historial de cambios",
"coming_soon": "Próximamente",
"members": "Miembros",
"you": "Tú",
"upgrade_cta": {
"higher_subscription": "Mejorar a una suscripción más alta",
"talk_to_sales": "Hablar con ventas"
},
"category": "Categoría",
"categories": "Categorías",
"saving": "Guardando",
+17 -4
View File
@@ -457,7 +457,7 @@
"Cancel": "Annuler",
"edit": "Modifier",
"archive": "Archiver",
"restor": "Restaurer",
"restore": "Restaurer",
"open_in_new_tab": "Ouvrir dans un nouvel onglet",
"delete": "Supprimer",
"deleting": "Suppression",
@@ -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",
@@ -845,6 +848,16 @@
"disconnecting": "Déconnexion",
"installing": "Installation",
"install": "Installer",
"reset": "Réinitialiser",
"live": "En direct",
"change_history": "Historique des modifications",
"coming_soon": "À venir",
"members": "Membres",
"you": "Vous",
"upgrade_cta": {
"higher_subscription": "Passer à une abonnement plus élevé",
"talk_to_sales": "Parler à la vente"
},
"category": "Catégorie",
"categories": "Catégories",
"saving": "Enregistrement",
File diff suppressed because it is too large Load Diff
+17 -4
View File
@@ -457,7 +457,7 @@
"Cancel": "キャンセル",
"edit": "編集",
"archive": "アーカイブ",
"restor": "復元",
"restore": "復元",
"open_in_new_tab": "新しいタブで開く",
"delete": "削除",
"deleting": "削除中",
@@ -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": "ワークスペース",
@@ -845,6 +848,16 @@
"disconnecting": "切断中",
"installing": "インストール中",
"install": "インストール",
"reset": "リセット",
"live": "ライブ",
"change_history": "変更履歴",
"coming_soon": "近日公開",
"members": "メンバー",
"you": "あなた",
"upgrade_cta": {
"higher_subscription": "高いサブスクリプションにアップグレード",
"talk_to_sales": "セールスに連絡"
},
"category": "カテゴリー",
"categories": "カテゴリーズ",
"saving": "セービング",
File diff suppressed because it is too large Load Diff
@@ -457,7 +457,7 @@
"Cancel": "取消",
"edit": "编辑",
"archive": "归档",
"restor": "恢复",
"restore": "恢复",
"open_in_new_tab": "在新标签页中打开",
"delete": "删除",
"deleting": "删除中",
@@ -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": "工作区",
@@ -845,6 +848,16 @@
"disconnecting": "正在断开连接",
"installing": "正在安装",
"install": "安装",
"reset": "重置",
"live": "实时",
"change_history": "变更历史",
"coming_soon": "即将推出",
"members": "成员",
"you": "你",
"upgrade_cta": {
"higher_subscription": "升级到更高订阅",
"talk_to_sales": "联系销售"
},
"category": "类别",
"categories": "类别",
"saving": "保存中",
+4
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}`);
}
+1 -1
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;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/logger",
"version": "0.24.1",
"version": "0.25.0",
"description": "Logger shared across multiple apps internally",
"private": true,
"main": "./src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/propel",
"version": "0.24.1",
"version": "0.25.0",
"private": true,
"scripts": {
"lint": "eslint src --ext .ts,.tsx",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/services",
"version": "0.24.1",
"version": "0.25.0",
"private": true,
"main": "./src/index.ts",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/shared-state",
"version": "0.24.1",
"version": "0.25.0",
"description": "Shared state shared across multiple apps internally",
"private": true,
"main": "./src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/tailwind-config",
"version": "0.24.1",
"version": "0.25.0",
"description": "common tailwind configuration across monorepo",
"main": "tailwind.config.js",
"private": true,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/types",
"version": "0.24.1",
"version": "0.25.0",
"private": true,
"types": "./src/index.d.ts",
"main": "./src/index.d.ts"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/typescript-config",
"version": "0.24.1",
"version": "0.25.0",
"private": true,
"files": [
"base.json",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@plane/ui",
"description": "UI components shared across multiple apps internally",
"private": true,
"version": "0.24.1",
"version": "0.25.0",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
+14 -20
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useRef, useState } from "react";
import { Tooltip2 } from "@blueprintjs/popover2";
import React, { useEffect, useRef, useState } from "react";
// helpers
import { cn } from "../../helpers";
@@ -23,7 +23,6 @@ export type TPosition =
interface ITooltipProps {
tooltipHeading?: string;
tooltipContent: string | React.ReactNode;
jsxContent?: string | React.ReactNode;
position?: TPosition;
children: JSX.Element;
disabled?: boolean;
@@ -39,14 +38,13 @@ export const Tooltip: React.FC<ITooltipProps> = ({
tooltipContent,
position = "top",
children,
jsxContent,
disabled = false,
className = "",
openDelay = 200,
closeDelay,
isMobile = false,
renderByDefault = true, //FIXME: tooltip should always render on hover and not by default, this is a temporary fix
}: ITooltipProps) => {
}) => {
const toolTipRef = useRef<HTMLDivElement | null>(null);
const [shouldRender, setShouldRender] = useState(renderByDefault);
@@ -81,22 +79,18 @@ export const Tooltip: React.FC<ITooltipProps> = ({
hoverOpenDelay={openDelay}
hoverCloseDelay={closeDelay}
content={
jsxContent ? (
<>{jsxContent}</>
) : (
<div
className={cn(
"relative block z-50 max-w-xs gap-1 overflow-hidden break-words rounded-md bg-custom-background-100 p-2 text-xs text-custom-text-200 shadow-md",
{
hidden: isMobile,
},
className
)}
>
{tooltipHeading && <h5 className="font-medium text-custom-text-100">{tooltipHeading}</h5>}
{tooltipContent}
</div>
)
<div
className={cn(
"relative block z-50 max-w-xs gap-1 overflow-hidden break-words rounded-md bg-custom-background-100 p-2 text-xs text-custom-text-200 shadow-md",
{
hidden: isMobile,
},
className
)}
>
{tooltipHeading && <h5 className="font-medium text-custom-text-100">{tooltipHeading}</h5>}
{tooltipContent}
</div>
}
position={position}
renderTarget={({
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/utils",
"version": "0.24.1",
"version": "0.25.0",
"description": "Helper functions shared across multiple apps internally",
"private": true,
"main": "./dist/index.js",
@@ -1,9 +1,9 @@
FROM nginx:1.25.0-alpine
RUN rm /etc/nginx/conf.d/default.conf
COPY nginx.conf.dev /etc/nginx/nginx.conf.template
COPY nginx/nginx.conf.dev /etc/nginx/nginx.conf.template
COPY ./env.sh /docker-entrypoint.sh
COPY nginx/env.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
# Update all environment variables
+2 -2
View File
@@ -1,9 +1,9 @@
FROM nginx:1.25.0-alpine
RUN rm /etc/nginx/conf.d/default.conf
COPY nginx.conf.template /etc/nginx/nginx.conf.template
COPY nginx/nginx.conf.template /etc/nginx/nginx.conf.template
COPY ./env.sh /docker-entrypoint.sh
COPY nginx/env.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
# Update all environment variables
+1
View File
@@ -0,0 +1 @@
nginx.conf.template
+39
View File
@@ -0,0 +1,39 @@
(plane_proxy) {
request_body {
max_size {$FILE_SIZE_LIMIT}
}
reverse_proxy /spaces/* space:3000
reverse_proxy /god-mode/* admin:3000
reverse_proxy /api/* api:8000
reverse_proxy /auth/* api:8000
reverse_proxy /live/* live:3000
reverse_proxy /{$BUCKET_NAME} plane-minio:9000
reverse_proxy /{$BUCKET_NAME}/* plane-minio:9000
reverse_proxy /* web:3000
}
{
email {$CERT_EMAIL:admin@example.com}
acme_ca {$CERT_ACME_CA}
{$CERT_ACME_DNS}
servers {
timeouts {
read_body 600s
read_header 30s
write 600s
idle 600s
}
max_header_size 25MB
client_ip_headers X-Forwarded-For X-Real-IP
trusted_proxies static {$TRUSTED_PROXIES:0.0.0.0/0}
}
log {
output {$LOG_OUTPUT:stdout}
level {$LOG_LEVEL:INFO}
}
}
{$SITE_ADDRESS} {
import plane_proxy
}
View File
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "space",
"version": "0.24.1",
"version": "0.25.0",
"private": true,
"scripts": {
"dev": "turbo run develop",
@@ -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,
@@ -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,
@@ -0,0 +1 @@
export * from "./work-item-actions";
@@ -0,0 +1,43 @@
import { Command } from "cmdk";
import { observer } from "mobx-react";
import { Check } from "lucide-react";
// plane imports
import { Spinner, StateGroupIcon } from "@plane/ui";
// store hooks
import { useProjectState } from "@/hooks/store";
export type TChangeWorkItemStateListProps = {
projectId: string | null;
currentStateId: string | null;
handleStateChange: (stateId: string) => void;
};
export const ChangeWorkItemStateList = observer((props: TChangeWorkItemStateListProps) => {
const { projectId, currentStateId, handleStateChange } = props;
// store hooks
const { getProjectStates } = useProjectState();
// derived values
const projectStates = getProjectStates(projectId);
return (
<>
{projectStates ? (
projectStates.length > 0 ? (
projectStates.map((state) => (
<Command.Item key={state.id} onSelect={() => handleStateChange(state.id)} className="focus:outline-none">
<div className="flex items-center space-x-3">
<StateGroupIcon stateGroup={state.group} color={state.color} height="16px" width="16px" />
<p>{state.name}</p>
</div>
<div>{state.id === currentStateId && <Check className="h-3 w-3" />}</div>
</Command.Item>
))
) : (
<div className="text-center">No states found</div>
)
) : (
<Spinner />
)}
</>
);
});
@@ -0,0 +1 @@
export * from "./change-state-list";
@@ -1,20 +0,0 @@
import { Plus } from "lucide-react";
// plane utils
import { cn } from "@plane/utils";
type Props = {
workspaceSlug: string;
projectId: string;
parentStateId: string;
onTransitionAdd?: () => void;
};
export const AddStateTransition = (props: Props) => (
<div className={cn("flex w-full px-3 h-6 items-center justify-start gap-2 text-sm bg-custom-background-90")}>
<>
<Plus className="h-4 w-4" color="#8591AD" />
<span className="text-custom-text-400 font-medium"> Add Transition</span>
<div className="text-white bg-custom-background-80 font-semibold px-2 rounded-lg">Pro</div>
</>
</div>
);
@@ -1,6 +1,5 @@
export * from "./state-option";
export * from "./state-item-child";
export * from "./state-transition-count";
export * from "./use-workflow-drag-n-drop";
export * from "./workflow-disabled-message";
export * from "./workflow-group-tree";
export * from "./workflow-disabled-overlay";
@@ -1,39 +0,0 @@
import { SetStateAction } from "react";
import { observer } from "mobx-react";
// Plane
import { DISPLAY_WORKFLOW_PRO_CTA } from "@plane/constants";
import { IState } from "@plane/types";
// components
import { StateItemTitle } from "@/components/project-states/state-item-title";
// constants
//
import { AddStateTransition } from "./add-state-transition";
export type StateItemChildProps = {
workspaceSlug: string;
projectId: string;
stateCount: number;
disabled: boolean;
state: IState;
setUpdateStateModal: (value: SetStateAction<boolean>) => void;
};
export const StateItemChild = observer((props: StateItemChildProps) => {
const { workspaceSlug, projectId, stateCount, setUpdateStateModal, disabled, state } = props;
return (
<div className="flex flex-col w-full items-center justify-between">
<StateItemTitle
workspaceSlug={workspaceSlug}
projectId={projectId}
setUpdateStateModal={setUpdateStateModal}
stateCount={stateCount}
disabled={disabled}
state={state}
/>
{DISPLAY_WORKFLOW_PRO_CTA && (
<AddStateTransition workspaceSlug={workspaceSlug} projectId={projectId} parentStateId={state.id} />
)}
</div>
);
});
+5 -3
View File
@@ -2,19 +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 (
@@ -1,7 +0,0 @@
import { IStateWorkFlow } from "@/plane-web/types";
type Props = {
currentTransitionMap?: IStateWorkFlow;
};
export const StateTransitionCount = (props: Props) => <></>;
@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { TIssueGroupByOptions } from "@plane/types";
export const useWorkFlowFDragNDrop = (
@@ -6,6 +7,7 @@ export const useWorkFlowFDragNDrop = (
) => ({
workflowDisabledSource: undefined,
isWorkflowDropDisabled: false,
getIsWorkflowWorkItemCreationDisabled: (groupId: string, subGroupId?: string) => false,
handleWorkFlowState: (
sourceGroupId: string,
destinationGroupId: string,
@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
type Props = {
parentStateId: string;
className?: string;
@@ -0,0 +1,10 @@
import { observer } from "mobx-react";
export type TWorkflowDisabledOverlayProps = {
messageContainerRef: React.RefObject<HTMLDivElement>;
workflowDisabledSource: string;
shouldOverlayBeVisible: boolean;
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const WorkFlowDisabledOverlay = observer((props: TWorkflowDisabledOverlayProps) => <></>);
@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { TIssueGroupByOptions } from "@plane/types";
type Props = {
@@ -0,0 +1,2 @@
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const useWorkspaceIssuePropertiesExtended = (workspaceSlug: string | string[] | undefined) => {};
-1
View File
@@ -1,4 +1,3 @@
export * from "./projects";
export * from "./issue-types";
export * from "./gantt-chart";
export * from "./state.d";
-8
View File
@@ -1,8 +0,0 @@
export interface IStateTransition {
transition_state_id: string;
actors: string[];
}
export interface IStateWorkFlow {
[transitionId: string]: IStateTransition;
}
@@ -1,16 +1,13 @@
"use client";
import { Command } from "cmdk";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// hooks
import { Check } from "lucide-react";
// plane imports
import { TIssue } from "@plane/types";
import { Spinner, StateGroupIcon } from "@plane/ui";
import { useProjectState, useIssueDetail } from "@/hooks/store";
// ui
// icons
// types
// store hooks
import { useIssueDetail } from "@/hooks/store";
// plane web imports
import { ChangeWorkItemStateList } from "@/plane-web/components/command-palette/actions/work-item-actions";
type Props = { closePalette: () => void; issue: TIssue };
@@ -20,10 +17,9 @@ export const ChangeIssueState: React.FC<Props> = observer((props) => {
const { workspaceSlug } = useParams();
// store hooks
const { updateIssue } = useIssueDetail();
const { getProjectStates } = useProjectState();
// derived values
const projectId = issue?.project_id;
const projectStates = getProjectStates(projectId);
const currentStateId = issue?.state_id;
const submitChanges = async (formData: Partial<TIssue>) => {
if (!workspaceSlug || !projectId || !issue) return;
@@ -40,24 +36,10 @@ export const ChangeIssueState: React.FC<Props> = observer((props) => {
};
return (
<>
{projectStates ? (
projectStates.length > 0 ? (
projectStates.map((state) => (
<Command.Item key={state.id} onSelect={() => handleIssueState(state.id)} className="focus:outline-none">
<div className="flex items-center space-x-3">
<StateGroupIcon stateGroup={state.group} color={state.color} height="16px" width="16px" />
<p>{state.name}</p>
</div>
<div>{state.id === issue.state_id && <Check className="h-3 w-3" />}</div>
</Command.Item>
))
) : (
<div className="text-center">No states found</div>
)
) : (
<Spinner />
)}
</>
<ChangeWorkItemStateList
projectId={projectId}
currentStateId={currentStateId}
handleStateChange={handleIssueState}
/>
);
});
@@ -29,15 +29,17 @@ export const ActivityBlockComponent: FC<TActivityBlockComponent> = (props) => {
if (!activity) return <></>;
return (
<div
className={`relative flex items-center gap-2 text-xs ${
className={`relative flex items-start gap-2 text-xs ${
ends === "top" ? `pb-3` : ends === "bottom" ? `pt-3` : `py-3`
}`}
>
<div className="flex-shrink-0 ring-6 w-7 h-7 rounded-full overflow-hidden flex justify-center items-start mt-0.5 z-[4] text-custom-text-200">
{icon ? icon : <Network className="w-3.5 h-3.5" />}
</div>
<div className="w-full truncate text-custom-text-200">
<User activity={activity} customUserName={customUserName} /> {children}
<div className="w-full text-custom-text-200">
<div className="line-clamp-2">
<User activity={activity} customUserName={customUserName} /> {children}
</div>
<div className="mt-1">
<Tooltip
isMobile={isMobile}
@@ -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",
@@ -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,
}}
/>
)}
+1 -1
View File
@@ -134,7 +134,7 @@ export const CycleQuickActions: React.FC<Props> = observer((props) => {
{
key: "restore",
action: handleRestoreCycle,
title: t("restor"),
title: t("restore"),
icon: ArchiveRestoreIcon,
shouldRender: isEditingAllowed && isArchived,
},
+2 -7
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,12 +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,
});
val?.from && val?.to && handleClose();
onSelect?.(val);
}}
mode="range"
disabled={disabledDays}
+5 -5
View File
@@ -35,6 +35,8 @@ type Props = TDropdownProps & {
renderByDefault?: boolean;
stateIds?: string[];
filterAvailableStateIds?: boolean;
isForWorkItemCreation?: boolean;
alwaysAllowStateChange?: boolean;
};
export const StateDropdown: React.FC<Props> = observer((props) => {
@@ -58,7 +60,6 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
value,
renderByDefault = true,
stateIds,
filterAvailableStateIds = true,
} = props;
// states
const [query, setQuery] = useState("");
@@ -98,7 +99,7 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
content: (
<div className="flex items-center gap-2">
<StateGroupIcon stateGroup={state?.group ?? "backlog"} color={state?.color} className="h-3 w-3 flex-shrink-0" />
<span className="flex-grow truncate">{state?.name}</span>
<span className="flex-grow truncate text-left">{state?.name}</span>
</div>
),
}));
@@ -182,7 +183,7 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
/>
)}
{BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && (
<span className="flex-grow truncate">{selectedState?.name ?? t("state")}</span>
<span className="flex-grow truncate text-left">{selectedState?.name ?? t("state")}</span>
)}
{dropdownArrow && (
<ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
@@ -233,10 +234,9 @@ 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"
/>

Some files were not shown because too many files have changed in this diff Show More