Compare commits

...

38 Commits

Author SHA1 Message Date
Aaryan Khandelwal d52b7475be chore: add sync for page access cahnge 2024-12-16 16:35:44 +05:30
Aaryan Khandelwal 383fb56361 fix: merge conflicts resolved from preview 2024-12-16 13:29:18 +05:30
Nikhil 74b2ec03ff feat: add language support (#6205) 2024-12-15 11:04:03 +05:30
guru_sainath 5908998127 [WEB-2854] chore: trigger issue_description_version task on issue create and update (#6202)
* chore: issue description version task trigger from issue create and update

* chore: add default value in prop
2024-12-13 22:30:29 +05:30
guru_sainath df6a80e7ae chore: add sync jobs for issue_version and issue_description_version tables (#6199)
* chore: added fields in issue_version and profile tables and created a new sticky table

* chore: removed point in issue version

* chore: add imports in init

* chore: added sync jobs for issue_version and issue_description_version

* chore: removed logs

* chore: updated logginh

---------

Co-authored-by: sainath <sainath@sainaths-MacBook-Pro.local>
2024-12-13 17:48:55 +05:30
guru_sainath 6ff258ceca chore: Add fields to issue_version and profile tables, and create new sticky table (#6198)
* chore: added fields in issue_version and profile tables and created a new sticky table

* chore: removed point in issue version

* chore: add imports in init

---------

Co-authored-by: sainath <sainath@sainaths-MacBook-Pro.local>
2024-12-13 17:30:25 +05:30
Saurabhkmr98 a8140a5f08 chore: Add logger package for node server side apps (#6188)
* chore: Add logger as a package

* chore: Add logger package for node server side apps

* remove plane logger import in web

* resolve pr reviews and add client logger with readme update

* fix: transformation and added middleware for logging requests

* chore: update readme

* fix: env configurable max file size

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2024-12-13 14:32:56 +05:30
Prateek Shourya 9234f21f26 [WEB-2848] improvement: enhanced components modularity (#6196)
* improvement: enhanced componenets modularity

* fix: lint errors resolved
2024-12-13 14:26:26 +05:30
Bavisetti Narayan ab11e83535 [WEB-2843] chore: updated the cycle end date logic (#6194)
* chore: updated the cycle end date logic

* chore: changed the key for timezone
2024-12-13 13:34:07 +05:30
Akshita Goyal b4112358ac [WEB-2688] chore: added icons and splitted issue header (#6195)
* chore: added icons and splitted issue header

* fix: added ee filler component

* fix: component name fixed

* fix: removed dupes

* fix: casing
2024-12-13 13:31:13 +05:30
Aaryan Khandelwal 77239ebcd4 fix: GitHub casing across the platform (#6193) 2024-12-13 02:22:46 +05:30
Prateek Shourya 54f828cbfa refactor: enhance components modularity and introduce new UI componenets (#6192)
* feat: add navigation dropdown component

* chore: enhance title/ description loader and componenet modularity

* chore: issue store filter update

* chore: added few icons to ui package

* chore: improvements for tabs componenet

* chore: enhance sidebar modularity

* chore: update issue and router store to add support for additional issue layouts

* chore: enhanced cycle componenets modularity

* feat: added project grouping header for cycles list

* chore: enhanced project dropdown componenet by adding multiple selection functionality

* chore: enhanced rich text editor modularity by taking members ids as props for mentions

* chore: added functionality to filter disabled layouts in issue-layout dropdown

* chore: added support to pass project ids as props in project card list

* feat: multi select project modal

* chore: seperate out project componenet for reusability

* chore: command pallete store improvements

* fix: build errors
2024-12-12 21:40:57 +05:30
Bavisetti Narayan 9ad8b43408 chore: handled the cycle date time using project timezone (#6187)
* chore: handled the cycle date time using project timezone

* chore: reverted the frontend commit
2024-12-12 14:11:12 +05:30
Prateek Shourya 38e8a5c807 fix: command palette build (#6186) 2024-12-11 18:19:09 +05:30
Prateek Shourya a9bd2e243a refactor: enhance command palette modularity (#6139)
* refactor: enhance command palette modularity

* chore: minor updates to command palette store
2024-12-11 18:02:58 +05:30
Vamsi Krishna ca0d50b229 fix: no activity while moving inbox issues (#6185) 2024-12-11 17:57:27 +05:30
Vamsi Krishna 7fca7fd86c [WEB-2774] fix:favorites reorder (#6179)
* fix:favorites reorder

* chore: added error handling

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2024-12-11 16:29:39 +05:30
Prateek Shourya 0ac68f2731 improvement: refactored issue grouping logic to access MobX store directly (#6134)
* improvement: refactored issue grouping logic to access MobX store directly

* chore: minor updates
2024-12-11 15:14:15 +05:30
rahulramesha 5a9ae66680 chore: Remove shouldIgnoreDependencies flags while dragging in timeline view (#6150)
* remove shouldEnable dependency flags for timeline view

* chore: error handling

---------

Co-authored-by: Prateek Shourya <prateekshourya29@gmail.com>
2024-12-11 13:43:48 +05:30
Vamsi Krishna 134644fdf1 [WEB-2382]chore:notification files restructuring (#6181)
* chore: adjusted  increment/decrement  for unread count

* chore: improved param handling for unread notification count function

* chore:file restructuring

* fix:notification types

* chore:file restructuring

* chore:modified notfication types

* chore: modified types for notification

* chore:removed redundant checks for id
2024-12-11 13:41:19 +05:30
sriram veeraghanta d0f3987aeb fix: instance changelog url updated 2024-12-10 21:03:44 +05:30
sriram veeraghanta f06b1b8c4a fix: updated package version 2024-12-10 21:02:29 +05:30
sriram veeraghanta 6e56ea4c60 fix: updated changelog url in apiserver 2024-12-10 20:28:51 +05:30
Anmol Singh Bhatia 216a69f991 chore: workspace draft and inbox issue local db mutation (#6180) 2024-12-10 19:12:24 +05:30
Vihar Kurama 205395e079 fix: changed checkboxes to toggles on notifications settings page (#6175) 2024-12-10 01:02:34 +05:30
Aaryan Khandelwal 3b6892d42a fix: merge conflicts resolved from preview 2024-11-22 14:23:14 +05:30
Aaryan Khandelwal 8806a67d08 fix: auth for access change and delete 2024-11-21 15:10:44 +05:30
Aaryan Khandelwal 9bcbf2466d chore: add type assertion 2024-11-21 15:04:19 +05:30
Aaryan Khandelwal f0a41bdd14 chore: use enum for page access 2024-11-21 14:18:51 +05:30
Aaryan Khandelwal 84acc608cc Merge branch 'preview' of https://github.com/makeplane/plane into dev/page-options 2024-11-21 14:16:55 +05:30
Aaryan Khandelwal c060024919 chore: memoize arranged options 2024-11-21 14:16:48 +05:30
Aaryan Khandelwal 436e4aca73 chore: add permisssions to duplicate page endpoint 2024-11-20 13:47:05 +05:30
Aaryan Khandelwal 8416b48daf refactor: remove unnecessary props 2024-11-20 13:28:54 +05:30
Aaryan Khandelwal 1314d3dd9c dev: hook to get page operations 2024-11-20 13:22:36 +05:30
Aaryan Khandelwal 835440f3e0 fix: type errors 2024-11-20 13:07:10 +05:30
Aaryan Khandelwal bb00042bff chore: add customizable page actions 2024-11-20 13:03:27 +05:30
Aaryan Khandelwal 9ceb91c207 refactor: page quick actions 2024-11-18 17:33:15 +05:30
Aaryan Khandelwal d238bac387 dev: support for edition specific options in pages 2024-11-14 12:55:27 +05:30
236 changed files with 5905 additions and 3291 deletions
+3 -3
View File
@@ -44,7 +44,7 @@ const InstanceGithubAuthenticationPage = observer(() => {
loading: "Saving Configuration...",
success: {
title: "Configuration saved",
message: () => `Github authentication is now ${value ? "active" : "disabled"}.`,
message: () => `GitHub authentication is now ${value ? "active" : "disabled"}.`,
},
error: {
title: "Error",
@@ -67,8 +67,8 @@ const InstanceGithubAuthenticationPage = observer(() => {
<div className="relative container mx-auto w-full h-full p-4 py-4 space-y-6 flex flex-col">
<div className="border-b border-custom-border-100 mx-4 py-4 space-y-1 flex-shrink-0">
<AuthenticationMethodCard
name="Github"
description="Allow members to login or sign up to plane with their Github accounts."
name="GitHub"
description="Allow members to login or sign up to plane with their GitHub accounts."
icon={
<Image
src={resolveGeneralTheme(resolvedTheme) === "dark" ? githubDarkModeImage : githubLightModeImage}
+1 -1
View File
@@ -30,7 +30,7 @@ export const InstanceHeader: FC = observer(() => {
case "google":
return "Google";
case "github":
return "Github";
return "GitHub";
case "gitlab":
return "GitLab";
case "workspace":
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "admin",
"version": "0.24.0",
"version": "0.24.1",
"private": true,
"scripts": {
"dev": "turbo run develop",
+1 -1
View File
@@ -4,7 +4,7 @@ FROM python:3.12.5-alpine AS backend
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
ENV INSTANCE_CHANGELOG_URL https://api.plane.so/api/public/anchor/8e1c2e4c7bc5493eb7731be3862f6960/pages/
ENV INSTANCE_CHANGELOG_URL https://sites.plane.so/pages/691ef037bcfe416a902e48cb55f59891/
WORKDIR /code
+1 -1
View File
@@ -4,7 +4,7 @@ FROM python:3.12.5-alpine AS backend
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
ENV INSTANCE_CHANGELOG_URL https://api.plane.so/api/public/anchor/8e1c2e4c7bc5493eb7731be3862f6960/pages/
ENV INSTANCE_CHANGELOG_URL https://sites.plane.so/pages/691ef037bcfe416a902e48cb55f59891/
RUN apk --no-cache add \
"bash~=5.2" \
+1 -1
View File
@@ -1,4 +1,4 @@
{
"name": "plane-api",
"version": "0.24.0"
"version": "0.24.1"
}
+13 -1
View File
@@ -4,7 +4,7 @@ from rest_framework import serializers
# Module imports
from .base import BaseSerializer
from plane.db.models import Cycle, CycleIssue
from plane.utils.timezone_converter import convert_to_utc
class CycleSerializer(BaseSerializer):
total_issues = serializers.IntegerField(read_only=True)
@@ -24,6 +24,18 @@ class CycleSerializer(BaseSerializer):
and data.get("start_date", None) > data.get("end_date", None)
):
raise serializers.ValidationError("Start date cannot exceed end date")
if (
data.get("start_date", None) is not None
and data.get("end_date", None) is not None
):
project_id = self.initial_data.get("project_id") or self.instance.project_id
data["start_date"] = convert_to_utc(
str(data.get("start_date").date()), project_id, is_start_date=True
)
data["end_date"] = convert_to_utc(
str(data.get("end_date", None).date()), project_id
)
return data
class Meta:
+12
View File
@@ -5,6 +5,7 @@ from rest_framework import serializers
from .base import BaseSerializer
from .issue import IssueStateSerializer
from plane.db.models import Cycle, CycleIssue, CycleUserProperties
from plane.utils.timezone_converter import convert_to_utc
class CycleWriteSerializer(BaseSerializer):
@@ -15,6 +16,17 @@ class CycleWriteSerializer(BaseSerializer):
and data.get("start_date", None) > data.get("end_date", None)
):
raise serializers.ValidationError("Start date cannot exceed end date")
if (
data.get("start_date", None) is not None
and data.get("end_date", None) is not None
):
project_id = self.initial_data.get("project_id") or self.instance.project_id
data["start_date"] = convert_to_utc(
str(data.get("start_date").date()), project_id, is_start_date=True
)
data["end_date"] = convert_to_utc(
str(data.get("end_date", None).date()), project_id
)
return data
class Meta:
+4
View File
@@ -54,6 +54,8 @@ class PageSerializer(BaseSerializer):
labels = validated_data.pop("labels", None)
project_id = self.context["project_id"]
owned_by_id = self.context["owned_by_id"]
description = self.context["description"]
description_binary = self.context["description_binary"]
description_html = self.context["description_html"]
# Get the workspace id from the project
@@ -62,6 +64,8 @@ class PageSerializer(BaseSerializer):
# Create the page
page = Page.objects.create(
**validated_data,
description=description,
description_binary=description_binary,
description_html=description_html,
owned_by_id=owned_by_id,
workspace_id=project.workspace_id,
+6
View File
@@ -8,6 +8,7 @@ from plane.app.views import (
SubPagesEndpoint,
PagesDescriptionViewSet,
PageVersionEndpoint,
PageDuplicateEndpoint,
)
@@ -78,4 +79,9 @@ urlpatterns = [
PageVersionEndpoint.as_view(),
name="page-versions",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/duplicate/",
PageDuplicateEndpoint.as_view(),
name="page-duplicate",
),
]
+1
View File
@@ -155,6 +155,7 @@ from .page.base import (
PageLogEndpoint,
SubPagesEndpoint,
PagesDescriptionViewSet,
PageDuplicateEndpoint,
)
from .page.version import PageVersionEndpoint
+49 -5
View File
@@ -1,5 +1,7 @@
# Python imports
import json
import pytz
# Django imports
from django.contrib.postgres.aggregates import ArrayAgg
@@ -52,6 +54,11 @@ from plane.bgtasks.recent_visited_task import recent_visited_task
# Module imports
from .. import BaseAPIView, BaseViewSet
from plane.bgtasks.webhook_task import model_activity
from plane.utils.timezone_converter import (
convert_utc_to_project_timezone,
convert_to_utc,
user_timezone_converter,
)
class CycleViewSet(BaseViewSet):
@@ -67,6 +74,19 @@ class CycleViewSet(BaseViewSet):
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
)
project = Project.objects.get(id=self.kwargs.get("project_id"))
# Fetch project for the specific record or pass project_id dynamically
project_timezone = project.timezone
# Convert the current time (timezone.now()) to the project's timezone
local_tz = pytz.timezone(project_timezone)
current_time_in_project_tz = timezone.now().astimezone(local_tz)
# Convert project local time back to UTC for comparison (start_date is stored in UTC)
current_time_in_utc = current_time_in_project_tz.astimezone(pytz.utc)
return self.filter_queryset(
super()
.get_queryset()
@@ -119,12 +139,15 @@ class CycleViewSet(BaseViewSet):
.annotate(
status=Case(
When(
Q(start_date__lte=timezone.now())
& Q(end_date__gte=timezone.now()),
Q(start_date__lte=current_time_in_utc)
& Q(end_date__gte=current_time_in_utc),
then=Value("CURRENT"),
),
When(start_date__gt=timezone.now(), then=Value("UPCOMING")),
When(end_date__lt=timezone.now(), then=Value("COMPLETED")),
When(
start_date__gt=current_time_in_utc,
then=Value("UPCOMING"),
),
When(end_date__lt=current_time_in_utc, then=Value("COMPLETED")),
When(
Q(start_date__isnull=True) & Q(end_date__isnull=True),
then=Value("DRAFT"),
@@ -160,10 +183,22 @@ class CycleViewSet(BaseViewSet):
# Update the order by
queryset = queryset.order_by("-is_favorite", "-created_at")
project = Project.objects.get(id=self.kwargs.get("project_id"))
# Fetch project for the specific record or pass project_id dynamically
project_timezone = project.timezone
# Convert the current time (timezone.now()) to the project's timezone
local_tz = pytz.timezone(project_timezone)
current_time_in_project_tz = timezone.now().astimezone(local_tz)
# Convert project local time back to UTC for comparison (start_date is stored in UTC)
current_time_in_utc = current_time_in_project_tz.astimezone(pytz.utc)
# Current Cycle
if cycle_view == "current":
queryset = queryset.filter(
start_date__lte=timezone.now(), end_date__gte=timezone.now()
start_date__lte=current_time_in_utc, end_date__gte=current_time_in_utc
)
data = queryset.values(
@@ -191,6 +226,8 @@ class CycleViewSet(BaseViewSet):
"version",
"created_by",
)
datetime_fields = ["start_date", "end_date"]
data = user_timezone_converter(data, datetime_fields, project_timezone)
if data:
return Response(data, status=status.HTTP_200_OK)
@@ -221,6 +258,8 @@ class CycleViewSet(BaseViewSet):
"version",
"created_by",
)
datetime_fields = ["start_date", "end_date"]
data = user_timezone_converter(data, datetime_fields, request.user.user_timezone)
return Response(data, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
@@ -417,6 +456,8 @@ class CycleViewSet(BaseViewSet):
)
queryset = queryset.first()
datetime_fields = ["start_date", "end_date"]
data = user_timezone_converter(data, datetime_fields, request.user.user_timezone)
recent_visited_task.delay(
slug=slug,
@@ -492,6 +533,9 @@ class CycleDateCheckEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
start_date = convert_to_utc(str(start_date), project_id, is_start_date=True)
end_date = convert_to_utc(str(end_date), project_id)
# Check if any cycle intersects in the given interval
cycles = Cycle.objects.filter(
Q(workspace__slug=slug)
+15 -1
View File
@@ -54,10 +54,11 @@ from plane.utils.issue_filters import issue_filters
from plane.utils.order_queryset import order_issue_queryset
from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPaginator
from .. import BaseAPIView, BaseViewSet
from plane.utils.user_timezone_converter import user_timezone_converter
from plane.utils.timezone_converter import user_timezone_converter
from plane.bgtasks.recent_visited_task import recent_visited_task
from plane.utils.global_paginator import paginate
from plane.bgtasks.webhook_task import model_activity
from plane.bgtasks.issue_description_version_task import issue_description_version_task
class IssueListEndpoint(BaseAPIView):
@@ -428,6 +429,13 @@ class IssueViewSet(BaseViewSet):
slug=slug,
origin=request.META.get("HTTP_ORIGIN"),
)
# updated issue description version
issue_description_version_task.delay(
updated_issue=json.dumps(request.data, cls=DjangoJSONEncoder),
issue_id=str(serializer.data["id"]),
user_id=request.user.id,
is_creating=True,
)
return Response(issue, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -649,6 +657,12 @@ class IssueViewSet(BaseViewSet):
slug=slug,
origin=request.META.get("HTTP_ORIGIN"),
)
# updated issue description version
issue_description_version_task.delay(
updated_issue=current_instance,
issue_id=str(serializer.data.get("id", None)),
user_id=request.user.id,
)
return Response(status=status.HTTP_204_NO_CONTENT)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+1 -1
View File
@@ -20,7 +20,7 @@ from plane.app.serializers import IssueSerializer
from plane.app.permissions import ProjectEntityPermission
from plane.db.models import Issue, IssueLink, FileAsset, CycleIssue
from plane.bgtasks.issue_activities_task import issue_activity
from plane.utils.user_timezone_converter import user_timezone_converter
from plane.utils.timezone_converter import user_timezone_converter
from collections import defaultdict
+1 -1
View File
@@ -28,7 +28,7 @@ from plane.app.permissions import ProjectEntityPermission
from plane.app.serializers import ModuleDetailSerializer
from plane.db.models import Issue, Module, ModuleLink, UserFavorite, Project
from plane.utils.analytics_plot import burndown_plot
from plane.utils.user_timezone_converter import user_timezone_converter
from plane.utils.timezone_converter import user_timezone_converter
# Module imports
+1 -1
View File
@@ -56,7 +56,7 @@ from plane.db.models import (
Project,
)
from plane.utils.analytics_plot import burndown_plot
from plane.utils.user_timezone_converter import user_timezone_converter
from plane.utils.timezone_converter import user_timezone_converter
from plane.bgtasks.webhook_task import model_activity
from .. import BaseAPIView, BaseViewSet
from plane.bgtasks.recent_visited_task import recent_visited_task
+32
View File
@@ -121,6 +121,8 @@ class PageViewSet(BaseViewSet):
context={
"project_id": project_id,
"owned_by_id": request.user.id,
"description": request.data.get("description", {}),
"description_binary": request.data.get("description_binary", None),
"description_html": request.data.get("description_html", "<p></p>"),
},
)
@@ -553,3 +555,33 @@ class PagesDescriptionViewSet(BaseViewSet):
return Response({"message": "Updated successfully"})
else:
return Response({"error": "No binary data provided"})
class PageDuplicateEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def post(self, request, slug, project_id, page_id):
page = Page.objects.filter(
pk=page_id, workspace__slug=slug, projects__id=project_id
).values()
new_page_data = list(page)[0]
new_page_data.name = f"{new_page_data.name} (Copy)"
serializer = PageSerializer(
data=new_page_data,
context={
"project_id": project_id,
"owned_by_id": request.user.id,
"description": new_page_data.description,
"description_binary": new_page_data.description_binary,
"description_html": new_page_data.description_html,
},
)
if serializer.is_valid():
serializer.save()
# capture the page transaction
page_transaction.delay(request.data, None, serializer.data["id"])
page = Page.objects.get(pk=serializer.data["id"])
serializer = PageDetailSerializer(page)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+1 -1
View File
@@ -10,7 +10,7 @@ from plane.app.views.base import BaseAPIView
from plane.db.models import Cycle
from plane.app.permissions import WorkspaceViewerPermission
from plane.app.serializers.cycle import CycleSerializer
from plane.utils.timezone_converter import user_timezone_converter
class WorkspaceCyclesEndpoint(BaseAPIView):
permission_classes = [WorkspaceViewerPermission]
@@ -0,0 +1,125 @@
# Python imports
from typing import Optional
import logging
# Django imports
from django.utils import timezone
from django.db import transaction
# Third party imports
from celery import shared_task
# Module imports
from plane.db.models import Issue, IssueDescriptionVersion, ProjectMember
from plane.utils.exception_logger import log_exception
def get_owner_id(issue: Issue) -> Optional[int]:
"""Get the owner ID of the issue"""
if issue.updated_by_id:
return issue.updated_by_id
if issue.created_by_id:
return issue.created_by_id
# Find project admin as fallback
project_member = ProjectMember.objects.filter(
project_id=issue.project_id,
role=20, # Admin role
).first()
return project_member.member_id if project_member else None
@shared_task
def sync_issue_description_version(batch_size=5000, offset=0, countdown=300):
"""Task to create IssueDescriptionVersion records for existing Issues in batches"""
try:
with transaction.atomic():
base_query = Issue.objects
total_issues_count = base_query.count()
if total_issues_count == 0:
return
# Calculate batch range
end_offset = min(offset + batch_size, total_issues_count)
# Fetch issues with related data
issues_batch = (
base_query.order_by("created_at")
.select_related("workspace", "project")
.only(
"id",
"workspace_id",
"project_id",
"created_by_id",
"updated_by_id",
"description_binary",
"description_html",
"description_stripped",
"description",
)[offset:end_offset]
)
if not issues_batch:
return
version_objects = []
for issue in issues_batch:
# Validate required fields
if not issue.workspace_id or not issue.project_id:
logging.warning(
f"Skipping {issue.id} - missing workspace_id or project_id"
)
continue
# Determine owned_by_id
owned_by_id = get_owner_id(issue)
if owned_by_id is None:
logging.warning(f"Skipping issue {issue.id} - missing owned_by")
continue
# Create version object
version_objects.append(
IssueDescriptionVersion(
workspace_id=issue.workspace_id,
project_id=issue.project_id,
created_by_id=issue.created_by_id,
updated_by_id=issue.updated_by_id,
owned_by_id=owned_by_id,
last_saved_at=timezone.now(),
issue_id=issue.id,
description_binary=issue.description_binary,
description_html=issue.description_html,
description_stripped=issue.description_stripped,
description_json=issue.description,
)
)
# Bulk create version objects
if version_objects:
IssueDescriptionVersion.objects.bulk_create(version_objects)
# Schedule next batch if needed
if end_offset < total_issues_count:
sync_issue_description_version.apply_async(
kwargs={
"batch_size": batch_size,
"offset": end_offset,
"countdown": countdown,
},
countdown=countdown,
)
return
except Exception as e:
log_exception(e)
return
@shared_task
def schedule_issue_description_version(batch_size=5000, countdown=300):
sync_issue_description_version.delay(
batch_size=int(batch_size), countdown=countdown
)
@@ -0,0 +1,84 @@
from celery import shared_task
from django.db import transaction
from django.utils import timezone
from typing import Optional, Dict
import json
from plane.db.models import Issue, IssueDescriptionVersion
from plane.utils.exception_logger import log_exception
def should_update_existing_version(
version: IssueDescriptionVersion, user_id: str, max_time_difference: int = 600
) -> bool:
if not version:
return
time_difference = (timezone.now() - version.last_saved_at).total_seconds()
return (
str(version.owned_by_id) == str(user_id)
and time_difference <= max_time_difference
)
def update_existing_version(version: IssueDescriptionVersion, issue) -> None:
version.description_json = issue.description
version.description_html = issue.description_html
version.description_binary = issue.description_binary
version.description_stripped = issue.description_stripped
version.last_saved_at = timezone.now()
version.save(
update_fields=[
"description_json",
"description_html",
"description_binary",
"description_stripped",
"last_saved_at",
]
)
@shared_task
def issue_description_version_task(
updated_issue, issue_id, user_id, is_creating=False
) -> Optional[bool]:
try:
# Parse updated issue data
current_issue: Dict = json.loads(updated_issue) if updated_issue else {}
# Get current issue
issue = Issue.objects.get(id=issue_id)
# Check if description has changed
if (
current_issue.get("description_html") == issue.description_html
and not is_creating
):
return
with transaction.atomic():
# Get latest version
latest_version = (
IssueDescriptionVersion.objects.filter(issue_id=issue_id)
.order_by("-last_saved_at")
.first()
)
# Determine whether to update existing or create new version
if should_update_existing_version(version=latest_version, user_id=user_id):
update_existing_version(latest_version, issue)
else:
IssueDescriptionVersion.log_issue_description_version(issue, user_id)
return
except Issue.DoesNotExist:
# Issue no longer exists, skip processing
return
except json.JSONDecodeError as e:
log_exception(f"Invalid JSON for updated_issue: {e}")
return
except Exception as e:
log_exception(f"Error processing issue description version: {e}")
return
@@ -0,0 +1,254 @@
# Python imports
import json
from typing import Optional, List, Dict
from uuid import UUID
from itertools import groupby
import logging
# Django imports
from django.utils import timezone
from django.db import transaction
# Third party imports
from celery import shared_task
# Module imports
from plane.db.models import (
Issue,
IssueVersion,
ProjectMember,
CycleIssue,
ModuleIssue,
IssueActivity,
IssueAssignee,
IssueLabel,
)
from plane.utils.exception_logger import log_exception
@shared_task
def issue_task(updated_issue, issue_id, user_id):
try:
current_issue = json.loads(updated_issue) if updated_issue else {}
issue = Issue.objects.get(id=issue_id)
updated_current_issue = {}
for key, value in current_issue.items():
if getattr(issue, key) != value:
updated_current_issue[key] = value
if updated_current_issue:
issue_version = (
IssueVersion.objects.filter(issue_id=issue_id)
.order_by("-last_saved_at")
.first()
)
if (
issue_version
and str(issue_version.owned_by) == str(user_id)
and (timezone.now() - issue_version.last_saved_at).total_seconds()
<= 600
):
for key, value in updated_current_issue.items():
setattr(issue_version, key, value)
issue_version.last_saved_at = timezone.now()
issue_version.save(
update_fields=list(updated_current_issue.keys()) + ["last_saved_at"]
)
else:
IssueVersion.log_issue_version(issue, user_id)
return
except Issue.DoesNotExist:
return
except Exception as e:
log_exception(e)
return
def get_owner_id(issue: Issue) -> Optional[int]:
"""Get the owner ID of the issue"""
if issue.updated_by_id:
return issue.updated_by_id
if issue.created_by_id:
return issue.created_by_id
# Find project admin as fallback
project_member = ProjectMember.objects.filter(
project_id=issue.project_id,
role=20, # Admin role
).first()
return project_member.member_id if project_member else None
def get_related_data(issue_ids: List[UUID]) -> Dict:
"""Get related data for the given issue IDs"""
cycle_issues = {
ci.issue_id: ci.cycle_id
for ci in CycleIssue.objects.filter(issue_id__in=issue_ids)
}
# Get assignees with proper grouping
assignee_records = list(
IssueAssignee.objects.filter(issue_id__in=issue_ids)
.values_list("issue_id", "assignee_id")
.order_by("issue_id")
)
assignees = {}
for issue_id, group in groupby(assignee_records, key=lambda x: x[0]):
assignees[issue_id] = [str(g[1]) for g in group]
# Get labels with proper grouping
label_records = list(
IssueLabel.objects.filter(issue_id__in=issue_ids)
.values_list("issue_id", "label_id")
.order_by("issue_id")
)
labels = {}
for issue_id, group in groupby(label_records, key=lambda x: x[0]):
labels[issue_id] = [str(g[1]) for g in group]
# Get modules with proper grouping
module_records = list(
ModuleIssue.objects.filter(issue_id__in=issue_ids)
.values_list("issue_id", "module_id")
.order_by("issue_id")
)
modules = {}
for issue_id, group in groupby(module_records, key=lambda x: x[0]):
modules[issue_id] = [str(g[1]) for g in group]
# Get latest activities
latest_activities = {}
activities = IssueActivity.objects.filter(issue_id__in=issue_ids).order_by(
"issue_id", "-created_at"
)
for issue_id, activities_group in groupby(activities, key=lambda x: x.issue_id):
first_activity = next(activities_group, None)
if first_activity:
latest_activities[issue_id] = first_activity.id
return {
"cycle_issues": cycle_issues,
"assignees": assignees,
"labels": labels,
"modules": modules,
"activities": latest_activities,
}
def create_issue_version(issue: Issue, related_data: Dict) -> Optional[IssueVersion]:
"""Create IssueVersion object from the given issue and related data"""
try:
if not issue.workspace_id or not issue.project_id:
logging.warning(
f"Skipping issue {issue.id} - missing workspace_id or project_id"
)
return None
owned_by_id = get_owner_id(issue)
if owned_by_id is None:
logging.warning(f"Skipping issue {issue.id} - missing owned_by")
return None
return IssueVersion(
workspace_id=issue.workspace_id,
project_id=issue.project_id,
created_by_id=issue.created_by_id,
updated_by_id=issue.updated_by_id,
owned_by_id=owned_by_id,
last_saved_at=timezone.now(),
activity_id=related_data["activities"].get(issue.id),
properties=getattr(issue, "properties", {}),
meta=getattr(issue, "meta", {}),
issue_id=issue.id,
parent=issue.parent_id,
state=issue.state_id,
estimate_point=issue.estimate_point_id,
name=issue.name,
priority=issue.priority,
start_date=issue.start_date,
target_date=issue.target_date,
assignees=related_data["assignees"].get(issue.id, []),
sequence_id=issue.sequence_id,
labels=related_data["labels"].get(issue.id, []),
sort_order=issue.sort_order,
completed_at=issue.completed_at,
archived_at=issue.archived_at,
is_draft=issue.is_draft,
external_source=issue.external_source,
external_id=issue.external_id,
type=issue.type_id,
cycle=related_data["cycle_issues"].get(issue.id),
modules=related_data["modules"].get(issue.id, []),
)
except Exception as e:
log_exception(e)
return None
@shared_task
def sync_issue_version(batch_size=5000, offset=0, countdown=300):
"""Task to create IssueVersion records for existing Issues in batches"""
try:
with transaction.atomic():
base_query = Issue.objects
total_issues_count = base_query.count()
if total_issues_count == 0:
return
end_offset = min(offset + batch_size, total_issues_count)
# Get issues batch with optimized queries
issues_batch = list(
base_query.order_by("created_at")
.select_related("workspace", "project")
.all()[offset:end_offset]
)
if not issues_batch:
return
# Get all related data in bulk
issue_ids = [issue.id for issue in issues_batch]
related_data = get_related_data(issue_ids)
issue_versions = []
for issue in issues_batch:
version = create_issue_version(issue, related_data)
if version:
issue_versions.append(version)
# Bulk create versions
if issue_versions:
IssueVersion.objects.bulk_create(issue_versions, batch_size=1000)
# Schedule the next batch if there are more workspaces to process
if end_offset < total_issues_count:
sync_issue_version.apply_async(
kwargs={
"batch_size": batch_size,
"offset": end_offset,
"countdown": countdown,
},
countdown=countdown,
)
logging.info(f"Processed Issues: {end_offset}")
return
except Exception as e:
log_exception(e)
return
@shared_task
def schedule_issue_version(batch_size=5000, countdown=300):
sync_issue_version.delay(batch_size=int(batch_size), countdown=countdown)
@@ -0,0 +1,23 @@
# Django imports
from django.core.management.base import BaseCommand
# Module imports
from plane.bgtasks.issue_description_version_sync import (
schedule_issue_description_version,
)
class Command(BaseCommand):
help = "Creates IssueDescriptionVersion records for existing Issues in batches"
def handle(self, *args, **options):
batch_size = input("Enter the batch size: ")
batch_countdown = input("Enter the batch countdown: ")
schedule_issue_description_version.delay(
batch_size=batch_size, countdown=int(batch_countdown)
)
self.stdout.write(
self.style.SUCCESS("Successfully created issue description version task")
)
@@ -0,0 +1,19 @@
# Django imports
from django.core.management.base import BaseCommand
# Module imports
from plane.bgtasks.issue_version_sync import schedule_issue_version
class Command(BaseCommand):
help = "Creates IssueVersion records for existing Issues in batches"
def handle(self, *args, **options):
batch_size = input("Enter the batch size: ")
batch_countdown = input("Enter the batch countdown: ")
schedule_issue_version.delay(
batch_size=batch_size, countdown=int(batch_countdown)
)
self.stdout.write(self.style.SUCCESS("Successfully created issue version task"))
@@ -0,0 +1,117 @@
# Generated by Django 4.2.17 on 2024-12-13 10:09
from django.conf import settings
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import plane.db.models.user
import uuid
class Migration(migrations.Migration):
dependencies = [
('db', '0086_issueversion_alter_teampage_unique_together_and_more'),
]
operations = [
migrations.RemoveField(
model_name='issueversion',
name='description',
),
migrations.RemoveField(
model_name='issueversion',
name='description_binary',
),
migrations.RemoveField(
model_name='issueversion',
name='description_html',
),
migrations.RemoveField(
model_name='issueversion',
name='description_stripped',
),
migrations.AddField(
model_name='issueversion',
name='activity',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='versions', to='db.issueactivity'),
),
migrations.AddField(
model_name='profile',
name='is_mobile_onboarded',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='profile',
name='mobile_onboarding_step',
field=models.JSONField(default=plane.db.models.user.get_mobile_default_onboarding),
),
migrations.AddField(
model_name='profile',
name='mobile_timezone_auto_set',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='profile',
name='language',
field=models.CharField(default='en', max_length=255),
),
migrations.AlterField(
model_name='issueversion',
name='owned_by',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_versions', to=settings.AUTH_USER_MODEL),
),
migrations.CreateModel(
name='Sticky',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='Deleted At')),
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('name', models.TextField()),
('description', models.JSONField(blank=True, default=dict)),
('description_html', models.TextField(blank=True, default='<p></p>')),
('description_stripped', models.TextField(blank=True, null=True)),
('description_binary', models.BinaryField(null=True)),
('logo_props', models.JSONField(default=dict)),
('color', models.CharField(blank=True, max_length=255, null=True)),
('background_color', models.CharField(blank=True, max_length=255, null=True)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stickies', to=settings.AUTH_USER_MODEL)),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stickies', to='db.workspace')),
],
options={
'verbose_name': 'Sticky',
'verbose_name_plural': 'Stickies',
'db_table': 'stickies',
'ordering': ('-created_at',),
},
),
migrations.CreateModel(
name='IssueDescriptionVersion',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='Deleted At')),
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('description_binary', models.BinaryField(null=True)),
('description_html', models.TextField(blank=True, default='<p></p>')),
('description_stripped', models.TextField(blank=True, null=True)),
('description_json', models.JSONField(blank=True, default=dict)),
('last_saved_at', models.DateTimeField(default=django.utils.timezone.now)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='description_versions', to='db.issue')),
('owned_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_description_versions', to=settings.AUTH_USER_MODEL)),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project')),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace')),
],
options={
'verbose_name': 'Issue Description Version',
'verbose_name_plural': 'Issue Description Versions',
'db_table': 'issue_description_versions',
},
),
]
+4 -9
View File
@@ -41,6 +41,8 @@ from .issue import (
IssueSequence,
IssueSubscriber,
IssueVote,
IssueVersion,
IssueDescriptionVersion,
)
from .module import Module, ModuleIssue, ModuleLink, ModuleMember, ModuleUserProperties
from .notification import EmailNotificationLog, Notification, UserNotificationPreference
@@ -68,15 +70,6 @@ from .workspace import (
WorkspaceUserProperties,
)
from .favorite import UserFavorite
from .issue_type import IssueType
@@ -86,3 +79,5 @@ from .recent_visit import UserRecentVisit
from .label import Label
from .device import Device, DeviceSession
from .sticky import Sticky
+90 -27
View File
@@ -15,6 +15,7 @@ from django import apps
from plane.utils.html_processor import strip_tags
from plane.db.mixins import SoftDeletionManager
from plane.utils.exception_logger import log_exception
from .base import BaseModel
from .project import ProjectBaseModel
@@ -660,9 +661,6 @@ class IssueVote(ProjectBaseModel):
class IssueVersion(ProjectBaseModel):
issue = models.ForeignKey(
"db.Issue", on_delete=models.CASCADE, related_name="versions"
)
PRIORITY_CHOICES = (
("urgent", "Urgent"),
("high", "High"),
@@ -670,14 +668,11 @@ class IssueVersion(ProjectBaseModel):
("low", "Low"),
("none", "None"),
)
parent = models.UUIDField(blank=True, null=True)
state = models.UUIDField(blank=True, null=True)
estimate_point = models.UUIDField(blank=True, null=True)
name = models.CharField(max_length=255, verbose_name="Issue Name")
description = models.JSONField(blank=True, default=dict)
description_html = models.TextField(blank=True, default="<p></p>")
description_stripped = models.TextField(blank=True, null=True)
description_binary = models.BinaryField(null=True)
priority = models.CharField(
max_length=30,
choices=PRIORITY_CHOICES,
@@ -686,7 +681,9 @@ class IssueVersion(ProjectBaseModel):
)
start_date = models.DateField(null=True, blank=True)
target_date = models.DateField(null=True, blank=True)
assignees = ArrayField(models.UUIDField(), blank=True, default=list)
sequence_id = models.IntegerField(default=1, verbose_name="Issue Sequence ID")
labels = ArrayField(models.UUIDField(), blank=True, default=list)
sort_order = models.FloatField(default=65535)
completed_at = models.DateTimeField(null=True)
archived_at = models.DateField(null=True)
@@ -694,14 +691,26 @@ class IssueVersion(ProjectBaseModel):
external_source = models.CharField(max_length=255, null=True, blank=True)
external_id = models.CharField(max_length=255, blank=True, null=True)
type = models.UUIDField(blank=True, null=True)
last_saved_at = models.DateTimeField(default=timezone.now)
owned_by = models.UUIDField()
assignees = ArrayField(models.UUIDField(), blank=True, default=list)
labels = ArrayField(models.UUIDField(), blank=True, default=list)
cycle = models.UUIDField(null=True, blank=True)
modules = ArrayField(models.UUIDField(), blank=True, default=list)
properties = models.JSONField(default=dict)
meta = models.JSONField(default=dict)
properties = models.JSONField(default=dict) # issue properties
meta = models.JSONField(default=dict) # issue meta
last_saved_at = models.DateTimeField(default=timezone.now)
issue = models.ForeignKey(
"db.Issue", on_delete=models.CASCADE, related_name="versions"
)
activity = models.ForeignKey(
"db.IssueActivity",
on_delete=models.SET_NULL,
null=True,
related_name="versions",
)
owned_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="issue_versions",
)
class Meta:
verbose_name = "Issue Version"
@@ -721,39 +730,93 @@ class IssueVersion(ProjectBaseModel):
Module = apps.get_model("db.Module")
CycleIssue = apps.get_model("db.CycleIssue")
IssueAssignee = apps.get_model("db.IssueAssignee")
IssueLabel = apps.get_model("db.IssueLabel")
cycle_issue = CycleIssue.objects.filter(issue=issue).first()
cls.objects.create(
issue=issue,
parent=issue.parent,
state=issue.state,
point=issue.point,
estimate_point=issue.estimate_point,
parent=issue.parent_id,
state=issue.state_id,
estimate_point=issue.estimate_point_id,
name=issue.name,
description=issue.description,
description_html=issue.description_html,
description_stripped=issue.description_stripped,
description_binary=issue.description_binary,
priority=issue.priority,
start_date=issue.start_date,
target_date=issue.target_date,
assignees=list(
IssueAssignee.objects.filter(issue=issue).values_list(
"assignee_id", flat=True
)
),
sequence_id=issue.sequence_id,
labels=list(
IssueLabel.objects.filter(issue=issue).values_list(
"label_id", flat=True
)
),
sort_order=issue.sort_order,
completed_at=issue.completed_at,
archived_at=issue.archived_at,
is_draft=issue.is_draft,
external_source=issue.external_source,
external_id=issue.external_id,
type=issue.type,
last_saved_at=issue.last_saved_at,
assignees=issue.assignees,
labels=issue.labels,
cycle=cycle_issue.cycle if cycle_issue else None,
modules=Module.objects.filter(issue=issue).values_list("id", flat=True),
type=issue.type_id,
cycle=cycle_issue.cycle_id if cycle_issue else None,
modules=list(
Module.objects.filter(issue=issue).values_list("id", flat=True)
),
properties={},
meta={},
last_saved_at=timezone.now(),
owned_by=user,
)
return True
except Exception as e:
log_exception(e)
return False
class IssueDescriptionVersion(ProjectBaseModel):
issue = models.ForeignKey(
"db.Issue", on_delete=models.CASCADE, related_name="description_versions"
)
description_binary = models.BinaryField(null=True)
description_html = models.TextField(blank=True, default="<p></p>")
description_stripped = models.TextField(blank=True, null=True)
description_json = models.JSONField(default=dict, blank=True)
last_saved_at = models.DateTimeField(default=timezone.now)
owned_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="issue_description_versions",
)
class Meta:
verbose_name = "Issue Description Version"
verbose_name_plural = "Issue Description Versions"
db_table = "issue_description_versions"
@classmethod
def log_issue_description_version(cls, issue, user):
try:
"""
Log the issue description version
"""
cls.objects.create(
workspace_id=issue.workspace_id,
project_id=issue.project_id,
created_by_id=issue.created_by_id,
updated_by_id=issue.updated_by_id,
owned_by_id=user,
last_saved_at=timezone.now(),
issue_id=issue.id,
description_binary=issue.description_binary,
description_html=issue.description_html,
description_stripped=issue.description_stripped,
description_json=issue.description,
)
return True
except Exception as e:
log_exception(e)
return False
+32
View File
@@ -0,0 +1,32 @@
# Django imports
from django.conf import settings
from django.db import models
# Module imports
from .base import BaseModel
class Sticky(BaseModel):
name = models.TextField()
description = models.JSONField(blank=True, default=dict)
description_html = models.TextField(blank=True, default="<p></p>")
description_stripped = models.TextField(blank=True, null=True)
description_binary = models.BinaryField(null=True)
logo_props = models.JSONField(default=dict)
color = models.CharField(max_length=255, blank=True, null=True)
background_color = models.CharField(max_length=255, blank=True, null=True)
workspace = models.ForeignKey(
"db.Workspace", on_delete=models.CASCADE, related_name="stickies"
)
owner = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="stickies"
)
class Meta:
verbose_name = "Sticky"
verbose_name_plural = "Stickies"
db_table = "stickies"
ordering = ("-created_at",)
+14
View File
@@ -26,6 +26,14 @@ def get_default_onboarding():
}
def get_mobile_default_onboarding():
return {
"profile_complete": False,
"workspace_create": False,
"workspace_join": False,
}
class User(AbstractBaseUser, PermissionsMixin):
id = models.UUIDField(
default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True
@@ -178,6 +186,12 @@ class Profile(TimeAuditModel):
billing_address = models.JSONField(null=True)
has_billing_address = models.BooleanField(default=False)
company_name = models.CharField(max_length=255, blank=True)
# mobile
is_mobile_onboarded = models.BooleanField(default=False)
mobile_onboarding_step = models.JSONField(default=get_mobile_default_onboarding)
mobile_timezone_auto_set = models.BooleanField(default=False)
# language
language = models.CharField(max_length=255, default="en")
class Meta:
verbose_name = "Profile"
+3
View File
@@ -262,6 +262,9 @@ CELERY_IMPORTS = (
"plane.license.bgtasks.tracer",
# management tasks
"plane.bgtasks.dummy_data_task",
# issue version tasks
"plane.bgtasks.issue_version_sync",
"plane.bgtasks.issue_description_version_sync",
)
# Sentry Settings
+100
View File
@@ -0,0 +1,100 @@
import pytz
from plane.db.models import Project
from datetime import datetime, time
from datetime import timedelta
def user_timezone_converter(queryset, datetime_fields, user_timezone):
# Create a timezone object for the user's timezone
user_tz = pytz.timezone(user_timezone)
# Check if queryset is a dictionary (single item) or a list of dictionaries
if isinstance(queryset, dict):
queryset_values = [queryset]
else:
queryset_values = list(queryset)
# Iterate over the dictionaries in the list
for item in queryset_values:
# Iterate over the datetime fields
for field in datetime_fields:
# Convert the datetime field to the user's timezone
if field in item and item[field]:
item[field] = item[field].astimezone(user_tz)
# If queryset was a single item, return a single item
if isinstance(queryset, dict):
return queryset_values[0]
else:
return queryset_values
def convert_to_utc(date, project_id, is_start_date=False):
"""
Converts a start date string to the project's local timezone at 12:00 AM
and then converts it to UTC for storage.
Args:
date (str): The date string in "YYYY-MM-DD" format.
project_id (int): The project's ID to fetch the associated timezone.
Returns:
datetime: The UTC datetime.
"""
# Retrieve the project's timezone using the project ID
project = Project.objects.get(id=project_id)
project_timezone = project.timezone
if not date or not project_timezone:
raise ValueError("Both date and timezone must be provided.")
# Parse the string into a date object
start_date = datetime.strptime(date, "%Y-%m-%d").date()
# Get the project's timezone
local_tz = pytz.timezone(project_timezone)
# Combine the date with 12:00 AM time
local_datetime = datetime.combine(start_date, time.min)
# Localize the datetime to the project's timezone
localized_datetime = local_tz.localize(local_datetime)
# If it's an start date, add one minute
if is_start_date:
localized_datetime += timedelta(minutes=1)
# Convert the localized datetime to UTC
utc_datetime = localized_datetime.astimezone(pytz.utc)
# Return the UTC datetime for storage
return utc_datetime
def convert_utc_to_project_timezone(utc_datetime, project_id):
"""
Converts a UTC datetime (stored in the database) to the project's local timezone.
Args:
utc_datetime (datetime): The UTC datetime to be converted.
project_id (int): The project's ID to fetch the associated timezone.
Returns:
datetime: The datetime in the project's local timezone.
"""
# Retrieve the project's timezone using the project ID
project = Project.objects.get(id=project_id)
project_timezone = project.timezone
if not project_timezone:
raise ValueError("Project timezone must be provided.")
# Get the timezone object for the project's timezone
local_tz = pytz.timezone(project_timezone)
# Convert the UTC datetime to the project's local timezone
if utc_datetime.tzinfo is None:
# Localize UTC datetime if it's naive (i.e., without timezone info)
utc_datetime = pytz.utc.localize(utc_datetime)
# Convert to the project's local timezone
local_datetime = utc_datetime.astimezone(local_tz)
return local_datetime
@@ -1,26 +0,0 @@
import pytz
def user_timezone_converter(queryset, datetime_fields, user_timezone):
# Create a timezone object for the user's timezone
user_tz = pytz.timezone(user_timezone)
# Check if queryset is a dictionary (single item) or a list of dictionaries
if isinstance(queryset, dict):
queryset_values = [queryset]
else:
queryset_values = list(queryset)
# Iterate over the dictionaries in the list
for item in queryset_values:
# Iterate over the datetime fields
for field in datetime_fields:
# Convert the datetime field to the user's timezone
if field in item and item[field]:
item[field] = item[field].astimezone(user_tz)
# If queryset was a single item, return a single item
if isinstance(queryset, dict):
return queryset_values[0]
else:
return queryset_values
+1 -1
View File
@@ -70,7 +70,7 @@
"value": ""
},
"GITHUB_CLIENT_SECRET": {
"description": "Github Client Secret",
"description": "GitHub Client Secret",
"value": ""
},
"NEXT_PUBLIC_API_BASE_URL": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "live",
"version": "0.24.0",
"version": "0.24.1",
"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.0",
"version": "0.24.1",
"license": "AGPL-3.0",
"private": true,
"workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/constants",
"version": "0.24.0",
"version": "0.24.1",
"private": true,
"main": "./src/index.ts"
}
+1
View File
@@ -11,6 +11,7 @@ export enum EIssueGroupByToServerOptions {
"target_date" = "target_date",
"project" = "project_id",
"created_by" = "created_by",
"team_project" = "project_id",
}
export enum EIssueGroupBYServerToProperty {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/editor",
"version": "0.24.0",
"version": "0.24.1",
"description": "Core Editor that powers Plane",
"private": true,
"main": "./dist/index.mjs",
@@ -3,4 +3,6 @@ export const DocumentCollaborativeEvents = {
unlock: { client: "unlocked", server: "unlock" },
archive: { client: "archived", server: "archive" },
unarchive: { client: "unarchived", server: "unarchive" },
"make-public": { client: "made-public", server: "make-public" },
"make-private": { client: "made-private", server: "make-private" },
} as const;
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@plane/eslint-config",
"private": true,
"version": "0.24.0",
"version": "0.24.1",
"files": [
"library.js",
"next.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/hooks",
"version": "0.24.0",
"version": "0.24.1",
"description": "React hooks that are shared across multiple apps internally",
"private": true,
"main": "./dist/index.js",
+3
View File
@@ -0,0 +1,3 @@
build/*
dist/*
out/*
+9
View File
@@ -0,0 +1,9 @@
/** @type {import("eslint").Linter.Config} */
module.exports = {
root: true,
extends: ["@plane/eslint-config/library.js"],
parser: "@typescript-eslint/parser",
parserOptions: {
project: true,
},
};
+5
View File
@@ -0,0 +1,5 @@
{
"printWidth": 120,
"tabWidth": 2,
"trailingComma": "es5"
}
+59
View File
@@ -0,0 +1,59 @@
# Logger Package
This package provides a logger and a request logger utility built using [Winston](https://github.com/winstonjs/winston). It offers customizable log levels using env and supports structured logging for general application logs and HTTP requests.
## Features.
- Dynamic log level configuration using env.
- Pre-configured winston logger for general usage (`logger`).
- Request logger middleware that logs incoming request
## Usage
### Adding as a package
Add this package as a dependency in package.json
```typescript
dependency: {
...
@plane/logger":"*",
...
}
```
### Importing the Logger
```typescript
import { logger, requestLogger } from '@plane/logger'
```
### Usage
### `logger`: General Logger
Use this for general application logs.
```typescript
logger.info("This is an info log");
logger.warn("This is a warning");
logger.error("This is an error");
```
### `requestLogger`: Request Logger Middleware
Use this as a middleware for incoming requests
```typescript
const app = express()
app.use(requestLogger)
```
## Available Log Levels
- `error`
- `warn`
- `info` (default)
- `http`
- `verbose`
- `debug`
- `silly`
## Log file
- Log files are stored in logs folder of current working directory. Error logs are stored in files with format `error-%DATE%.log` and combined logs are stored with format `combined-%DATE%.log`.
- Log files have a 7 day rotation period defined.
## Configuration
- By default, the log level is set to `info`.
- You can specify a log level by adding a LOG_LEVEL in .env.
+21
View File
@@ -0,0 +1,21 @@
{
"name": "@plane/logger",
"version": "0.24.1",
"description": "Logger shared across multiple apps internally",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"lint": "eslint src --ext .ts,.tsx",
"lint:errors": "eslint src --ext .ts,.tsx --quiet"
},
"dependencies": {
"winston": "^3.17.0",
"winston-daily-rotate-file": "^5.0.0"
},
"devDependencies": {
"@plane/eslint-config": "*",
"@types/node": "^22.5.4",
"typescript": "^5.3.3"
}
}
+66
View File
@@ -0,0 +1,66 @@
import winston from "winston";
import DailyRotateFile from "winston-daily-rotate-file";
import path from "path";
// Define log levels
const levels = {
error: 0,
warn: 1,
info: 2,
http: 3,
debug: 4,
};
// Define colors for each level
const colors = {
error: "red",
warn: "yellow",
info: "green",
http: "magenta",
debug: "white",
};
// Tell winston about our colors
winston.addColors(colors);
// Custom format for logging
const format = winston.format.combine(
winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss:ms" }),
winston.format.colorize({ all: true }),
winston.format.printf(
(info: winston.Logform.TransformableInfo) => `[${info?.timestamp}] ${info.level}: ${info.message}`
)
);
// Define which transports to use
const transports = [
// Console transport
new winston.transports.Console(),
// Rotating file transport for errors
new DailyRotateFile({
filename: path.join(process.cwd(), "logs", "error-%DATE%.log"),
datePattern: "YYYY-MM-DD",
zippedArchive: true,
maxSize: process.env.LOG_MAX_SIZE || "20m",
maxFiles: process.env.LOG_RETENTION || "7d",
level: "error",
}),
// Rotating file transport for all logs
new DailyRotateFile({
filename: path.join(process.cwd(), "logs", "combined-%DATE%.log"),
datePattern: "YYYY-MM-DD",
zippedArchive: true,
maxSize: process.env.LOG_MAX_SIZE || "20m",
maxFiles: process.env.LOG_RETENTION || "7d",
}),
];
// Create the logger
export const logger = winston.createLogger({
level: process.env.LOG_LEVEL || "info",
levels,
format,
transports,
});
+2
View File
@@ -0,0 +1,2 @@
export * from "./config";
export * from "./middleware";
+23
View File
@@ -0,0 +1,23 @@
import { Request, Response, NextFunction } from "express";
import { logger } from "./config";
export const requestLogger = (req: Request, res: Response, next: NextFunction) => {
// Log when the request starts
const startTime = Date.now();
// Log request details
logger.http(`Incoming ${req.method} request to ${req.url} from ${req.ip}`);
// Log request body if present
if (Object.keys(req.body).length > 0) {
logger.debug("Request body:", req.body);
}
// Capture response
res.on("finish", () => {
const duration = Date.now() - startTime;
logger.http(`Completed ${req.method} ${req.url} with status ${res.statusCode} in ${duration}ms`);
});
next();
};
+19
View File
@@ -0,0 +1,19 @@
{
"extends": "@plane/typescript-config/base.json",
"compilerOptions": {
"module": "ESNext",
"target": "ESNext",
"moduleResolution": "node",
"esModuleInterop": true,
"outDir": "./dist",
"rootDir": "./src",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"experimentalDecorators": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "tailwind-config-custom",
"version": "0.24.0",
"version": "0.24.1",
"description": "common tailwind configuration across monorepo",
"main": "index.js",
"private": true,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/types",
"version": "0.24.0",
"version": "0.24.1",
"private": true,
"types": "./src/index.d.ts",
"main": "./src/index.d.ts"
+15
View File
@@ -0,0 +1,15 @@
export type TCommandPaletteActionList = Record<
string,
{ title: string; description: string; action: () => void }
>;
export type TCommandPaletteShortcutList = {
key: string;
title: string;
shortcuts: TCommandPaletteShortcut[];
};
export type TCommandPaletteShortcut = {
keys: string; // comma separated keys
description: string;
};
+2
View File
@@ -22,3 +22,5 @@ export type TLogoProps = {
background_color?: string;
};
};
export type TNameDescriptionLoader = "submitting" | "submitted" | "saved";
+1
View File
@@ -59,4 +59,5 @@ export enum EFileAssetType {
USER_AVATAR = "USER_AVATAR",
USER_COVER = "USER_COVER",
WORKSPACE_LOGO = "WORKSPACE_LOGO",
TEAM_SPACE_DESCRIPTION = "TEAM_SPACE_DESCRIPTION",
}
+1
View File
@@ -32,3 +32,4 @@ export * from "./workspace-notifications";
export * from "./favorite";
export * from "./file";
export * from "./workspace-draft-issues/base";
export * from "./command-palette";
+3 -2
View File
@@ -211,12 +211,13 @@ export type GroupByColumnTypes =
| "priority"
| "labels"
| "assignees"
| "created_by";
| "created_by"
| "team_project";
export interface IGroupByColumn {
id: string;
name: string;
icon: ReactElement | undefined;
icon?: ReactElement | undefined;
payload: Partial<TIssue>;
isDropDisabled?: boolean;
dropErrorMessage?: string;
+1
View File
@@ -1,2 +1,3 @@
export * from "./project_filters";
export * from "./projects";
export * from "./project_link";
+22
View File
@@ -0,0 +1,22 @@
export type TProjectLinkEditableFields = {
title: string;
url: string;
};
export type TProjectLink = TProjectLinkEditableFields & {
created_by_id: string;
id: string;
metadata: any;
project_id: string;
//need
created_at: Date;
};
export type TProjectLinkMap = {
[project_id: string]: TProjectLink;
};
export type TProjectLinkIdMap = {
[project_id: string]: string[];
};
+3
View File
@@ -18,6 +18,7 @@ export type TIssueGroupByOptions =
| "cycle"
| "module"
| "target_date"
| "team_project"
| null;
export type TIssueOrderByOptions =
@@ -69,6 +70,7 @@ export type TIssueParams =
| "start_date"
| "target_date"
| "project"
| "team_project"
| "group_by"
| "sub_group_by"
| "order_by"
@@ -92,6 +94,7 @@ export interface IIssueFilterOptions {
cycle?: string[] | null;
module?: string[] | null;
project?: string[] | null;
team_project?: string[] | null;
start_date?: string[] | null;
state?: string[] | null;
state_group?: string[] | null;
+1 -1
View File
@@ -35,7 +35,7 @@ export type TNotificationData = {
};
export type TNotification = {
id: string | undefined;
id: string;
title: string | undefined;
data: TNotificationData | undefined;
entity_identifier: string | undefined;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/typescript-config",
"version": "0.24.0",
"version": "0.24.1",
"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.0",
"version": "0.24.1",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
+1
View File
@@ -1 +1,2 @@
export * from "./breadcrumbs";
export * from "./navigation-dropdown";
@@ -0,0 +1,96 @@
"use client";
import * as React from "react";
import { CheckIcon, ChevronDownIcon } from "lucide-react";
// ui
import { CustomMenu, TContextMenuItem } from "../dropdowns";
// helpers
import { cn } from "../../helpers";
type TBreadcrumbNavigationDropdownProps = {
selectedItemKey: string;
navigationItems: TContextMenuItem[];
navigationDisabled?: boolean;
};
export const BreadcrumbNavigationDropdown = (props: TBreadcrumbNavigationDropdownProps) => {
const { selectedItemKey, navigationItems, navigationDisabled = false } = props;
// derived values
const selectedItem = navigationItems.find((item) => item.key === selectedItemKey);
const selectedItemIcon = selectedItem?.icon ? (
<selectedItem.icon className={cn("size-3.5", selectedItem.iconClassName)} />
) : undefined;
// if no selected item, return null
if (!selectedItem) return null;
const NavigationButton = ({ className }: { className?: string }) => (
<li
className={cn(
"flex items-center justify-center cursor-default text-sm font-medium text-custom-text-200 group-hover:text-custom-text-100 outline-none",
className
)}
tabIndex={-1}
>
{selectedItemIcon && (
<div className="flex h-5 w-5 items-center justify-start overflow-hidden">{selectedItemIcon}</div>
)}
<div className="relative line-clamp-1 block max-w-[150px] overflow-hidden truncate">{selectedItem.title}</div>
</li>
);
if (navigationDisabled) {
return <NavigationButton />;
}
return (
<CustomMenu
customButton={
<div className="group flex items-center gap-1.5">
<NavigationButton className="cursor-pointer" />
<ChevronDownIcon className="size-4 text-custom-text-200 group-hover:text-custom-text-100" />
</div>
}
placement="bottom-start"
closeOnSelect
>
{navigationItems.map((item) => {
if (item.shouldRender === false) return null;
return (
<CustomMenu.MenuItem
key={item.key}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (item.key === selectedItemKey) return;
item.action();
}}
className={cn(
"flex items-center gap-2",
{
"text-custom-text-400": item.disabled,
},
item.className
)}
disabled={item.disabled}
>
{item.icon && <item.icon className={cn("size-3.5", item.iconClassName)} />}
<div className="w-full">
<h5>{item.title}</h5>
{item.description && (
<p
className={cn("text-custom-text-300 whitespace-pre-line", {
"text-custom-text-400": item.disabled,
})}
>
{item.description}
</p>
)}
</div>
{item.key === selectedItemKey && <CheckIcon className="flex-shrink-0 size-3.5" />}
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
);
};
+17 -13
View File
@@ -36,19 +36,23 @@ export const ContextMenuItem: React.FC<ContextMenuItemProps> = (props) => {
onMouseEnter={handleActiveItem}
disabled={item.disabled}
>
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
<div>
<h5>{item.title}</h5>
{item.description && (
<p
className={cn("text-custom-text-300 whitespace-pre-line", {
"text-custom-text-400": item.disabled,
})}
>
{item.description}
</p>
)}
</div>
{item.customContent ?? (
<>
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
<div>
<h5>{item.title}</h5>
{item.description && (
<p
className={cn("text-custom-text-300 whitespace-pre-line", {
"text-custom-text-400": item.disabled,
})}
>
{item.description}
</p>
)}
</div>
</>
)}
</button>
);
};
@@ -11,7 +11,8 @@ import { usePlatformOS } from "../../hooks/use-platform-os";
export type TContextMenuItem = {
key: string;
title: string;
customContent?: React.ReactNode;
title?: string;
description?: string;
icon?: React.FC<any>;
action: () => void;
+2 -2
View File
@@ -54,7 +54,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
if (referenceElement) referenceElement.focus();
};
const closeDropdown = () => {
isOpen && onMenuClose && onMenuClose();
if (isOpen) onMenuClose?.();
setIsOpen(false);
};
@@ -216,7 +216,7 @@ const MenuItem: React.FC<ICustomMenuItemProps> = (props) => {
)}
onClick={(e) => {
close();
onClick && onClick(e);
onClick?.(e);
}}
disabled={disabled}
>
+22
View File
@@ -0,0 +1,22 @@
import * as React from "react";
import { ISvgIcons } from "./type";
export const ActivityIcon: React.FC<ISvgIcons> = ({ className = "text-current", ...rest }) => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_15681_9387)">
<path
d="M14.6666 8.00001H13.0133C12.7219 7.99939 12.4384 8.09421 12.206 8.26999C11.9736 8.44576 11.8053 8.69281 11.7266 8.97334L10.1599 14.5467C10.1498 14.5813 10.1288 14.6117 10.0999 14.6333C10.0711 14.655 10.036 14.6667 9.99992 14.6667C9.96386 14.6667 9.92877 14.655 9.89992 14.6333C9.87107 14.6117 9.85002 14.5813 9.83992 14.5467L6.15992 1.45334C6.14982 1.41872 6.12877 1.38831 6.09992 1.36668C6.07107 1.34504 6.03598 1.33334 5.99992 1.33334C5.96386 1.33334 5.92877 1.34504 5.89992 1.36668C5.87107 1.38831 5.85002 1.41872 5.83992 1.45334L4.27325 7.02668C4.1949 7.30611 4.02751 7.55235 3.79649 7.72802C3.56548 7.90368 3.28347 7.99918 2.99325 8.00001H1.33325"
stroke="#8591AD"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
<defs>
<clipPath id="clip0_15681_9387">
<rect width="16" height="16" fill="white" />
</clipPath>
</defs>
</svg>
);
+30
View File
@@ -0,0 +1,30 @@
import * as React from "react";
import { ISvgIcons } from "./type";
export const AtRiskIcon: React.FC<ISvgIcons> = ({ width = "16", height = "16" }) => (
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M16 1C7.71573 1 1 7.71573 1 16C1 24.2843 7.71573 31 16 31C24.2843 31 31 24.2843 31 16C31 7.71573 24.2843 1 16 1Z"
fill="#CC7700"
/>
<path
d="M16 1C7.71573 1 1 7.71573 1 16C1 24.2843 7.71573 31 16 31C24.2843 31 31 24.2843 31 16C31 7.71573 24.2843 1 16 1Z"
stroke="#F3F4F7"
stroke-width="2"
/>
<g clip-path="url(#clip0_21157_25600)">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M10.0369 15.3346H10.667C11.0352 15.3346 11.3337 15.6331 11.3337 16.0013C11.3337 16.3695 11.0352 16.668 10.667 16.668H10.0369C10.3686 19.6679 12.912 22.0013 16.0003 22.0013C19.0887 22.0013 21.6321 19.6679 21.9637 16.668H21.3337C20.9655 16.668 20.667 16.3695 20.667 16.0013C20.667 15.6331 20.9655 15.3346 21.3337 15.3346H21.9637C21.6321 12.3347 19.0887 10.0013 16.0003 10.0013C12.912 10.0013 10.3686 12.3347 10.0369 15.3346ZM8.66699 16.0013C8.66699 11.9512 11.9502 8.66797 16.0003 8.66797C20.0504 8.66797 23.3337 11.9512 23.3337 16.0013C23.3337 20.0514 20.0504 23.3346 16.0003 23.3346C11.9502 23.3346 8.66699 20.0514 8.66699 16.0013ZM16.0003 12.668C16.3685 12.668 16.667 12.9664 16.667 13.3346V16.0013C16.667 16.3695 16.3685 16.668 16.0003 16.668C15.6321 16.668 15.3337 16.3695 15.3337 16.0013V13.3346C15.3337 12.9664 15.6321 12.668 16.0003 12.668ZM15.3337 18.668C15.3337 18.2998 15.6321 18.0013 16.0003 18.0013H16.007C16.3752 18.0013 16.6737 18.2998 16.6737 18.668C16.6737 19.0362 16.3752 19.3346 16.007 19.3346H16.0003C15.6321 19.3346 15.3337 19.0362 15.3337 18.668Z"
fill="white"
/>
</g>
<defs>
<clipPath id="clip0_21157_25600">
<rect width="16" height="16" fill="white" transform="translate(8 8)" />
</clipPath>
</defs>
</svg>
);
+9
View File
@@ -16,6 +16,7 @@ export * from "./epic-icon";
export * from "./full-screen-panel-icon";
export * from "./github-icon";
export * from "./gitlab-icon";
export * from "./info-fill-icon";
export * from "./info-icon";
export * from "./layer-stack";
export * from "./layers-icon";
@@ -38,3 +39,11 @@ export * from "./done-icon";
export * from "./pending-icon";
export * from "./pi-chat";
export * from "./workspace-icon";
export * from "./teams";
export * from "./lead-icon";
export * from "./activity-icon";
export * from "./updates-icon";
export * from "./overview-icon";
export * from "./on-track-icon";
export * from "./off-track-icon";
export * from "./at-risk-icon";
+26
View File
@@ -0,0 +1,26 @@
import * as React from "react";
import { ISvgIcons } from "./type";
export const LeadIcon: React.FC<ISvgIcons> = ({ className = "text-current", ...rest }) => (
<svg className={className} viewBox="0 0 19 18" fill="none" xmlns="http://www.w3.org/2000/svg" {...rest}>
<path
d="M0.571533 9C0.571533 4.02944 4.60097 0 9.57153 0C14.5421 0 18.5715 4.02944 18.5715 9C18.5715 13.9706 14.5421 18 9.57153 18C4.60097 18 0.571533 13.9706 0.571533 9Z"
fill="#3372FF"
/>
<g clip-path="url(#clip0_8992_2377)">
<circle cx="9.57153" cy="6.5" r="2.5" fill="#F5F5FF" />
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M8.94653 9.625C6.53029 9.625 4.57153 11.5838 4.57153 14H9.57153H14.5715C14.5715 11.5838 12.6128 9.625 10.1965 9.625H9.82153L10.8215 13.0278L9.57153 14L8.32153 13.0278L9.32153 9.625H8.94653Z"
fill="#F5F5FF"
/>
</g>
<defs>
<clipPath id="clip0_8992_2377">
<rect width="10" height="10" fill="white" transform="translate(4.57153 4)" />
</clipPath>
</defs>
</svg>
);
+30
View File
@@ -0,0 +1,30 @@
import * as React from "react";
import { ISvgIcons } from "./type";
export const OffTrackIcon: React.FC<ISvgIcons> = ({ width = "16", height = "16" }) => (
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M16 1C7.71573 1 1 7.71573 1 16C1 24.2843 7.71573 31 16 31C24.2843 31 31 24.2843 31 16C31 7.71573 24.2843 1 16 1Z"
fill="#CC0000"
/>
<path
d="M16 1C7.71573 1 1 7.71573 1 16C1 24.2843 7.71573 31 16 31C24.2843 31 31 24.2843 31 16C31 7.71573 24.2843 1 16 1Z"
stroke="#F3F4F7"
stroke-width="2"
/>
<g clip-path="url(#clip0_21157_78200)">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M10.0369 15.3346H10.667C11.0352 15.3346 11.3337 15.6331 11.3337 16.0013C11.3337 16.3695 11.0352 16.668 10.667 16.668H10.0369C10.3686 19.6679 12.912 22.0013 16.0003 22.0013C19.0887 22.0013 21.6321 19.6679 21.9637 16.668H21.3337C20.9655 16.668 20.667 16.3695 20.667 16.0013C20.667 15.6331 20.9655 15.3346 21.3337 15.3346H21.9637C21.6321 12.3347 19.0887 10.0013 16.0003 10.0013C12.912 10.0013 10.3686 12.3347 10.0369 15.3346ZM8.66699 16.0013C8.66699 11.9512 11.9502 8.66797 16.0003 8.66797C20.0504 8.66797 23.3337 11.9512 23.3337 16.0013C23.3337 20.0514 20.0504 23.3346 16.0003 23.3346C11.9502 23.3346 8.66699 20.0514 8.66699 16.0013ZM14.667 12.668C15.0352 12.668 15.3337 12.9664 15.3337 13.3346V16.0013C15.3337 16.3695 15.0352 16.668 14.667 16.668C14.2988 16.668 14.0003 16.3695 14.0003 16.0013V13.3346C14.0003 12.9664 14.2988 12.668 14.667 12.668ZM17.3337 12.668C17.7018 12.668 18.0003 12.9664 18.0003 13.3346V16.0013C18.0003 16.3695 17.7018 16.668 17.3337 16.668C16.9655 16.668 16.667 16.3695 16.667 16.0013V13.3346C16.667 12.9664 16.9655 12.668 17.3337 12.668ZM14.0003 18.668C14.0003 18.2998 14.2988 18.0013 14.667 18.0013H14.6737C15.0418 18.0013 15.3403 18.2998 15.3403 18.668C15.3403 19.0362 15.0418 19.3346 14.6737 19.3346H14.667C14.2988 19.3346 14.0003 19.0362 14.0003 18.668ZM16.667 18.668C16.667 18.2998 16.9655 18.0013 17.3337 18.0013H17.3403C17.7085 18.0013 18.007 18.2998 18.007 18.668C18.007 19.0362 17.7085 19.3346 17.3403 19.3346H17.3337C16.9655 19.3346 16.667 19.0362 16.667 18.668Z"
fill="white"
/>
</g>
<defs>
<clipPath id="clip0_21157_78200">
<rect width="16" height="16" fill="white" transform="translate(8 8)" />
</clipPath>
</defs>
</svg>
);
+48
View File
@@ -0,0 +1,48 @@
import * as React from "react";
import { ISvgIcons } from "./type";
export const OnTrackIcon: React.FC<ISvgIcons> = ({ width = "16", height = "16" }) => (
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M16 1C7.71573 1 1 7.71573 1 16C1 24.2843 7.71573 31 16 31C24.2843 31 31 24.2843 31 16C31 7.71573 24.2843 1 16 1Z"
fill="#1FAD40"
/>
<path
d="M16 1C7.71573 1 1 7.71573 1 16C1 24.2843 7.71573 31 16 31C24.2843 31 31 24.2843 31 16C31 7.71573 24.2843 1 16 1Z"
stroke="#F3F4F7"
stroke-width="2"
/>
<g clip-path="url(#clip0_21157_107468)">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M19.0005 10.8004C17.8118 10.1146 16.4238 9.85693 15.0681 10.0705C13.7124 10.2841 12.4709 10.956 11.5507 11.9741C10.6304 12.9923 10.087 14.2952 10.0111 15.6655C9.93516 17.0358 10.3313 18.3907 11.1334 19.5043C11.9356 20.6179 13.0953 21.4228 14.4191 21.7849C15.7429 22.1469 17.1508 22.0442 18.408 21.4938C19.6652 20.9435 20.6958 19.9787 21.3278 18.7605C21.9598 17.5423 22.1551 16.1442 21.8811 14.7994C21.8076 14.4387 22.0404 14.0866 22.4012 14.0131C22.762 13.9396 23.1141 14.1725 23.1876 14.5332C23.5225 16.1768 23.2838 17.8856 22.5113 19.3745C21.7389 20.8635 20.4793 22.0426 18.9427 22.7153C17.4061 23.3879 15.6853 23.5135 14.0673 23.071C12.4493 22.6285 11.032 21.6447 10.0516 20.2836C9.07117 18.9226 8.58699 17.2665 8.67979 15.5917C8.77259 13.9169 9.43675 12.3245 10.5615 11.0801C11.6863 9.83568 13.2037 9.01448 14.8606 8.75343C16.5176 8.49238 18.2139 8.80726 19.6668 9.64556C19.9857 9.82957 20.0951 10.2373 19.9111 10.5562C19.7271 10.8751 19.3194 10.9845 19.0005 10.8004ZM23.1384 10.1949C23.3987 10.4553 23.3987 10.8774 23.1384 11.1377L16.4717 17.8044C16.2114 18.0648 15.7893 18.0648 15.5289 17.8044L13.5289 15.8044C13.2686 15.5441 13.2686 15.1219 13.5289 14.8616C13.7893 14.6012 14.2114 14.6012 14.4717 14.8616L16.0003 16.3902L22.1956 10.1949C22.4559 9.93458 22.878 9.93458 23.1384 10.1949Z"
fill="white"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M16.0003 23.333C15.6321 23.333 15.3337 23.0345 15.3337 22.6663V21.333C15.3337 20.9648 15.6321 20.6663 16.0003 20.6663C16.3685 20.6663 16.667 20.9648 16.667 21.333V22.6663C16.667 23.0345 16.3685 23.333 16.0003 23.333Z"
fill="white"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M16.0003 11.333C15.6321 11.333 15.3337 11.0345 15.3337 10.6663V9.333C15.3337 8.96481 15.6321 8.66634 16.0003 8.66634C16.3685 8.66634 16.667 8.96481 16.667 9.333V10.6663C16.667 11.0345 16.3685 11.333 16.0003 11.333Z"
fill="white"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M11.3337 15.9997C11.3337 16.3679 11.0352 16.6663 10.667 16.6663H9.33366C8.96547 16.6663 8.66699 16.3679 8.66699 15.9997C8.66699 15.6315 8.96547 15.333 9.33366 15.333H10.667C11.0352 15.333 11.3337 15.6315 11.3337 15.9997Z"
fill="white"
/>
</g>
<defs>
<clipPath id="clip0_21157_107468">
<rect width="16" height="16" fill="white" transform="translate(8 8)" />
</clipPath>
</defs>
</svg>
);
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react";
import { ISvgIcons } from "./type";
export const OverviewIcon: React.FC<ISvgIcons> = ({ width = "16", height = "16", className = "" }) => (
<svg
width={width}
height={height}
className={className}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M2.5 3C2.5 2.86739 2.55268 2.74021 2.64645 2.64645C2.74021 2.55268 2.86739 2.5 3 2.5H3.5C9.02267 2.5 13.5 6.97733 13.5 12.5V13C13.5 13.1326 13.4473 13.2598 13.3536 13.3536C13.2598 13.4473 13.1326 13.5 13 13.5H12.5C12.3674 13.5 12.2402 13.4473 12.1464 13.3536C12.0527 13.2598 12 13.1326 12 13V12.5C12 7.80533 8.19467 4 3.5 4H3C2.86739 4 2.74021 3.94732 2.64645 3.85355C2.55268 3.75979 2.5 3.63261 2.5 3.5V3ZM2.5 7.5C2.5 7.36739 2.55268 7.24022 2.64645 7.14645C2.74021 7.05268 2.86739 7 3 7H3.5C4.22227 7 4.93747 7.14226 5.60476 7.41866C6.27205 7.69506 6.87837 8.10019 7.38909 8.61091C7.89981 9.12164 8.30494 9.72795 8.58134 10.3952C8.85774 11.0625 9 11.7777 9 12.5V13C9 13.1326 8.94732 13.2598 8.85355 13.3536C8.75978 13.4473 8.63261 13.5 8.5 13.5H8C7.86739 13.5 7.74022 13.4473 7.64645 13.3536C7.55268 13.2598 7.5 13.1326 7.5 13V12.5C7.5 11.4391 7.07857 10.4217 6.32843 9.67157C5.57828 8.92143 4.56087 8.5 3.5 8.5H3C2.86739 8.5 2.74021 8.44732 2.64645 8.35355C2.55268 8.25978 2.5 8.13261 2.5 8V7.5ZM2.5 12.5C2.5 12.2348 2.60536 11.9804 2.79289 11.7929C2.98043 11.6054 3.23478 11.5 3.5 11.5C3.76522 11.5 4.01957 11.6054 4.20711 11.7929C4.39464 11.9804 4.5 12.2348 4.5 12.5C4.5 12.7652 4.39464 13.0196 4.20711 13.2071C4.01957 13.3946 3.76522 13.5 3.5 13.5C3.23478 13.5 2.98043 13.3946 2.79289 13.2071C2.60536 13.0196 2.5 12.7652 2.5 12.5Z"
fill="#455068"
/>
</svg>
);
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react";
import { ISvgIcons } from "./type";
export const TeamsIcon: React.FC<ISvgIcons> = ({ className = "text-current", ...rest }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M8.25 6.75C8.25 5.75544 8.64509 4.80161 9.34835 4.09835C10.0516 3.39509 11.0054 3 12 3C12.9946 3 13.9484 3.39509 14.6517 4.09835C15.3549 4.80161 15.75 5.75544 15.75 6.75C15.75 7.74456 15.3549 8.69839 14.6517 9.40165C13.9484 10.1049 12.9946 10.5 12 10.5C11.0054 10.5 10.0516 10.1049 9.34835 9.40165C8.64509 8.69839 8.25 7.74456 8.25 6.75ZM15.75 9.75C15.75 8.95435 16.0661 8.19129 16.6287 7.62868C17.1913 7.06607 17.9544 6.75 18.75 6.75C19.5456 6.75 20.3087 7.06607 20.8713 7.62868C21.4339 8.19129 21.75 8.95435 21.75 9.75C21.75 10.5456 21.4339 11.3087 20.8713 11.8713C20.3087 12.4339 19.5456 12.75 18.75 12.75C17.9544 12.75 17.1913 12.4339 16.6287 11.8713C16.0661 11.3087 15.75 10.5456 15.75 9.75ZM2.25 9.75C2.25 8.95435 2.56607 8.19129 3.12868 7.62868C3.69129 7.06607 4.45435 6.75 5.25 6.75C6.04565 6.75 6.80871 7.06607 7.37132 7.62868C7.93393 8.19129 8.25 8.95435 8.25 9.75C8.25 10.5456 7.93393 11.3087 7.37132 11.8713C6.80871 12.4339 6.04565 12.75 5.25 12.75C4.45435 12.75 3.69129 12.4339 3.12868 11.8713C2.56607 11.3087 2.25 10.5456 2.25 9.75ZM6.31 15.117C6.91995 14.161 7.76108 13.3743 8.75562 12.8294C9.75016 12.2846 10.866 11.9994 12 12C12.9498 11.9991 13.8891 12.1989 14.7564 12.5862C15.6237 12.9734 16.3994 13.5395 17.0327 14.2474C17.6661 14.9552 18.1428 15.7888 18.4317 16.6936C18.7205 17.5985 18.815 18.5541 18.709 19.498C18.696 19.6153 18.6556 19.7278 18.591 19.8265C18.5263 19.9252 18.4393 20.0073 18.337 20.066C16.4086 21.1725 14.2233 21.7532 12 21.75C9.695 21.75 7.53 21.138 5.663 20.066C5.56069 20.0073 5.47368 19.9252 5.40904 19.8265C5.34441 19.7278 5.30396 19.6153 5.291 19.498C5.12305 17.9646 5.48246 16.4198 6.31 15.118V15.117Z"
fill="currentColor"
{...rest}
/>
<path
d="M5.08208 14.2539C4.09584 15.7763 3.63633 17.5802 3.77408 19.3889C3.17359 19.2979 2.58299 19.1505 2.01008 18.9489L1.89508 18.9089C1.79248 18.8725 1.70263 18.8071 1.63643 18.7207C1.57023 18.6342 1.53051 18.5305 1.52208 18.4219L1.51208 18.3009C1.47169 17.7989 1.53284 17.2938 1.69188 16.816C1.85093 16.3381 2.10462 15.8971 2.4378 15.5194C2.77099 15.1417 3.17685 14.835 3.63116 14.6176C4.08547 14.4001 4.57893 14.2765 5.08208 14.2539ZM20.2261 19.3889C20.3638 17.5802 19.9043 15.7763 18.9181 14.2539C19.4212 14.2765 19.9147 14.4001 20.369 14.6176C20.8233 14.835 21.2292 15.1417 21.5624 15.5194C21.8955 15.8971 22.1492 16.3381 22.3083 16.816C22.4673 17.2938 22.5285 17.7989 22.4881 18.3009L22.4781 18.4219C22.4695 18.5303 22.4297 18.6338 22.3635 18.7201C22.2973 18.8063 22.2075 18.8716 22.1051 18.9079L21.9901 18.9479C21.4231 19.1479 20.8341 19.2969 20.2261 19.3889Z"
fill="currentColor"
/>
</svg>
);
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react";
import { ISvgIcons } from "./type";
export const UpdatesIcon: React.FC<ISvgIcons> = ({ className = "text-current", ...rest }) => (
<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M6.54325 5.056C8.46325 2.58867 11.4633 1 14.8333 1C14.9659 1 15.093 1.05268 15.1868 1.14645C15.2806 1.24021 15.3333 1.36739 15.3333 1.5C15.3333 4.87067 13.7446 7.87 11.2773 9.79067C11.3799 10.4335 11.3418 11.0909 11.1656 11.7176C10.9895 12.3443 10.6795 12.9253 10.257 13.4205C9.83454 13.9158 9.30964 14.3135 8.71854 14.5862C8.12744 14.8588 7.48422 15 6.83325 15C6.70064 15 6.57347 14.9473 6.4797 14.8536C6.38593 14.7598 6.33325 14.6326 6.33325 14.5V11.746C5.6852 11.2342 5.09942 10.6482 4.58792 10H1.83325C1.70064 10 1.57347 9.94732 1.4797 9.85355C1.38593 9.75979 1.33325 9.63261 1.33325 9.5C1.3332 8.84897 1.47441 8.20568 1.74713 7.61453C2.01986 7.02337 2.41761 6.49843 2.91293 6.07594C3.40824 5.65345 3.98934 5.34346 4.6161 5.16737C5.24287 4.99128 5.90038 4.95328 6.54325 5.056ZM10.3333 4.5C9.93543 4.5 9.5539 4.65804 9.27259 4.93934C8.99129 5.22064 8.83325 5.60218 8.83325 6C8.83325 6.39782 8.99129 6.77936 9.27259 7.06066C9.5539 7.34196 9.93543 7.5 10.3333 7.5C10.7311 7.5 11.1126 7.34196 11.3939 7.06066C11.6752 6.77936 11.8333 6.39782 11.8333 6C11.8333 5.60218 11.6752 5.22064 11.3939 4.93934C11.1126 4.65804 10.7311 4.5 10.3333 4.5Z"
fill="#8591AD"
/>
<path
d="M3.83994 11.4947C3.8926 11.4554 3.93701 11.4062 3.97064 11.3497C4.00426 11.2933 4.02645 11.2308 4.03592 11.1658C4.04539 11.1008 4.04197 11.0346 4.02584 10.9709C4.00972 10.9072 3.98121 10.8473 3.94194 10.7947C3.90268 10.742 3.85342 10.6976 3.797 10.664C3.74057 10.6304 3.67807 10.6082 3.61307 10.5987C3.54807 10.5892 3.48184 10.5927 3.41816 10.6088C3.35448 10.6249 3.2946 10.6534 3.24194 10.6927C2.73054 11.0731 2.33288 11.5861 2.092 12.1762C1.85111 12.7663 1.77617 13.4111 1.87528 14.0407C1.89139 14.1455 1.94045 14.2426 2.01536 14.3177C2.09026 14.3929 2.18713 14.4422 2.29194 14.4587C2.92163 14.5577 3.56641 14.4827 4.15652 14.2417C4.74664 14.0007 5.25961 13.6029 5.63994 13.0913C5.68047 13.0388 5.71015 12.9788 5.72723 12.9147C5.74432 12.8506 5.74848 12.7837 5.73948 12.718C5.73047 12.6522 5.70847 12.589 5.67476 12.5318C5.64105 12.4747 5.59631 12.4248 5.54315 12.3852C5.48998 12.3455 5.42945 12.3168 5.36508 12.3007C5.30071 12.2847 5.23379 12.2816 5.16821 12.2917C5.10264 12.3017 5.03973 12.3248 4.98314 12.3594C4.92655 12.394 4.87742 12.4395 4.83861 12.4933C4.60612 12.806 4.30366 13.0599 3.95544 13.2347C3.60722 13.4095 3.22291 13.5004 2.83328 13.5C2.83328 12.68 3.22794 11.9513 3.83994 11.4947Z"
fill="#8591AD"
/>
</svg>
);
+27 -5
View File
@@ -1,4 +1,4 @@
import React, { FC, Fragment } from "react";
import React, { FC, Fragment, useEffect, useState } from "react";
import { Tab } from "@headlessui/react";
import { LucideProps } from "lucide-react";
// helpers
@@ -11,11 +11,12 @@ type TabItem = {
label?: React.ReactNode;
content: React.ReactNode;
disabled?: boolean;
onClick?: () => void;
};
type TTabsProps = {
tabs: TabItem[];
storageKey: string;
storageKey?: string;
actions?: React.ReactNode;
defaultTab?: string;
containerClassName?: string;
@@ -23,6 +24,8 @@ type TTabsProps = {
tabListClassName?: string;
tabClassName?: string;
tabPanelClassName?: string;
size?: "sm" | "md" | "lg";
storeInLocalStorage?: boolean;
};
export const Tabs: FC<TTabsProps> = (props: TTabsProps) => {
@@ -36,15 +39,28 @@ export const Tabs: FC<TTabsProps> = (props: TTabsProps) => {
tabListClassName = "",
tabClassName = "",
tabPanelClassName = "",
size = "md",
storeInLocalStorage = true,
} = props;
// local storage
const { storedValue, setValue } = useLocalStorage(`tab-${storageKey}`, defaultTab);
const { storedValue, setValue } = useLocalStorage(
storeInLocalStorage && storageKey ? `tab-${storageKey}` : `tab-${tabs[0]?.key}`,
defaultTab
);
// state
const [selectedTab, setSelectedTab] = useState(storedValue ?? defaultTab);
useEffect(() => {
if (storeInLocalStorage) {
setValue(selectedTab);
}
}, [selectedTab, setValue, storeInLocalStorage, storageKey]);
const currentTabIndex = (tabKey: string): number => tabs.findIndex((tab) => tab.key === tabKey);
return (
<div className="flex flex-col w-full h-full">
<Tab.Group defaultIndex={currentTabIndex(storedValue ?? defaultTab)}>
<Tab.Group defaultIndex={currentTabIndex(selectedTab)}>
<div className={cn("flex flex-col w-full h-full gap-2", containerClassName)}>
<div className={cn("flex w-full items-center gap-4", tabListContainerClassName)}>
<Tab.List
@@ -64,12 +80,18 @@ export const Tabs: FC<TTabsProps> = (props: TTabsProps) => {
: tab.disabled
? "text-custom-text-400 cursor-not-allowed"
: "text-custom-text-400 hover:text-custom-text-300 hover:bg-custom-background-80/60",
{
"text-xs": size === "sm",
"text-sm": size === "md",
"text-base": size === "lg",
},
tabClassName
)
}
key={tab.key}
onClick={() => {
if (!tab.disabled) setValue(tab.key);
if (!tab.disabled) setSelectedTab(tab.key);
tab.onClick?.();
}}
disabled={tab.disabled}
>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/utils",
"version": "0.24.0",
"version": "0.24.1",
"description": "Helper functions shared across multiple apps internally",
"private": true,
"main": "./dist/index.js",
@@ -26,7 +26,7 @@ import { CoreRootStore } from "../root.store";
// constants
// helpers
export type TIssueDisplayFilterOptions = Exclude<TIssueGroupByOptions, null> | "target_date";
export type TIssueDisplayFilterOptions = Exclude<TIssueGroupByOptions, null | "team_project"> | "target_date";
export enum EIssueGroupedAction {
ADD = "ADD",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "space",
"version": "0.24.0",
"version": "0.24.1",
"private": true,
"scripts": {
"dev": "turbo run develop",
@@ -1,7 +1,7 @@
"use client";
// components
import { NotificationsSidebarRoot } from "@/plane-web/components/workspace-notifications";
import { NotificationsSidebarRoot } from "@/components/workspace-notifications";
export default function ProjectInboxIssuesLayout({ children }: { children: React.ReactNode }) {
return (
@@ -77,7 +77,12 @@ const CycleDetailPage = observer(() => {
"0px 1px 4px 0px rgba(0, 0, 0, 0.06), 0px 2px 4px 0px rgba(16, 24, 40, 0.06), 0px 1px 8px -1px rgba(16, 24, 40, 0.06)",
}}
>
<CycleDetailsSidebar handleClose={toggleSidebar} />
<CycleDetailsSidebar
handleClose={toggleSidebar}
cycleId={cycleId.toString()}
projectId={projectId.toString()}
workspaceSlug={workspaceSlug.toString()}
/>
</div>
)}
</div>
@@ -1,130 +1,3 @@
"use client";
import { IssuesHeader } from "@/plane-web/components/issues";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// icons
import { Briefcase, Circle, ExternalLink } from "lucide-react";
// ui
import { Breadcrumbs, Button, LayersIcon, Tooltip, Header } from "@plane/ui";
// components
import { BreadcrumbLink, CountChip, Logo } from "@/components/common";
// constants
import HeaderFilters from "@/components/issues/filters";
import { EIssuesStoreType } from "@/constants/issue";
// helpers
import { SPACE_BASE_PATH, SPACE_BASE_URL } from "@/helpers/common.helper";
// hooks
import { useEventTracker, useProject, useCommandPalette, useUserPermissions } from "@/hooks/store";
import { useIssues } from "@/hooks/store/use-issues";
import { useAppRouter } from "@/hooks/use-app-router";
import { usePlatformOS } from "@/hooks/use-platform-os";
import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions";
export const ProjectIssuesHeader = observer(() => {
// router
const router = useAppRouter();
const { workspaceSlug, projectId } = useParams() as { workspaceSlug: string; projectId: string };
// store hooks
const {
issues: { getGroupIssueCount },
} = useIssues(EIssuesStoreType.PROJECT);
const { currentProjectDetails, loader } = useProject();
const { toggleCreateIssueModal } = useCommandPalette();
const { setTrackElement } = useEventTracker();
const { allowPermissions } = useUserPermissions();
const { isMobile } = usePlatformOS();
const SPACE_APP_URL = (SPACE_BASE_URL.trim() === "" ? window.location.origin : SPACE_BASE_URL) + SPACE_BASE_PATH;
const publishedURL = `${SPACE_APP_URL}/issues/${currentProjectDetails?.anchor}`;
const issuesCount = getGroupIssueCount(undefined, undefined, false);
const canUserCreateIssue = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.PROJECT
);
return (
<Header>
<Header.LeftItem>
<div className="flex items-center gap-2.5">
<Breadcrumbs onBack={() => router.back()} isLoading={loader}>
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<BreadcrumbLink
label={currentProjectDetails?.name ?? "Project"}
icon={
currentProjectDetails ? (
currentProjectDetails && (
<span className="grid place-items-center flex-shrink-0 h-4 w-4">
<Logo logo={currentProjectDetails?.logo_props} size={16} />
</span>
)
) : (
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded uppercase">
<Briefcase className="h-4 w-4" />
</span>
)
}
/>
}
/>
<Breadcrumbs.BreadcrumbItem
type="text"
link={<BreadcrumbLink label="Issues" icon={<LayersIcon className="h-4 w-4 text-custom-text-300" />} />}
/>
</Breadcrumbs>
{issuesCount && issuesCount > 0 ? (
<Tooltip
isMobile={isMobile}
tooltipContent={`There are ${issuesCount} ${issuesCount > 1 ? "issues" : "issue"} in this project`}
position="bottom"
>
<CountChip count={issuesCount} />
</Tooltip>
) : null}
</div>
{currentProjectDetails?.anchor ? (
<a
href={publishedURL}
className="group flex items-center gap-1.5 rounded bg-custom-primary-100/10 px-2.5 py-1 text-xs font-medium text-custom-primary-100"
target="_blank"
rel="noopener noreferrer"
>
<Circle className="h-1.5 w-1.5 fill-custom-primary-100" strokeWidth={2} />
Public
<ExternalLink className="hidden h-3 w-3 group-hover:block" strokeWidth={2} />
</a>
) : (
<></>
)}
</Header.LeftItem>
<Header.RightItem>
<div className="hidden gap-3 md:flex">
<HeaderFilters
projectId={projectId}
currentProjectDetails={currentProjectDetails}
workspaceSlug={workspaceSlug}
canUserCreateIssue={canUserCreateIssue}
/>
</div>
{canUserCreateIssue ? (
<Button
onClick={() => {
setTrackElement("Project issues page");
toggleCreateIssueModal(true, EIssuesStoreType.PROJECT);
}}
size="sm"
>
<div className="hidden sm:block">Add</div> Issue
</Button>
) : (
<></>
)}
</Header.RightItem>
</Header>
);
});
export const ProjectIssuesHeader = () => <IssuesHeader />;
+13 -4
View File
@@ -21,6 +21,7 @@ import { useFavorite } from "@/hooks/store/use-favorite";
import useSize from "@/hooks/use-window-size";
// plane web components
import { SidebarAppSwitcher } from "@/plane-web/components/sidebar";
import { SidebarTeamsList } from "@/plane-web/components/workspace/sidebar/teams-sidebar-list";
import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions";
export const AppSidebar: FC = observer(() => {
@@ -47,7 +48,7 @@ export const AppSidebar: FC = observer(() => {
});
useEffect(() => {
if (windowSize[0] < 768) !sidebarCollapsed && toggleSidebar();
if (windowSize[0] < 768 && !sidebarCollapsed) toggleSidebar();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [windowSize]);
@@ -73,9 +74,12 @@ export const AppSidebar: FC = observer(() => {
"px-4": !sidebarCollapsed,
})}
>
{/* Workspace switcher and settings */}
<SidebarDropdown />
<div className="flex-shrink-0 h-4" />
<SidebarAppSwitcher />
{/* App switcher */}
{canPerformWorkspaceMemberActions && <SidebarAppSwitcher />}
{/* Quick actions */}
<SidebarQuickActions />
</div>
<hr
@@ -88,18 +92,23 @@ export const AppSidebar: FC = observer(() => {
"vertical-scrollbar px-4": !sidebarCollapsed,
})}
>
{/* User Menu */}
<SidebarUserMenu />
{/* Workspace Menu */}
<SidebarWorkspaceMenu />
<hr
className={cn("flex-shrink-0 border-custom-sidebar-border-300 h-[0.5px] w-3/5 mx-auto my-1", {
"opacity-0": !sidebarCollapsed,
})}
/>
{/* Favorites Menu */}
{canPerformWorkspaceMemberActions && !isFavoriteEmpty && <SidebarFavoritesMenu />}
{/* Teams List */}
<SidebarTeamsList />
{/* Projects List */}
<SidebarProjectsList />
</div>
{/* Help Section */}
<SidebarHelpSection />
</div>
</div>
@@ -0,0 +1,3 @@
export * from "./workspace-level";
export * from "./project-level";
export * from "./issue-level";
@@ -0,0 +1,73 @@
import { observer } from "mobx-react";
import { useParams, usePathname } from "next/navigation";
import useSWR from "swr";
// components
import { BulkDeleteIssuesModal } from "@/components/core";
import { CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues";
// constants
import { ISSUE_DETAILS } from "@/constants/fetch-keys";
// hooks
import { useCommandPalette, useUser } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router";
import { useIssuesStore } from "@/hooks/use-issue-layout-store";
// services
import { IssueService } from "@/services/issue";
// services
const issueService = new IssueService();
export const IssueLevelModals = observer(() => {
// router
const pathname = usePathname();
const { workspaceSlug, projectId, issueId, cycleId, moduleId } = useParams();
const router = useAppRouter();
// store hooks
const { data: currentUser } = useUser();
const {
issues: { removeIssue },
} = useIssuesStore();
const {
isCreateIssueModalOpen,
toggleCreateIssueModal,
isDeleteIssueModalOpen,
toggleDeleteIssueModal,
isBulkDeleteIssueModalOpen,
toggleBulkDeleteIssueModal,
} = useCommandPalette();
// derived values
const isDraftIssue = pathname?.includes("draft-issues") || false;
const { data: issueDetails } = useSWR(
workspaceSlug && projectId && issueId ? ISSUE_DETAILS(issueId as string) : null,
workspaceSlug && projectId && issueId
? () => issueService.retrieve(workspaceSlug as string, projectId as string, issueId as string)
: null
);
return (
<>
<CreateUpdateIssueModal
isOpen={isCreateIssueModalOpen}
onClose={() => toggleCreateIssueModal(false)}
data={cycleId ? { cycle_id: cycleId.toString() } : moduleId ? { module_ids: [moduleId.toString()] } : undefined}
isDraft={isDraftIssue}
/>
{workspaceSlug && projectId && issueId && issueDetails && (
<DeleteIssueModal
handleClose={() => toggleDeleteIssueModal(false)}
isOpen={isDeleteIssueModalOpen}
data={issueDetails}
onSubmit={async () => {
await removeIssue(workspaceSlug.toString(), projectId.toString(), issueId.toString());
router.push(`/${workspaceSlug}/projects/${projectId}/issues`);
}}
/>
)}
<BulkDeleteIssuesModal
isOpen={isBulkDeleteIssueModalOpen}
onClose={() => toggleBulkDeleteIssueModal(false)}
user={currentUser}
/>
</>
);
});
@@ -0,0 +1,59 @@
import { observer } from "mobx-react";
// components
import { CycleCreateUpdateModal } from "@/components/cycles";
import { CreateUpdateModuleModal } from "@/components/modules";
import { CreatePageModal } from "@/components/pages";
import { CreateUpdateProjectViewModal } from "@/components/views";
// hooks
import { useCommandPalette } from "@/hooks/store";
export type TProjectLevelModalsProps = {
workspaceSlug: string;
projectId: string;
};
export const ProjectLevelModals = observer((props: TProjectLevelModalsProps) => {
const { workspaceSlug, projectId } = props;
// store hooks
const {
isCreateCycleModalOpen,
toggleCreateCycleModal,
isCreateModuleModalOpen,
toggleCreateModuleModal,
isCreateViewModalOpen,
toggleCreateViewModal,
createPageModal,
toggleCreatePageModal,
} = useCommandPalette();
return (
<>
<CycleCreateUpdateModal
isOpen={isCreateCycleModalOpen}
handleClose={() => toggleCreateCycleModal(false)}
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
/>
<CreateUpdateModuleModal
isOpen={isCreateModuleModalOpen}
onClose={() => toggleCreateModuleModal(false)}
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
/>
<CreateUpdateProjectViewModal
isOpen={isCreateViewModalOpen}
onClose={() => toggleCreateViewModal(false)}
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
/>
<CreatePageModal
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
isModalOpen={createPageModal.isOpen}
pageAccess={createPageModal.pageAccess}
handleModalClose={() => toggleCreatePageModal({ isOpen: false })}
redirectionEnabled
/>
</>
);
});
@@ -0,0 +1,25 @@
import { observer } from "mobx-react";
// components
import { CreateProjectModal } from "@/components/project";
// hooks
import { useCommandPalette } from "@/hooks/store";
export type TWorkspaceLevelModalsProps = {
workspaceSlug: string;
};
export const WorkspaceLevelModals = observer((props: TWorkspaceLevelModalsProps) => {
const { workspaceSlug } = props;
// store hooks
const { isCreateProjectModalOpen, toggleCreateProjectModal } = useCommandPalette();
return (
<>
<CreateProjectModal
isOpen={isCreateProjectModalOpen}
onClose={() => toggleCreateProjectModal(false)}
workspaceSlug={workspaceSlug.toString()}
/>
</>
);
});
+65 -52
View File
@@ -1,5 +1,6 @@
"use client";
import { useMemo } from "react";
import { observer } from "mobx-react";
import { Disclosure } from "@headlessui/react";
// ui
@@ -22,68 +23,80 @@ import { ActiveCycleIssueDetails } from "@/store/issue/cycle";
interface IActiveCycleDetails {
workspaceSlug: string;
projectId: string;
cycleId?: string;
showHeader?: boolean;
}
export const ActiveCycleRoot: React.FC<IActiveCycleDetails> = observer((props) => {
const { workspaceSlug, projectId } = props;
const { currentProjectActiveCycle, currentProjectActiveCycleId } = useCycle();
const { workspaceSlug, projectId, cycleId: propsCycleId, showHeader = true } = props;
const { currentProjectActiveCycleId } = useCycle();
// derived values
const cycleId = propsCycleId ?? currentProjectActiveCycleId;
// fetch cycle details
const {
handleFiltersUpdate,
cycle: activeCycle,
cycleIssueDetails,
} = useCyclesDetails({ workspaceSlug, projectId, cycleId: currentProjectActiveCycleId });
} = useCyclesDetails({ workspaceSlug, projectId, cycleId });
const ActiveCyclesComponent = useMemo(
() => (
<>
{!cycleId || !activeCycle ? (
<EmptyState type={EmptyStateType.PROJECT_CYCLE_ACTIVE} size="sm" />
) : (
<div className="flex flex-col border-b border-custom-border-200">
{cycleId && (
<CyclesListItem
key={cycleId}
cycleId={cycleId}
workspaceSlug={workspaceSlug}
projectId={projectId}
className="!border-b-transparent"
/>
)}
<Row className="bg-custom-background-100 pt-3 pb-6">
<div className="grid grid-cols-1 bg-custom-background-100 gap-3 lg:grid-cols-2 xl:grid-cols-3">
<ActiveCycleProgress
handleFiltersUpdate={handleFiltersUpdate}
projectId={projectId}
workspaceSlug={workspaceSlug}
cycle={activeCycle}
/>
<ActiveCycleProductivity workspaceSlug={workspaceSlug} projectId={projectId} cycle={activeCycle} />
<ActiveCycleStats
workspaceSlug={workspaceSlug}
projectId={projectId}
cycle={activeCycle}
cycleId={cycleId}
handleFiltersUpdate={handleFiltersUpdate}
cycleIssueDetails={cycleIssueDetails as ActiveCycleIssueDetails}
/>
</div>
</Row>
</div>
)}
</>
),
[cycleId, activeCycle, workspaceSlug, projectId, handleFiltersUpdate, cycleIssueDetails]
);
return (
<>
<Disclosure as="div" className="flex flex-shrink-0 flex-col" defaultOpen>
{({ open }) => (
<>
<Disclosure.Button className="sticky top-0 z-[2] w-full flex-shrink-0 border-b border-custom-border-200 bg-custom-background-90 cursor-pointer">
<CycleListGroupHeader title="Active cycle" type="current" isExpanded={open} />
</Disclosure.Button>
<Disclosure.Panel>
{!currentProjectActiveCycle ? (
<EmptyState type={EmptyStateType.PROJECT_CYCLE_ACTIVE} size="sm" />
) : (
<div className="flex flex-col border-b border-custom-border-200">
{currentProjectActiveCycleId && (
<CyclesListItem
key={currentProjectActiveCycleId}
cycleId={currentProjectActiveCycleId}
workspaceSlug={workspaceSlug}
projectId={projectId}
className="!border-b-transparent"
/>
)}
<Row className="bg-custom-background-100 pt-3 pb-6">
<div className="grid grid-cols-1 bg-custom-background-100 gap-3 lg:grid-cols-2 xl:grid-cols-3">
<ActiveCycleProgress
handleFiltersUpdate={handleFiltersUpdate}
projectId={projectId}
workspaceSlug={workspaceSlug}
cycle={activeCycle}
/>
<ActiveCycleProductivity
workspaceSlug={workspaceSlug}
projectId={projectId}
cycle={activeCycle}
/>
<ActiveCycleStats
workspaceSlug={workspaceSlug}
projectId={projectId}
cycle={activeCycle}
cycleId={currentProjectActiveCycleId}
handleFiltersUpdate={handleFiltersUpdate}
cycleIssueDetails={cycleIssueDetails as ActiveCycleIssueDetails}
/>
</div>
</Row>
</div>
)}
</Disclosure.Panel>
</>
)}
</Disclosure>
{showHeader ? (
<Disclosure as="div" className="flex flex-shrink-0 flex-col" defaultOpen>
{({ open }) => (
<>
<Disclosure.Button className="sticky top-0 z-[2] w-full flex-shrink-0 border-b border-custom-border-200 bg-custom-background-90 cursor-pointer">
<CycleListGroupHeader title="Active cycle" type="current" isExpanded={open} />
</Disclosure.Button>
<Disclosure.Panel>{ActiveCyclesComponent}</Disclosure.Panel>
</>
)}
</Disclosure>
) : (
<>{ActiveCyclesComponent}</>
)}
</>
);
});
@@ -1,2 +1,3 @@
export * from "./applied-filters";
export * from "./issue-types";
export * from "./team-project";
@@ -0,0 +1,12 @@
"use client";
import React from "react";
import { observer } from "mobx-react";
type Props = {
appliedFilters: string[] | null;
handleUpdate: (val: string) => void;
searchQuery: string;
};
export const FilterTeamProjects: React.FC<Props> = observer(() => null);
+130
View File
@@ -0,0 +1,130 @@
"use client";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// icons
import { Briefcase, Circle, ExternalLink } from "lucide-react";
// ui
import { Breadcrumbs, Button, LayersIcon, Tooltip, Header } from "@plane/ui";
// components
import { BreadcrumbLink, CountChip, Logo } from "@/components/common";
// constants
import HeaderFilters from "@/components/issues/filters";
import { EIssuesStoreType } from "@/constants/issue";
// helpers
import { SPACE_BASE_PATH, SPACE_BASE_URL } from "@/helpers/common.helper";
// hooks
import { useEventTracker, useProject, useCommandPalette, useUserPermissions } from "@/hooks/store";
import { useIssues } from "@/hooks/store/use-issues";
import { useAppRouter } from "@/hooks/use-app-router";
import { usePlatformOS } from "@/hooks/use-platform-os";
import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions";
export const IssuesHeader = observer(() => {
// router
const router = useAppRouter();
const { workspaceSlug, projectId } = useParams() as { workspaceSlug: string; projectId: string };
// store hooks
const {
issues: { getGroupIssueCount },
} = useIssues(EIssuesStoreType.PROJECT);
const { currentProjectDetails, loader } = useProject();
const { toggleCreateIssueModal } = useCommandPalette();
const { setTrackElement } = useEventTracker();
const { allowPermissions } = useUserPermissions();
const { isMobile } = usePlatformOS();
const SPACE_APP_URL = (SPACE_BASE_URL.trim() === "" ? window.location.origin : SPACE_BASE_URL) + SPACE_BASE_PATH;
const publishedURL = `${SPACE_APP_URL}/issues/${currentProjectDetails?.anchor}`;
const issuesCount = getGroupIssueCount(undefined, undefined, false);
const canUserCreateIssue = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.PROJECT
);
return (
<Header>
<Header.LeftItem>
<div className="flex items-center gap-2.5">
<Breadcrumbs onBack={() => router.back()} isLoading={loader}>
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<BreadcrumbLink
label={currentProjectDetails?.name ?? "Project"}
icon={
currentProjectDetails ? (
currentProjectDetails && (
<span className="grid place-items-center flex-shrink-0 h-4 w-4">
<Logo logo={currentProjectDetails?.logo_props} size={16} />
</span>
)
) : (
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded uppercase">
<Briefcase className="h-4 w-4" />
</span>
)
}
/>
}
/>
<Breadcrumbs.BreadcrumbItem
type="text"
link={<BreadcrumbLink label="Issues" icon={<LayersIcon className="h-4 w-4 text-custom-text-300" />} />}
/>
</Breadcrumbs>
{issuesCount && issuesCount > 0 ? (
<Tooltip
isMobile={isMobile}
tooltipContent={`There are ${issuesCount} ${issuesCount > 1 ? "issues" : "issue"} in this project`}
position="bottom"
>
<CountChip count={issuesCount} />
</Tooltip>
) : null}
</div>
{currentProjectDetails?.anchor ? (
<a
href={publishedURL}
className="group flex items-center gap-1.5 rounded bg-custom-primary-100/10 px-2.5 py-1 text-xs font-medium text-custom-primary-100"
target="_blank"
rel="noopener noreferrer"
>
<Circle className="h-1.5 w-1.5 fill-custom-primary-100" strokeWidth={2} />
Public
<ExternalLink className="hidden h-3 w-3 group-hover:block" strokeWidth={2} />
</a>
) : (
<></>
)}
</Header.LeftItem>
<Header.RightItem>
<div className="hidden gap-3 md:flex">
<HeaderFilters
projectId={projectId}
currentProjectDetails={currentProjectDetails}
workspaceSlug={workspaceSlug}
canUserCreateIssue={canUserCreateIssue}
/>
</div>
{canUserCreateIssue ? (
<Button
onClick={() => {
setTrackElement("Project issues page");
toggleCreateIssueModal(true, EIssuesStoreType.PROJECT);
}}
size="sm"
>
<div className="hidden sm:block">Add</div> Issue
</Button>
) : (
<></>
)}
</Header.RightItem>
</Header>
);
});
+1
View File
@@ -4,3 +4,4 @@ export * from "./issue-modal";
export * from "./issue-details";
export * from "./quick-add";
export * from "./filters";
export * from "./header";
@@ -0,0 +1,4 @@
// types
import { IGroupByColumn } from "@plane/types";
export const getTeamProjectColumns = (): IGroupByColumn[] | undefined => undefined;
+1
View File
@@ -1,2 +1,3 @@
export * from "./editor";
export * from "./modals";
export * from "./extra-actions";
+1
View File
@@ -0,0 +1 @@
export * from "./move-page-modal";
@@ -0,0 +1,10 @@
// store types
import { IPage } from "@/store/pages/page";
export type TMovePageModalProps = {
isOpen: boolean;
onClose: () => void;
page: IPage;
};
export const MovePageModal: React.FC<TMovePageModalProps> = () => null;
@@ -1 +1 @@
export * from './root'
export * from "./notification-card/root";
@@ -0,0 +1 @@
export const SidebarTeamsList = () => null;

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