Merge branch 'preview' of github.com:makeplane/plane-ee into develop

This commit is contained in:
sriram veeraghanta
2024-08-19 16:31:03 +05:30
164 changed files with 2908 additions and 750 deletions

View File

@@ -1,4 +1,4 @@
FROM python:3.11.1-alpine3.17 AS backend
FROM python:3.12.5-alpine AS backend
# set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
@@ -7,23 +7,23 @@ ENV PIP_DISABLE_PIP_VERSION_CHECK=1
WORKDIR /code
RUN apk --no-cache add \
"libpq~=15" \
"libxslt~=1.1" \
"nodejs-current~=19" \
"xmlsec~=1.2"
RUN apk add --no-cache \
"libpq" \
"libxslt" \
"nodejs-current" \
"xmlsec"
COPY requirements.txt ./
COPY requirements ./requirements
RUN apk add --no-cache libffi-dev
RUN apk add --no-cache --virtual .build-deps \
"bash~=5.2" \
"g++~=12.2" \
"gcc~=12.2" \
"cargo~=1.64" \
"git~=2" \
"make~=4.3" \
"postgresql13-dev~=13" \
"g++" \
"gcc" \
"cargo" \
"git" \
"make" \
"postgresql-dev" \
"libc-dev" \
"linux-headers" \
&& \

View File

@@ -1,4 +1,4 @@
FROM python:3.11.1-alpine3.17 AS backend
FROM python:3.12.5-alpine AS backend
# set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
@@ -7,18 +7,18 @@ ENV PIP_DISABLE_PIP_VERSION_CHECK=1
RUN apk --no-cache add \
"bash~=5.2" \
"libpq~=15" \
"libxslt~=1.1" \
"nodejs-current~=19" \
"xmlsec~=1.2" \
"libpq" \
"libxslt" \
"nodejs-current" \
"xmlsec" \
"libffi-dev" \
"bash~=5.2" \
"g++~=12.2" \
"gcc~=12.2" \
"cargo~=1.64" \
"git~=2" \
"make~=4.3" \
"postgresql13-dev~=13" \
"g++" \
"gcc" \
"cargo" \
"git" \
"make" \
"postgresql-dev" \
"libc-dev" \
"linux-headers" \
"xmlsec-dev"

View File

@@ -47,6 +47,12 @@ class IssueSerializer(BaseSerializer):
write_only=True,
required=False,
)
type_id = serializers.PrimaryKeyRelatedField(
source="type",
queryset=IssueType.objects.all(),
required=False,
allow_null=True,
)
class Meta:
model = Issue
@@ -130,19 +136,14 @@ class IssueSerializer(BaseSerializer):
workspace_id = self.context["workspace_id"]
default_assignee_id = self.context["default_assignee_id"]
issue_type_id = validated_data.get("type_id")
issue_type = validated_data.pop("type", None)
if issue_type_id:
# Check if issue type is valid
issue_type = IssueType.objects.filter(
project_id=project_id, id=issue_type_id, is_active=True
).first()
else:
if not issue_type:
# Get default issue type
issue_type = IssueType.objects.filter(
project_id=project_id,
is_default=True,
project_issue_types__project_id=project_id, is_default=True
).first()
issue_type = issue_type
issue = Issue.objects.create(
**validated_data,

View File

@@ -53,6 +53,7 @@ class IssueFlatSerializer(BaseSerializer):
"sequence_id",
"sort_order",
"is_draft",
"type_id",
]
@@ -147,7 +148,7 @@ class IssueCreateSerializer(BaseSerializer):
if not issue_type:
# Get default issue type
issue_type = IssueType.objects.filter(
project_id=project_id, is_default=True
project_issue_types__project_id=project_id, is_default=True
).first()
issue_type = issue_type

View File

@@ -269,6 +269,7 @@ class IssueDraftViewSet(BaseViewSet):
"link_count",
"is_draft",
"archived_at",
"type_id",
)
.first()
)

View File

@@ -143,6 +143,7 @@ class IssueRelationViewSet(BaseViewSet):
"created_by",
"updated_by",
"relation_type",
"type_id",
]
response_data = {

View File

@@ -133,6 +133,7 @@ class SubIssuesEndpoint(BaseAPIView):
"link_count",
"is_draft",
"archived_at",
"type_id",
)
datetime_fields = ["created_at", "updated_at"]
sub_issues = user_timezone_converter(

View File

@@ -91,6 +91,7 @@ class GlobalSearchEndpoint(BaseAPIView):
"project__identifier",
"project_id",
"workspace__slug",
"type_id",
)
def filter_cycles(self, query, slug, project_id, workspace_search):
@@ -368,6 +369,7 @@ class SearchEndpoint(BaseAPIView):
"project_id",
"priority",
"state_id",
"type_id",
)[:count]
)
return Response(issues, status=status.HTTP_200_OK)

View File

@@ -95,6 +95,7 @@ class IssueSearchEndpoint(BaseAPIView):
"state__name",
"state__group",
"state__color",
"type_id",
),
status=status.HTTP_200_OK,
)

View File

@@ -119,6 +119,7 @@ class WorkspaceEntitySearchEndpoint(BaseAPIView):
"project_id",
"priority",
"state_id",
"type_id",
)[:count]
)

View File

@@ -109,6 +109,6 @@ from .dashboard import Dashboard, DashboardWidget, Widget
from .favorite import UserFavorite
from .issue_type import IssueType
from .issue_type import IssueType, ProjectIssueType
from .recent_visit import UserRecentVisit
from .recent_visit import UserRecentVisit

View File

@@ -1,4 +1,4 @@
# Generated by Django 4.2.14 on 2024-07-31 14:22
# Generated by Django 4.2.14 on 2024-08-02 09:52
from django.conf import settings
from django.db import migrations, models
@@ -13,43 +13,8 @@ class Migration(migrations.Migration):
]
operations = [
migrations.AddField(
model_name="issueproperty",
name="deleted_at",
field=models.DateTimeField(
blank=True, null=True, verbose_name="Deleted At"
),
),
migrations.AddField(
model_name="issuepropertyactivity",
name="deleted_at",
field=models.DateTimeField(
blank=True, null=True, verbose_name="Deleted At"
),
),
migrations.AddField(
model_name="issuepropertyoption",
name="deleted_at",
field=models.DateTimeField(
blank=True, null=True, verbose_name="Deleted At"
),
),
migrations.AddField(
model_name="issuepropertyvalue",
name="deleted_at",
field=models.DateTimeField(
blank=True, null=True, verbose_name="Deleted At"
),
),
migrations.AddField(
model_name="issueworklog",
name="deleted_at",
field=models.DateTimeField(
blank=True, null=True, verbose_name="Deleted At"
),
),
migrations.CreateModel(
name="WorkspaceFeature",
name="ProjectAttribute",
fields=[
(
"created_at",
@@ -81,43 +46,27 @@ class Migration(migrations.Migration):
),
),
(
"is_project_grouping_enabled",
models.BooleanField(default=False),
),
(
"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",
),
),
(
"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.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="features",
to="db.workspace",
"priority",
models.CharField(
choices=[
("none", "None"),
("low", "Low"),
("medium", "Medium"),
("high", "High"),
("urgent", "Urgent"),
],
default="none",
max_length=50,
),
),
("start_date", models.DateTimeField(blank=True, null=True)),
("target_date", models.DateTimeField(blank=True, null=True)),
],
options={
"verbose_name": "Workspace Feature",
"verbose_name_plural": "Workspace Features",
"db_table": "workspace_features",
"ordering": ("-created_at",),
"verbose_name": "Project Attribute",
"verbose_name_plural": "Project Attributes",
"db_table": "project_attributes",
"ordering": ("created_at",),
},
),
migrations.CreateModel(
@@ -180,45 +129,16 @@ class Migration(migrations.Migration):
"external_id",
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",
),
),
(
"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="project_states",
to="db.workspace",
),
),
],
options={
"verbose_name": "Project State",
"verbose_name_plural": "Project States",
"db_table": "project_states",
"ordering": ("sequence",),
"unique_together": {("name", "workspace")},
},
),
migrations.CreateModel(
name="ProjectAttribute",
name="WorkspaceFeature",
fields=[
(
"created_at",
@@ -250,72 +170,206 @@ class Migration(migrations.Migration):
),
),
(
"priority",
models.CharField(
choices=[
("none", "None"),
("low", "Low"),
("medium", "Medium"),
("high", "High"),
("urgent", "Urgent"),
],
default="none",
max_length=50,
),
),
("start_date", models.DateTimeField(blank=True, null=True)),
("target_date", models.DateTimeField(blank=True, 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",
),
),
(
"project",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="project_%(class)s",
to="db.project",
),
),
(
"state",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="project_attributes",
to="ee.projectstate",
),
),
(
"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",
),
"is_project_grouping_enabled",
models.BooleanField(default=False),
),
],
options={
"verbose_name": "Project Attribute",
"verbose_name_plural": "Project Attributes",
"db_table": "project_attributes",
"ordering": ("created_at",),
"verbose_name": "Workspace Feature",
"verbose_name_plural": "Workspace Features",
"db_table": "workspace_features",
"ordering": ("-created_at",),
},
),
migrations.AlterUniqueTogether(
name="issueproperty",
unique_together=set(),
),
migrations.AlterUniqueTogether(
name="issuepropertyoption",
unique_together=set(),
),
migrations.AddField(
model_name="issueproperty",
name="deleted_at",
field=models.DateTimeField(
blank=True, null=True, verbose_name="Deleted At"
),
),
migrations.AddField(
model_name="issuepropertyactivity",
name="deleted_at",
field=models.DateTimeField(
blank=True, null=True, verbose_name="Deleted At"
),
),
migrations.AddField(
model_name="issuepropertyoption",
name="deleted_at",
field=models.DateTimeField(
blank=True, null=True, verbose_name="Deleted At"
),
),
migrations.AddField(
model_name="issuepropertyvalue",
name="deleted_at",
field=models.DateTimeField(
blank=True, null=True, verbose_name="Deleted At"
),
),
migrations.AddField(
model_name="issueworklog",
name="deleted_at",
field=models.DateTimeField(
blank=True, null=True, verbose_name="Deleted At"
),
),
migrations.AlterUniqueTogether(
name="issueproperty",
unique_together={("name", "issue_type", "deleted_at")},
),
migrations.AlterUniqueTogether(
name="issuepropertyoption",
unique_together={("name", "property", "deleted_at")},
),
migrations.AddConstraint(
model_name="issueproperty",
constraint=models.UniqueConstraint(
condition=models.Q(("deleted_at__isnull", True)),
fields=("name", "issue_type"),
name="issue_property_unique_name_project_when_deleted_at_null",
),
),
migrations.AddConstraint(
model_name="issuepropertyoption",
constraint=models.UniqueConstraint(
condition=models.Q(("deleted_at__isnull", True)),
fields=("name", "property"),
name="issue_property_option_unique_name_project_when_deleted_at_null",
),
),
migrations.AddField(
model_name="workspacefeature",
name="created_by",
field=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",
),
),
migrations.AddField(
model_name="workspacefeature",
name="updated_by",
field=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",
),
),
migrations.AddField(
model_name="workspacefeature",
name="workspace",
field=models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="features",
to="db.workspace",
),
),
migrations.AddField(
model_name="projectstate",
name="created_by",
field=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",
),
),
migrations.AddField(
model_name="projectstate",
name="updated_by",
field=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",
),
),
migrations.AddField(
model_name="projectstate",
name="workspace",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="project_states",
to="db.workspace",
),
),
migrations.AddField(
model_name="projectattribute",
name="created_by",
field=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",
),
),
migrations.AddField(
model_name="projectattribute",
name="project",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="project_%(class)s",
to="db.project",
),
),
migrations.AddField(
model_name="projectattribute",
name="state",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="project_attributes",
to="ee.projectstate",
),
),
migrations.AddField(
model_name="projectattribute",
name="updated_by",
field=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",
),
),
migrations.AddField(
model_name="projectattribute",
name="workspace",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="workspace_%(class)s",
to="db.workspace",
),
),
migrations.AddConstraint(
model_name="projectstate",
constraint=models.UniqueConstraint(
condition=models.Q(("deleted_at__isnull", True)),
fields=("name", "workspace"),
name="project_state_unique_name_project_when_deleted_at_null",
),
),
migrations.AlterUniqueTogether(
name="projectstate",
unique_together={("name", "workspace", "deleted_at")},
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 4.2.14 on 2024-08-14 10:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("ee", "0004_workspacelicense_trial_end_date"),
]
operations = [
migrations.AddField(
model_name="workspacelicense",
name="has_activated_free_trial",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="workspacelicense",
name="has_added_payment_method",
field=models.BooleanField(default=False),
),
]

View File

@@ -51,6 +51,10 @@ class WorkspaceLicense(BaseModel):
last_synced_at = models.DateTimeField(default=timezone.now)
# trial end date
trial_end_date = models.DateTimeField(null=True, blank=True)
# has activated free trial
has_activated_free_trial = models.BooleanField(default=False)
# is payment method added
has_added_payment_method = models.BooleanField(default=False)
class Meta:
verbose_name = "Workspace License"

View File

@@ -13,6 +13,10 @@ from plane.ee.models import (
class IssueTypeSerializer(BaseSerializer):
issue_exists = serializers.BooleanField(read_only=True)
project_ids = serializers.ListField(
child=serializers.UUIDField(),
required=False,
)
class Meta:
model = IssueType
@@ -34,7 +38,6 @@ class IssuePropertySerializer(BaseSerializer):
"name",
"issue_type",
"workspace",
"project",
"deleted_at",
]
@@ -47,7 +50,6 @@ class IssuePropertyOptionSerializer(BaseSerializer):
read_only_fields = [
"property",
"workspace",
"project",
"deleted_at",
]

View File

@@ -19,6 +19,11 @@ class IssuePropertyActivityEndpoint(BaseAPIView):
@check_feature_flag(FeatureFlag.ISSUE_TYPE_DISPLAY)
def get(self, request, slug, project_id, issue_id):
# Get the filters
filters = {}
if request.GET.get("created_at__gt", None) is not None:
filters = {"created_at__gt": request.GET.get("created_at__gt")}
# Get the order by
order_by = request.GET.get("order_by", "-created_at")
@@ -27,6 +32,7 @@ class IssuePropertyActivityEndpoint(BaseAPIView):
workspace__slug=slug,
project_id=project_id,
issue_id=issue_id,
**filters
).order_by(order_by)
# Serialize the data

View File

@@ -1,5 +1,7 @@
# Django imports
from django.db.models import Exists, OuterRef
from django.db.models import Exists, OuterRef, Subquery
from django.db.models.functions import Coalesce
from django.contrib.postgres.aggregates import ArrayAgg
# Third party imports
from rest_framework import status
@@ -7,7 +9,7 @@ from rest_framework.response import Response
# Module imports
from plane.ee.views.base import BaseAPIView
from plane.db.models import IssueType, Issue, Project
from plane.db.models import IssueType, Issue, Project, ProjectIssueType
from plane.ee.permissions import (
ProjectEntityPermission,
WorkspaceEntityPermission,
@@ -25,17 +27,35 @@ class WorkspaceIssueTypeEndpoint(BaseAPIView):
@check_feature_flag(FeatureFlag.ISSUE_TYPE_DISPLAY)
def get(self, request, slug):
# Get all issue types for the workspace
issue_types = IssueType.objects.filter(
workspace__slug=slug,
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
).annotate(
issue_exists=Exists(
Issue.objects.filter(
workspace__slug=slug, type_id=OuterRef("pk")
issue_types = (
IssueType.objects.filter(
workspace__slug=slug,
project_issue_types__project__project_projectmember__member=request.user,
project_issue_types__project__project_projectmember__is_active=True,
)
.annotate(
issue_exists=Exists(
Issue.objects.filter(
workspace__slug=slug, type_id=OuterRef("pk")
)
)
)
)
.annotate(
project_ids=Coalesce(
Subquery(
ProjectIssueType.objects.filter(
issue_type=OuterRef("pk"), workspace__slug=slug
)
.values("issue_type")
.annotate(
project_ids=ArrayAgg("project_id", distinct=True)
)
.values("project_ids")
),
[],
)
)
).order_by("created_at")
serializer = IssueTypeSerializer(issue_types, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
@@ -54,44 +74,111 @@ class IssueTypeEndpoint(BaseAPIView):
issue_exists=Exists(
Issue.objects.filter(project_id=project_id, type_id=pk)
)
).get(workspace__slug=slug, project_id=project_id, pk=pk)
).get(
workspace__slug=slug,
project_issue_types__project_id=project_id,
pk=pk,
)
serializer = IssueTypeSerializer(issue_type)
return Response(serializer.data, status=status.HTTP_200_OK)
# Get all issue types
issue_types = IssueType.objects.filter(
workspace__slug=slug, project_id=project_id
).annotate(
issue_exists=Exists(
Issue.objects.filter(
project_id=project_id, type_id=OuterRef("pk")
issue_types = (
IssueType.objects.filter(
workspace__slug=slug,
project_issue_types__project_id=project_id,
)
.annotate(
issue_exists=Exists(
Issue.objects.filter(
project_id=project_id, type_id=OuterRef("pk")
)
)
)
)
.annotate(
project_ids=Coalesce(
Subquery(
ProjectIssueType.objects.filter(
issue_type=OuterRef("pk"), workspace__slug=slug
)
.values("issue_type")
.annotate(
project_ids=ArrayAgg("project_id", distinct=True)
)
.values("project_ids")
),
[],
)
)
).order_by("created_at")
serializer = IssueTypeSerializer(issue_types, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
@check_feature_flag(FeatureFlag.ISSUE_TYPE_SETTINGS)
# @check_feature_flag(FeatureFlag.ISSUE_TYPE_SETTINGS)
def post(self, request, slug, project_id):
# Fetch the project
project = Project.objects.get(pk=project_id)
# Create a new issue type
serializer = IssueTypeSerializer(data=request.data)
# check weight
if not request.data.get("weight"):
request.data["weight"] = 1
# Check is_active
if not request.data.get("is_active"):
request.data["is_active"] = False
# Validate the data
serializer.is_valid(raise_exception=True)
# Save the data
serializer.save(project_id=project_id)
serializer.save(workspace_id=project.workspace_id)
# Bridge the issue type with the project
ProjectIssueType.objects.create(
project_id=project_id,
issue_type_id=serializer.data["id"],
level=0,
)
# Refetch the data
issue_type = (
IssueType.objects.filter(
workspace__slug=slug,
project_issue_types__project_id=project_id,
pk=serializer.data["id"],
)
.annotate(
issue_exists=Exists(
Issue.objects.filter(
project_id=project_id, type_id=OuterRef("pk")
)
)
)
.annotate(
project_ids=Coalesce(
Subquery(
ProjectIssueType.objects.filter(
issue_type=OuterRef("pk"), workspace__slug=slug
)
.values("issue_type")
.annotate(
project_ids=ArrayAgg("project_id", distinct=True)
)
.values("project_ids")
),
[],
)
)
).first()
# Serialize the data
serializer = IssueTypeSerializer(issue_type)
return Response(serializer.data, status=status.HTTP_201_CREATED)
@check_feature_flag(FeatureFlag.ISSUE_TYPE_SETTINGS)
def patch(self, request, slug, project_id, pk):
# Update an issue type
issue_type = IssueType.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
workspace__slug=slug,
project_issue_types__project_id=project_id,
pk=pk,
)
# Default cannot be made in active
@@ -110,13 +197,50 @@ class IssueTypeEndpoint(BaseAPIView):
serializer.is_valid(raise_exception=True)
# Save the data
serializer.save()
# Refetch the data
issue_type = (
IssueType.objects.filter(
workspace__slug=slug,
project_issue_types__project_id=project_id,
pk=serializer.data["id"],
)
.annotate(
issue_exists=Exists(
Issue.objects.filter(
project_id=project_id, type_id=OuterRef("pk")
)
)
)
.annotate(
project_ids=Coalesce(
Subquery(
ProjectIssueType.objects.filter(
issue_type=OuterRef("pk"), workspace__slug=slug
)
.values("issue_type")
.annotate(
project_ids=ArrayAgg("project_id", distinct=True)
)
.values("project_ids")
),
[],
)
)
)
# Serialize the data
serializer = IssueTypeSerializer(issue_type.first())
return Response(serializer.data, status=status.HTTP_200_OK)
@check_feature_flag(FeatureFlag.ISSUE_TYPE_SETTINGS)
def delete(self, request, slug, project_id, pk):
# Delete an issue type
issue_type = IssueType.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
workspace__slug=slug,
project_issue_types__project_id=project_id,
pk=pk,
)
# Check if there are any issues using this issue type
@@ -150,32 +274,39 @@ class DefaultIssueTypeEndpoint(BaseAPIView):
# If issue type is already created return an error
if IssueType.objects.filter(
workspace__slug=slug, project_id=project_id
workspace__slug=slug,
project_issue_types__project_id=project_id,
is_default=True,
).exists():
return Response(
{{"error": "Default issue type already exists"}},
{"error": "Default issue type already exists"},
status=status.HTTP_400_BAD_REQUEST,
)
# Check if default issue type exists for the project
if ProjectIssueType.objects.filter(
project_id=project_id, is_default=True
).exists():
return Response(
{"error": "Default issue type already exists"},
status=status.HTTP_400_BAD_REQUEST,
)
# Create a new default issue type
issue_type, _ = IssueType.objects.get_or_create(
project_id=project_id,
issue_type = IssueType.objects.create(
workspace_id=project.workspace_id,
name="Issue",
is_default=True,
defaults={
"description": "Default issue type with the option to add new properties",
"is_default": True,
"weight": 0,
"sort_order": 1,
"logo_props": {
"in_use": "icon",
"icon": {
"color": "#ffffff",
"background_color": "#6695FF",
},
description="Default issue type with the option to add new properties",
logo_props={
"in_use": "icon",
"icon": {
"color": "#ffffff",
"background_color": "#6695FF",
},
},
)
# Update existing issues to use the new default issue type
Issue.objects.filter(
project_id=project_id,
@@ -187,5 +318,46 @@ class DefaultIssueTypeEndpoint(BaseAPIView):
project.is_issue_type_enabled = True
project.save()
# Bridge the issue type with the project
ProjectIssueType.objects.create(
project_id=project_id,
issue_type_id=issue_type.id,
level=0,
is_default=True,
)
# Refetch the data
issue_type = (
IssueType.objects.filter(
workspace__slug=slug,
project_issue_types__project_id=project_id,
pk=issue_type.id,
)
.annotate(
issue_exists=Exists(
Issue.objects.filter(
project_id=project_id, type_id=OuterRef("pk")
)
)
)
.annotate(
project_ids=Coalesce(
Subquery(
ProjectIssueType.objects.filter(
issue_type=OuterRef("pk"), workspace__slug=slug
)
.values("issue_type")
.annotate(
project_ids=ArrayAgg("project_id", distinct=True)
)
.values("project_ids")
),
[],
)
)
)
# Serialize the data
return Response(str(issue_type.id), status=status.HTTP_201_CREATED)
serializer = IssueTypeSerializer(issue_type.first())
return Response(serializer.data, status=status.HTTP_200_OK)

View File

@@ -230,9 +230,16 @@ class IssuePropertyValueEndpoint(BaseAPIView):
existing_prop_queryset
).values("property_id", "values")
# existing values
existing_values = {
str(prop["property_id"]): prop["values"]
for prop in existing_prop_values
}
# Get the value
values = request.data.get("values", [])
# Check if the property is required
if issue_property.is_required and (
not values or not [v for v in values if v]
):
@@ -277,10 +284,7 @@ class IssuePropertyValueEndpoint(BaseAPIView):
# Log the activity
issue_property_activity.delay(
existing_values={
str(prop["property_id"]): prop["values"]
for prop in existing_prop_values
},
existing_values=existing_values,
requested_values={str(property_id): values},
issue_id=issue_id,
user_id=str(request.user.id),

View File

@@ -102,6 +102,12 @@ def member_sync_task(slug):
workspace_license.trial_end_date = updated_workspace_license[
"trial_end_date"
]
workspace_license.has_activated_free_trial = (
updated_workspace_license["has_activated_free_trial"]
)
workspace_license.has_added_payment_method = (
updated_workspace_license["has_added_payment_method"]
)
workspace_license.last_synced_at = timezone.now()
workspace_license.save()
@@ -120,6 +126,12 @@ def member_sync_task(slug):
"is_offline_payment"
],
last_synced_at=timezone.now(),
has_activated_free_trial=updated_workspace_license[
"has_activated_free_trial"
],
has_added_payment_method=updated_workspace_license[
"has_added_payment_method"
],
)
else:
return

View File

@@ -12,6 +12,7 @@ from .views import (
WorkspaceLicenseRefreshEndpoint,
WorkspaceLicenseSyncEndpoint,
WorkspaceFreeTrialEndpoint,
WorkspaceTrialUpgradeEndpoint,
)
urlpatterns = [
@@ -68,6 +69,11 @@ urlpatterns = [
path(
"workspaces/<str:slug>/trial-subscriptions/",
WorkspaceFreeTrialEndpoint.as_view(),
name="license-refresh",
name="trial-subscriptions",
),
path(
"workspaces/<str:slug>/trial-subscriptions/upgrade/",
WorkspaceTrialUpgradeEndpoint.as_view(),
name="trial-upgrade",
),
]

View File

@@ -9,6 +9,7 @@ from .payment import (
PaymentLinkEndpoint,
WebsitePaymentLinkEndpoint,
WorkspaceFreeTrialEndpoint,
WorkspaceTrialUpgradeEndpoint,
)
from .subscription import SubscriptionEndpoint, UpgradeSubscriptionEndpoint
from .feature_flag import FeatureFlagProxyEndpoint

View File

@@ -246,7 +246,7 @@ class WorkspaceFreeTrialEndpoint(BaseAPIView):
return Response(response, status=status.HTTP_200_OK)
else:
return Response(
{"error": "error fetching product details"},
{"error": "error upgrading trial subscriptions"},
status=status.HTTP_400_BAD_REQUEST,
)
except requests.exceptions.RequestException as e:
@@ -257,6 +257,87 @@ class WorkspaceFreeTrialEndpoint(BaseAPIView):
)
log_exception(e)
return Response(
{"error": "error fetching payment link"},
{"error": "error creating trial subscriptions"},
status=status.HTTP_400_BAD_REQUEST,
)
class WorkspaceTrialUpgradeEndpoint(BaseAPIView):
permission_classes = [
WorkspaceOwnerPermission,
]
def post(self, request, slug):
try:
# Get the product_id and price_id
price_id = request.data.get("price_id", False)
# Check if product_id and price_id are present
if not price_id:
return Response(
{"error": "price_id is required"},
status=status.HTTP_400_BAD_REQUEST,
)
# Get the workspace members
workspace_members = (
WorkspaceMember.objects.filter(
workspace__slug=slug, is_active=True, member__is_bot=False
)
.annotate(
user_email=F("member__email"),
user_id=F("member__id"),
user_role=F("role"),
)
.values(
"user_email",
"user_id",
"user_role",
)
.values("user_email", "user_id", "user_role")
)
# Convert the user_id to string
for member in workspace_members:
member["user_id"] = str(member["user_id"])
# Get the workspace
workspace = Workspace.objects.get(slug=slug)
# Check if the payment server base url is present
if settings.PAYMENT_SERVER_BASE_URL:
response = requests.post(
f"{settings.PAYMENT_SERVER_BASE_URL}/api/trial-subscriptions/upgrade/",
headers={
"content-type": "application/json",
"x-api-key": settings.PAYMENT_SERVER_AUTH_TOKEN,
},
json={
"workspace_id": str(workspace.id),
"stripe_price_id": price_id,
"members_list": list(workspace_members),
"slug": slug,
},
)
# Check if the response is successful
response.raise_for_status()
# Convert the response to json
response = response.json()
return Response(response, status=status.HTTP_200_OK)
else:
return Response(
{"error": "error upgrading trial subscriptions"},
status=status.HTTP_400_BAD_REQUEST,
)
except requests.exceptions.RequestException as e:
if e.response.status_code == 400:
return Response(
e.response.json(),
status=status.HTTP_400_BAD_REQUEST,
)
log_exception(e)
return Response(
{"error": "error upgrading trial subscriptions"},
status=status.HTTP_400_BAD_REQUEST,
)

View File

@@ -6,6 +6,7 @@ from django.conf import settings
from django.db.models import CharField
from django.db.models.functions import Cast
from django.utils import timezone
from django.db.models import F
# Third party imports
from rest_framework import status
@@ -94,48 +95,36 @@ class WebsiteUserWorkspaceEndpoint(BaseAPIView):
def get(self, request):
try:
# Get all the workspaces where the user is admin
workspace_query = (
WorkspaceMember.objects.filter(
member=request.user,
is_active=True,
role=20,
)
.annotate(uuid_str=Cast("workspace_id", CharField()))
workspace_ids = WorkspaceMember.objects.filter(
member=request.user,
is_active=True,
role=20,
).values_list("workspace_id", flat=True)
# Fetch the workspaces from the workspace license
workspace_licenses = (
WorkspaceLicense.objects.filter(workspace_id__in=workspace_ids)
.annotate(slug=F("workspace__slug"))
.annotate(name=F("workspace__name"))
.annotate(logo=F("workspace__logo"))
.annotate(product=F("plan"))
.values(
"uuid_str",
"workspace__slug",
"workspace__name",
"workspace__logo",
"workspace_id",
"slug",
"name",
"logo",
"product",
"trial_end_date",
"has_activated_free_trial",
"has_added_payment_method",
"current_period_end_date",
"is_offline_payment",
)
)
workspaces = [
{
"workspace_id": workspace["uuid_str"],
"slug": workspace["workspace__slug"],
"name": workspace["workspace__name"],
"logo": workspace["workspace__logo"],
}
for workspace in workspace_query
]
# Get the workspace details
return Response(workspace_licenses, status=status.HTTP_200_OK)
if settings.PAYMENT_SERVER_BASE_URL:
response = requests.post(
f"{settings.PAYMENT_SERVER_BASE_URL}/api/user-workspace-products/",
headers={
"content-type": "application/json",
"x-api-key": settings.PAYMENT_SERVER_AUTH_TOKEN,
},
json={"workspaces": workspaces},
)
response.raise_for_status()
response = response.json()
return Response(response, status=status.HTTP_200_OK)
else:
return Response(
{"error": "error fetching product details"},
status=status.HTTP_400_BAD_REQUEST,
)
except requests.exceptions.RequestException as e:
if e.response.status_code == 400:
return Response(
@@ -204,6 +193,15 @@ class WorkspaceProductEndpoint(BaseAPIView):
workspace_license.is_offline_payment = response.get(
"is_offline_payment", False
)
workspace_license.trial_end_date = response.get(
"trial_end_date"
)
workspace_license.has_activated_free_trial = (
response.get("has_activated_free_trial", False)
)
workspace_license.has_added_payment_method = (
response.get("has_added_payment_method", False)
)
workspace_license.save()
return Response(
@@ -215,6 +213,8 @@ class WorkspaceProductEndpoint(BaseAPIView):
"product": workspace_license.plan,
"is_offline_payment": workspace_license.is_offline_payment,
"trial_end_date": workspace_license.trial_end_date,
"has_activated_free_trial": workspace_license.has_activated_free_trial,
"has_added_payment_method": workspace_license.has_added_payment_method,
},
status=status.HTTP_200_OK,
)
@@ -228,6 +228,8 @@ class WorkspaceProductEndpoint(BaseAPIView):
"product": workspace_license.plan,
"is_offline_payment": workspace_license.is_offline_payment,
"trial_end_date": workspace_license.trial_end_date,
"has_activated_free_trial": workspace_license.has_activated_free_trial,
"has_added_payment_method": workspace_license.has_added_payment_method,
},
status=status.HTTP_200_OK,
)
@@ -256,6 +258,12 @@ class WorkspaceProductEndpoint(BaseAPIView):
plan=response.get("plan"),
last_synced_at=timezone.now(),
trial_end_date=response.get("trial_end_date"),
has_activated_free_trial=response.get(
"has_activated_free_trial", False
),
has_added_payment_method=response.get(
"has_added_payment_method", False
),
)
# Return the workspace license
return Response(
@@ -267,6 +275,8 @@ class WorkspaceProductEndpoint(BaseAPIView):
"product": workspace_license.plan,
"is_offline_payment": workspace_license.is_offline_payment,
"trial_end_date": workspace_license.trial_end_date,
"has_activated_free_trial": workspace_license.has_activated_free_trial,
"has_added_payment_method": workspace_license.has_added_payment_method,
},
status=status.HTTP_200_OK,
)
@@ -284,7 +294,6 @@ class WorkspaceProductEndpoint(BaseAPIView):
class WorkspaceLicenseRefreshEndpoint(BaseAPIView):
def post(self, request, slug):
workspace = Workspace.objects.get(slug=slug)
@@ -318,6 +327,12 @@ class WorkspaceLicenseRefreshEndpoint(BaseAPIView):
workspace_license.recurring_interval = response.get("interval")
workspace_license.plan = response.get("plan")
workspace_license.trial_end_date = response.get("trial_end_date")
workspace_license.has_activated_free_trial = response.get(
"has_activated_free_trial", False
)
workspace_license.has_added_payment_method = response.get(
"has_added_payment_method", False
)
workspace_license.last_synced_at = timezone.now()
workspace_license.save()
# If the license is not present, then fetch the license from the payment server and create it
@@ -345,6 +360,12 @@ class WorkspaceLicenseRefreshEndpoint(BaseAPIView):
plan=response.get("plan"),
last_synced_at=timezone.now(),
trial_end_date=response.get("trial_end_date"),
has_activated_free_trial=response.get(
"has_activated_free_trial", False
),
has_added_payment_method=response.get(
"has_added_payment_method", False
),
)
# Return the response
@@ -352,7 +373,6 @@ class WorkspaceLicenseRefreshEndpoint(BaseAPIView):
class WorkspaceLicenseSyncEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
@@ -401,6 +421,12 @@ class WorkspaceLicenseSyncEndpoint(BaseAPIView):
workspace_license.trial_end_date = request.data.get(
"trial_end_date"
)
workspace_license.has_activated_free_trial = request.data.get(
"has_activated_free_trial", False
)
workspace_license.has_added_payment_method = request.data.get(
"has_added_payment_method", False
)
workspace_license.save()
# If the workspace license is not present, then fetch the license from the payment server and create it
else:
@@ -417,6 +443,12 @@ class WorkspaceLicenseSyncEndpoint(BaseAPIView):
plan=request.data.get("plan"),
last_synced_at=timezone.now(),
trial_end_date=request.data.get("trial_end_date"),
has_activated_free_trial=request.data.get(
"has_activated_free_trial", False
),
has_added_payment_method=request.data.get(
"has_added_payment_method", False
),
)
# Return the response

View File

@@ -143,6 +143,16 @@ class UpgradeSubscriptionEndpoint(BaseAPIView):
workspace_license.trial_end_date = (
workspace_license_response.get("trial_end_date")
)
workspace_license.has_activated_free_trial = (
workspace_license_response.get(
"has_activated_free_trial"
)
)
workspace_license.has_added_payment_method = (
workspace_license_response.get(
"has_added_payment_method"
)
)
workspace_license.save()
else:
# Create a new workspace license
@@ -168,6 +178,12 @@ class UpgradeSubscriptionEndpoint(BaseAPIView):
"trial_end_date"
),
plan=workspace_license_response.get("plan"),
has_activated_free_trial=workspace_license_response.get(
"has_activated_free_trial"
),
has_added_payment_method=workspace_license_response.get(
"has_added_payment_method"
),
)
# Return the response

View File

@@ -524,6 +524,22 @@ def filter_logged_by(params, issue_filter, method, prefix=""):
return issue_filter
def filter_issue_type(params, issue_filter, method, prefix=""):
if method == "GET":
types = [item for item in params.get("issue_type").split(",") if item != "null"]
types = filter_valid_uuids(types)
if len(types) and "" not in types:
issue_filter[f"{prefix}type__in"] = types
else:
if (
params.get("issue_type", None)
and len(params.get("issue_type"))
and params.get("issue_type") != "null"
):
issue_filter[f"{prefix}type__in"] = params.get("issue_type")
return issue_filter
def issue_filters(query_params, method, prefix=""):
issue_filter = {}
@@ -552,6 +568,7 @@ def issue_filters(query_params, method, prefix=""):
"sub_issue": filter_sub_issue_toggle,
"subscriber": filter_subscribed_issues,
"start_target_date": filter_start_target_date_issues,
"issue_type": filter_issue_type,
}
for key, value in ISSUE_FILTER.items():

View File

@@ -138,7 +138,7 @@ services:
plane-db:
<<: *app-env
image: postgres:15.5-alpine
image: postgres:15.7-alpine
pull_policy: if_not_present
restart: unless-stopped
command: postgres -c 'max_connections=1000'

View File

@@ -31,7 +31,7 @@ services:
MINIO_ROOT_PASSWORD: ${AWS_SECRET_ACCESS_KEY}
plane-db:
image: postgres:15.2-alpine
image: postgres:15.7-alpine
restart: unless-stopped
networks:
- dev_env

View File

@@ -110,7 +110,7 @@ services:
plane-db:
container_name: plane-db
image: postgres:15.2-alpine
image: postgres:15.7-alpine
restart: always
command: postgres -c 'max_connections=1000'
volumes:

View File

@@ -140,6 +140,7 @@ export interface IIssueActivity {
name: string;
priority: string | null;
sequence_id: string;
type_id: string;
} | null;
new_identifier: string | null;
new_value: string | null;

View File

@@ -60,4 +60,9 @@ export type TIssueActivityComment =
id: string;
activity_type: "WORKLOG";
created_at?: string;
}
| {
id: string;
activity_type: "ISSUE_ADDITIONAL_PROPERTIES_ACTIVITY";
created_at?: string;
};

View File

@@ -23,4 +23,8 @@ export type IWorkspaceProductSubscription = {
interval?: "month" | "year" | null;
current_period_end_date: string | null;
is_offline_payment: boolean;
trial_end_date: string | undefined;
purchased_seats: number | undefined;
has_activated_free_trial: boolean;
has_added_payment_method: boolean;
};

View File

@@ -144,4 +144,5 @@ export interface ISearchIssueResponse {
state__group: TStateGroups;
state__name: string;
workspace__slug: string;
type_id: string;
}

View File

@@ -118,6 +118,7 @@ export interface IWorkspaceIssueSearchResult {
project_id: string;
sequence_id: number;
workspace__slug: string;
type_id: string;
}
export interface IWorkspacePageSearchResult {

View File

@@ -5,7 +5,6 @@ import useFontFaceObserver from "use-font-face-observer";
import { LUCIDE_ICONS_LIST } from "./icons";
// helpers
import { emojiCodeToUnicode } from "./helpers";
import { cn } from "../../helpers";
type TLogoProps = {
in_use: "emoji" | "icon";
@@ -23,11 +22,10 @@ type Props = {
logo: TLogoProps;
size?: number;
type?: "lucide" | "material";
customColor?: string;
};
export const Logo: FC<Props> = (props) => {
const { logo, size = 16, customColor, type = "material" } = props;
const { logo, size = 16, type = "material" } = props;
// destructuring the logo object
const { in_use, emoji, icon } = logo;
@@ -74,20 +72,19 @@ export const Logo: FC<Props> = (props) => {
{lucideIcon && (
<lucideIcon.element
style={{
color: !customColor ? color : undefined,
color: color,
height: size,
width: size,
}}
className={cn(customColor)}
/>
)}
</>
) : (
<span
className={cn("material-symbols-rounded", customColor)}
className="material-symbols-rounded"
style={{
fontSize: size,
color: !customColor ? color : undefined,
color: color,
scale: "115%",
}}
>

View File

@@ -29,7 +29,8 @@
"NEXT_PUBLIC_PLANE_ONE_PAYMENT_URL",
"NEXT_PUBLIC_PRO_PLAN_MONTHLY_REDIRECT_URL",
"NEXT_PUBLIC_PRO_PLAN_YEARLY_REDIRECT_URL",
"NEXT_PUBLIC_PRO_SELF_HOSTED_PAYMENT_URL"
"NEXT_PUBLIC_PRO_SELF_HOSTED_PAYMENT_URL",
"NEXT_PUBLIC_PRO_SELF_HOSTED_MONTHLY_PAYMENT_URL"
],
"tasks": {
"build": {

View File

@@ -10,7 +10,7 @@ export default function WorkspaceLayout({ children }: { children: React.ReactNod
<AuthenticationWrapper>
<CommandPalette />
<WorkspaceAuthWrapper>
<div className="relative flex h-screen w-full overflow-hidden">
<div className="relative flex h-full w-full overflow-hidden">
<AppSidebar />
<main className="relative flex h-full w-full flex-col overflow-hidden bg-custom-background-100">
{children}

View File

@@ -48,7 +48,7 @@ const ImportsPage = observer(() => {
return (
<>
<PageHead title={pageTitle} />
<div className="flex h-full flex-col gap-10 rounded-xl px-8">
<div className="flex h-full flex-col gap-10 rounded-xl md:pr-9 pr-4">
<div className="flex items-center border-b border-custom-border-100 py-3.5">
<h3 className="text-xl font-medium">Imports</h3>
</div>
@@ -103,7 +103,7 @@ const ImportsPage = observer(() => {
return (
<>
<PageHead title={pageTitle} />
<section className="flex flex-col justify-start flex-grow h-full w-full overflow-y-auto py-8 pr-9">
<section className="flex flex-col justify-start flex-grow h-full w-full overflow-y-auto md:pr-9 pr-4">
<div className="flex items-center border-b border-custom-border-100 py-3.5 flex-shrink-0">
<h3 className="text-xl font-medium">Imports</h3>
</div>

View File

@@ -1,16 +1,33 @@
"use client";
import { useEffect } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { useParams, useRouter } from "next/navigation";
import { useInstance, useWorkspace } from "@/hooks/store";
import SiloIframe from "@/plane-web/components/iframe/silo-iframe";
const ImportProviderPage = observer(() => {
const { workspaceSlug, providerSlug } = useParams();
const router = useRouter();
const { config } = useInstance();
const { currentWorkspace } = useWorkspace();
useEffect(() => {
const handleMessage = (e: MessageEvent) => {
if (e.data.msg === "created-migration") {
router.push(`/${workspaceSlug}/settings/imports`);
}
};
window.addEventListener("message", handleMessage, false);
return () => {
window.removeEventListener("message", handleMessage, false);
};
}, [router, workspaceSlug]);
if (config?.silo_base_url) {
return (
<SiloIframe

View File

@@ -51,7 +51,7 @@ const WorkspaceIntegrationsPage = observer(() => {
if (true)
return (
<div className="flex h-full flex-col gap-10 rounded-xl px-8">
<div className="flex h-full flex-col gap-10 rounded-xl md:pr-9 pr-4">
<div className="flex items-center border-b border-custom-border-100 py-3.5">
<h3 className="text-xl font-medium">Integrations</h3>
</div>

View File

@@ -9,6 +9,8 @@ import "@/styles/react-day-picker.css";
import { SITE_NAME, SITE_DESCRIPTION } from "@/constants/meta";
// helpers
import { API_BASE_URL } from "@/helpers/common.helper";
// plane web components
import { FreeTrialBanner } from "@/plane-web/components/license/free-trial-banner";
// local
import { AppProvider } from "./provider";
@@ -70,7 +72,13 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<body>
<div id="context-menu-portal" />
<AppProvider>
<div className={`h-screen w-full overflow-hidden bg-custom-background-100`}>{children}</div>
<div className={`h-screen w-full overflow-hidden bg-custom-background-100 relative flex flex-col`}>
<div className="flex-shrink-0">
{/* free trial banner */}
<FreeTrialBanner />
</div>
<div className="w-full h-full overflow-hidden">{children}</div>
</div>
</AppProvider>
</body>
{process.env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN && (

View File

@@ -18,24 +18,38 @@ import { PaymentService } from "@/plane-web/services/payment.service";
const paymentService = new PaymentService();
const workspaceService = new WorkspaceService();
const calculateYearlyDiscount = (monthlyPrice: number, yearlyPricePerMonth: number): number => {
const monthlyCost = monthlyPrice * 12;
const yearlyCost = yearlyPricePerMonth * 12;
const amountSaved = monthlyCost - yearlyCost;
const discountPercentage = (amountSaved / monthlyCost) * 100;
return Math.floor(discountPercentage);
};
const renderPlanPricing = (price: number, members: number = 1, recurring: string) => {
if (recurring === "month") return ((price / 100) * members).toFixed(0);
if (recurring === "year") return ((price / 100) * members).toFixed(0);
};
const CloudUpgradePlanPage = observer(() => {
// states
const [selectedPlan, setSelectedPlan] = useState<IPaymentProductPrice | null>(null);
const [isLoading, setLoading] = useState(false);
// router
const { selectedWorkspace } = useParams();
const { selectedWorkspace: workspaceSlug } = useParams();
// fetch workspace members
const { data: workspaceMembers, isLoading: isFetching } = useSWR(
selectedWorkspace ? `WORKSPACES_WITH_PLAN_DETAILS` : null,
selectedWorkspace ? () => workspaceService.fetchWorkspaceMembers(selectedWorkspace.toString()) : null,
workspaceSlug ? `CLOUD_PRO_WORKSPACE_MEMBER_DETAILS` : null,
workspaceSlug ? () => workspaceService.fetchWorkspaceMembers(workspaceSlug.toString()) : null,
{ revalidateIfStale: false, revalidateOnFocus: false }
);
// fetch products
const { data, isLoading: isLoadingProduct } = useSWR(
selectedWorkspace ? "CLOUD_PAYMENT_PRODUCTS" : null,
selectedWorkspace ? () => paymentService.listProducts(selectedWorkspace.toString()) : null,
const { data: products, isLoading: isLoadingProduct } = useSWR(
workspaceSlug ? "CLOUD_PRO_PRODUCTS" : null,
workspaceSlug ? () => paymentService.listProducts(workspaceSlug.toString()) : null,
{
errorRetryCount: 2,
revalidateIfStale: false,
@@ -43,26 +57,29 @@ const CloudUpgradePlanPage = observer(() => {
}
);
const totalWorkspaceMembers = (workspaceMembers || [])?.filter((member) => member.role >= 15)?.length;
// fetch workspace current plan
const { data: workspaceSubscription, isLoading: workspaceSubscriptionLoading } = useSWR(
workspaceSlug ? `CLOUD_PRO_WORKSPACE_SUBSCRIPTION_PLAN_${workspaceSlug}` : null,
workspaceSlug ? () => paymentService.getWorkspaceCurrentPlan(workspaceSlug.toString()) : null,
{ revalidateIfStale: false, revalidateOnFocus: false }
);
const proProduct = (data || [])?.find((product: IPaymentProduct) => product?.type === "PRO");
// derived values
const totalWorkspaceMembers = (workspaceMembers || [])?.filter((member) => member.role >= 15)?.length;
const proProduct = (products || [])?.find((product: IPaymentProduct) => product?.type === "PRO");
const monthlyPlan = proProduct?.prices?.find((price) => price.recurring === "month");
const yearlyPlan = proProduct?.prices?.find((price) => price.recurring === "year");
const calculateYearlyDiscount = (monthlyAmount: number | undefined, yearlyAmount: number | undefined) => {
if (!monthlyAmount || !yearlyAmount) return undefined;
const yearlyCostIfPaidMonthly = monthlyAmount * 12;
const discountAmount = yearlyCostIfPaidMonthly - yearlyAmount;
const discountPercentage = (discountAmount / yearlyCostIfPaidMonthly) * 100;
const monthlyPlanUnitPrice = (monthlyPlan?.unit_amount || 0) / 100;
const yearlyPlanUnitPrice = (yearlyPlan?.unit_amount || 0) / 1200;
const yearlyDiscountedPrice = calculateYearlyDiscount(monthlyPlanUnitPrice, yearlyPlanUnitPrice);
return discountPercentage;
};
const discountedPrice = calculateYearlyDiscount(monthlyPlan?.unit_amount, yearlyPlan?.unit_amount);
const workspaceOnTrial =
workspaceSubscription?.has_activated_free_trial && workspaceSubscription?.trial_end_date ? true : false;
const handleStripeCheckout = (priceId: string) => {
if (!selectedWorkspace) {
if (!workspaceSlug) {
setToast({
type: TOAST_TYPE.INFO,
title: "Please select a workspace to continue",
@@ -71,7 +88,7 @@ const CloudUpgradePlanPage = observer(() => {
}
setLoading(true);
paymentService
.getCurrentWorkspacePaymentLink(selectedWorkspace.toString(), {
.getCurrentWorkspacePaymentLink(workspaceSlug.toString(), {
price_id: priceId,
product_id: proProduct?.id,
})
@@ -92,16 +109,46 @@ const CloudUpgradePlanPage = observer(() => {
});
};
const handleTrialStripeCheckout = (priceId: string) => {
if (!workspaceSlug) {
setToast({
type: TOAST_TYPE.INFO,
title: "Please select a workspace to continue",
});
return;
}
setLoading(true);
paymentService
.modifyTrailSubscription(workspaceSlug.toString(), {
price_id: priceId,
})
.then((response) => {
if (response.session_url) {
window.open(response.session_url, "_self");
}
})
.catch((error) => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: error?.detail ?? "Failed to generate payment link. Please try again.",
});
})
.finally(() => {
setLoading(false);
});
};
return (
<div className="w-full h-full flex flex-col items-center justify-center gap-2">
<div className="flex flex-col items-center gap-2 pb-4">
<div className="text-3xl font-semibold">Choose your billing frequency</div>
{discountedPrice && (
<div className="text-center text-base text-custom-text-300">{`Upgrade to our yearly plan and get ${Math.round(discountedPrice)}% off.`}</div>
{yearlyDiscountedPrice && (
<div className="text-center text-base text-custom-text-300">{`Upgrade to our yearly plan and get ${Math.round(yearlyDiscountedPrice)}% off.`}</div>
)}
</div>
{isFetching || isLoadingProduct ? (
{isFetching || isLoadingProduct || workspaceSubscriptionLoading ? (
<div className="flex flex-col rounded gap-1 w-full">
<Loader.Item height="90px" />
<Loader.Item height="90px" />
@@ -132,7 +179,9 @@ const CloudUpgradePlanPage = observer(() => {
<div className="flex flex-col">
{totalWorkspaceMembers && (
<div className="flex items-center gap-2">
<span className="text-2xl font-semibold leading-7">{`$${plan.unit_amount / 100}`}</span>
<span className="text-2xl font-semibold leading-7">
${renderPlanPricing(plan.unit_amount, 1, plan.recurring)}
</span>
<span className="text-sm text-custom-text-300">
{` per user per ${plan.recurring} x ${totalWorkspaceMembers}`}
</span>
@@ -143,10 +192,11 @@ const CloudUpgradePlanPage = observer(() => {
</span>
</div>
</div>
<div className="flex flex-col">
<div className="flex flex-col text-right">
{totalWorkspaceMembers && (
<span className="text-2xl font-semibold leading-7">{`$${(plan.unit_amount / 100) * totalWorkspaceMembers}`}</span>
<span className="text-2xl font-semibold leading-7">
${renderPlanPricing(plan.unit_amount, totalWorkspaceMembers, plan.recurring)}
</span>
)}
<span className="text-sm text-custom-text-300">
{plan.recurring === "month" ? "per month" : "per year"}
@@ -156,12 +206,18 @@ const CloudUpgradePlanPage = observer(() => {
))}
</div>
)}
{selectedPlan?.recurring && selectedPlan.unit_amount ? (
<>
<Button className="w-full px-2 my-4" onClick={() => handleStripeCheckout(selectedPlan.id)}>
<Button
className="w-full px-2 my-4"
onClick={() =>
workspaceOnTrial ? handleTrialStripeCheckout(selectedPlan.id) : handleStripeCheckout(selectedPlan.id)
}
>
{isLoading
? "Redirecting to Stripe..."
: `Pay $${(selectedPlan?.unit_amount / 100) * totalWorkspaceMembers} every ${selectedPlan?.recurring}`}
: `Pay $${renderPlanPricing(selectedPlan.unit_amount, totalWorkspaceMembers, selectedPlan.recurring)} every ${selectedPlan?.recurring}`}
</Button>
</>
) : (

View File

@@ -18,6 +18,8 @@ import { useUser } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router";
// services
import { WorkspaceService } from "@/plane-web/services";
// plane web types
import { TWorkspaceWithProductDetails } from "@/plane-web/types/workspace";
const workspaceService = new WorkspaceService();
@@ -33,7 +35,7 @@ const CloudUpgradePage = observer(() => {
const { setTheme } = useTheme();
const { data: workspacesList, isLoading: isFetching } = useSWR(
currentUser ? `WORKSPACES_WITH_PLAN_DETAILS` : null,
currentUser ? `CLOUD_PRO_WORKSPACES_LIST` : null,
currentUser ? () => workspaceService.getWorkspacesWithPlanDetails() : null,
{ revalidateIfStale: false, revalidateOnFocus: false }
);
@@ -69,6 +71,23 @@ const CloudUpgradePage = observer(() => {
.finally(() => setIsLoading(false));
};
const workspaceSubscription = (workspace: TWorkspaceWithProductDetails): "FREE" | "PRO" | "TRIAL" => {
switch (workspace.product) {
case "PRO":
if (workspace.is_offline_payment) {
return "PRO";
} else if (workspace.trial_end_date && workspace.has_added_payment_method) {
return "PRO";
} else if (workspace.trial_end_date && !workspace.has_added_payment_method) {
return "TRIAL";
}
case "FREE":
return "FREE";
default:
return "FREE";
}
};
return (
<div className="w-full h-full flex flex-col items-center justify-center gap-2">
<RadioInput
@@ -111,7 +130,9 @@ const CloudUpgradePage = observer(() => {
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">{truncateText(workspace?.name, 40)}</div>
</div>
{workspace.product === "PRO" && (
{/* pro */}
{workspaceSubscription(workspace) === "PRO" && (
<div className="flex-shrink-0">
<Tooltip position="right" tooltipContent="This workspace is already subscribed to Pro.">
<div
@@ -124,10 +145,25 @@ const CloudUpgradePage = observer(() => {
</Tooltip>
</div>
)}
{/* trial */}
{workspaceSubscription(workspace) === "TRIAL" && (
<div className="flex-shrink-0">
<Tooltip position="right" tooltipContent="This workspace is in Trial please upgrade to Pro.">
<div
className={cn(
"text-custom-primary-100 bg-custom-primary-100/20 rounded-md px-2 py-0 text-center text-xs font-medium outline-none"
)}
>
Trial
</div>
</Tooltip>
</div>
)}
</div>
),
value: workspace.slug,
disabled: workspace.product !== "FREE",
disabled: ["PRO"].includes(workspaceSubscription(workspace)),
}))
: []
}

View File

@@ -44,9 +44,7 @@ export default function UpgradePlanPage() {
router.push("/upgrade/pro/cloud");
}
if (selectedEdition === "self-hosted") {
if (process.env.NEXT_PUBLIC_PRO_SELF_HOSTED_PAYMENT_URL) {
window.open(process.env.NEXT_PUBLIC_PRO_SELF_HOSTED_PAYMENT_URL, "_blank");
}
router.push("/upgrade/pro/self-hosted");
}
};

View File

@@ -0,0 +1,24 @@
"use client";
import { ReactNode } from "react";
// components
import { PageHead } from "@/components/core";
// helpers
import { EPageTypes } from "@/helpers/authentication.helper";
// wrappers
import { AuthenticationWrapper } from "@/lib/wrappers";
type Props = {
children: ReactNode;
};
export default function SelfHostedUpgradeLayout(props: Props) {
const { children } = props;
return (
<div className="h-full w-full overflow-hidden">
<PageHead title="Self-hosted upgrade - Plane" />
<AuthenticationWrapper pageType={EPageTypes.PUBLIC}>{children}</AuthenticationWrapper>
</div>
);
}

View File

@@ -0,0 +1,102 @@
"use client";
import React, { useState } from "react";
import { observer } from "mobx-react";
import { Check } from "lucide-react";
// ui
import { Button, setToast, TOAST_TYPE } from "@plane/ui";
// helpers
import { cn } from "@/helpers/common.helper";
type TSelfHostedProduct = {
recurring: "month" | "year";
unit_amount: number;
redirection_link: string | undefined;
};
const selfHostedProducts: TSelfHostedProduct[] = [
{
recurring: "month",
unit_amount: 8,
redirection_link: process.env.NEXT_PUBLIC_PRO_SELF_HOSTED_MONTHLY_PAYMENT_URL || undefined,
},
{
recurring: "year",
unit_amount: 72,
redirection_link: process.env.NEXT_PUBLIC_PRO_SELF_HOSTED_PAYMENT_URL || undefined,
},
];
const SelfHostedUpgradePlanPage = observer(() => {
// states
const [selectedPlan, setSelectedPlan] = useState<TSelfHostedProduct | undefined>(undefined);
const handleStripeRedirection = () => {
if (selectedPlan?.redirection_link) {
window.open(selectedPlan.redirection_link, "_blank");
} else {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Failed to generate payment link.",
});
}
};
return (
<div className="w-full h-full flex flex-col items-center justify-center gap-2">
<div className="flex flex-col items-center gap-2 pb-4">
<div className="text-3xl font-semibold">Choose your billing frequency</div>
<div className="text-center text-base text-custom-text-300">Upgrade to our yearly plan and get 25% off.</div>
</div>
<div className="flex flex-col rounded w-full">
{selfHostedProducts &&
selfHostedProducts.map((product) => (
<div
key={product.unit_amount}
className={cn(
"flex items-center gap-6 border-x border border-custom-border-200 cursor-pointer rounded py-6 px-4 first:rounded-b-none last:rounded-t-none",
{
"border border-custom-primary-100": product.recurring === selectedPlan?.recurring,
}
)}
onClick={() => setSelectedPlan(product)}
>
<div className="flex items-center gap-4">
<span
className={cn("flex items-center justify-center size-6 rounded-full", {
"bg-custom-primary-100 text-white": product.recurring === selectedPlan?.recurring,
"border border-custom-border-200": product.recurring !== selectedPlan?.recurring,
})}
>
{product.recurring === selectedPlan?.recurring && <Check className="size-4 stroke-2" />}
</span>
<div className="flex flex-col">
<div className="flex items-center gap-2">
<span className="text-2xl font-semibold leading-7">${product.unit_amount}</span>
<span className="text-sm text-custom-text-300">{` per user per ${product.recurring}`}</span>
</div>
<span className="text-sm text-custom-text-300">
{`Billed ${product.recurring === "month" ? "monthly" : "yearly"}`}
</span>
</div>
</div>
</div>
))}
</div>
{selectedPlan?.recurring && selectedPlan.unit_amount ? (
<>
<Button className="w-full px-2 my-4" onClick={handleStripeRedirection}>
{`Pay $${selectedPlan.unit_amount} every ${selectedPlan?.recurring}`}
</Button>
</>
) : (
<>
<Button className="w-full px-2 my-4" disabled>{`Select a plan to continue`}</Button>
</>
)}
</div>
);
});
export default SelfHostedUpgradePlanPage;

View File

@@ -1 +1,2 @@
export * from "./issue-identifier";
export * from "./issue-properties-activity";

View File

@@ -4,29 +4,41 @@ import { cn } from "@/helpers/common.helper";
// hooks
import { useIssueDetail, useProject } from "@/hooks/store";
type TIssueIdentifierProps = {
issueId: string;
type TIssueIdentifierBaseProps = {
projectId: string;
iconSize?: number;
iconContainerSize?: number;
size?: "xs" | "sm" | "md" | "lg";
textContainerClassName?: string;
};
type TIssueIdentifierFromStore = TIssueIdentifierBaseProps & {
issueId: string;
};
type TIssueIdentifierWithDetails = TIssueIdentifierBaseProps & {
issueTypeId?: string | null;
projectIdentifier: string;
issueSequenceId: string | number;
};
type TIssueIdentifierProps = TIssueIdentifierFromStore | TIssueIdentifierWithDetails;
export const IssueIdentifier: React.FC<TIssueIdentifierProps> = observer((props) => {
const { issueId, projectId, textContainerClassName } = props;
const { projectId, textContainerClassName } = props;
// store hooks
const { getProjectById } = useProject();
const { getProjectIdentifierById } = useProject();
const {
issue: { getIssueById },
} = useIssueDetail();
// Determine if the component is using store data or not
const isUsingStoreData = "issueId" in props;
// derived values
const issue = getIssueById(issueId);
const projectDetails = getProjectById(projectId);
const issue = isUsingStoreData ? getIssueById(props.issueId) : null;
const projectIdentifier = isUsingStoreData ? getProjectIdentifierById(projectId) : props.projectIdentifier;
const issueSequenceId = isUsingStoreData ? issue?.sequence_id : props.issueSequenceId;
return (
<div className="flex items-center space-x-2">
<span className={cn("text-xs font-medium text-custom-text-300", textContainerClassName)}>
{projectDetails?.identifier}-{issue?.sequence_id}
<span className={cn("text-base font-medium text-custom-text-300", textContainerClassName)}>
{projectIdentifier}-{issueSequenceId}
</span>
</div>
);

View File

@@ -0,0 +1 @@
export * from "./root";

View File

@@ -0,0 +1,8 @@
import { FC } from "react";
type TIssueAdditionalPropertiesActivity = {
activityId: string;
ends: "top" | "bottom" | undefined;
};
export const IssueAdditionalPropertiesActivity: FC<TIssueAdditionalPropertiesActivity> = () => <></>;

View File

@@ -7,10 +7,11 @@ import { useParams } from "next/navigation";
import useSWR from "swr";
import { FolderPlus, Search, Settings } from "lucide-react";
import { Dialog, Transition } from "@headlessui/react";
// icons
// types
import { IWorkspaceSearchResults } from "@plane/types";
// hooks
// ui
import { LayersIcon, Loader, ToggleSwitch, Tooltip } from "@plane/ui";
// components
import {
ChangeIssueAssignee,
ChangeIssuePriority,
@@ -23,8 +24,11 @@ import {
CommandPaletteWorkspaceSettingsActions,
} from "@/components/command-palette";
import { EmptyState } from "@/components/empty-state";
// constants
import { EmptyStateType } from "@/constants/empty-state";
// fetch-keys
import { ISSUE_DETAILS } from "@/constants/fetch-keys";
// hooks
import { useCommandPalette, useEventTracker, useProject, useUser } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router";
import useDebounce from "@/hooks/use-debounce";
@@ -36,12 +40,6 @@ import { WorkspaceService } from "@/plane-web/services";
// services
import { IssueService } from "@/services/issue";
// ui
// components
// types
// fetch-keys
// constants
const workspaceService = new WorkspaceService();
const issueService = new IssueService();

View File

@@ -9,6 +9,7 @@ import {
IWorkspaceProjectSearchResult,
IWorkspaceSearchResult,
} from "@plane/types";
// ui
import { ContrastIcon, DiceIcon } from "@plane/ui";
// plane web components
import { IssueIdentifier } from "@/plane-web/components/issues";
@@ -37,10 +38,10 @@ export const commandGroups: {
itemName: (issue: IWorkspaceIssueSearchResult) => (
<div className="flex gap-2">
<IssueIdentifier
issueId={issue.id}
projectId={issue.project_id}
iconSize={10}
iconContainerSize={16}
issueTypeId={issue.type_id}
projectIdentifier={issue.project__identifier}
issueSequenceId={issue.sequence_id}
textContainerClassName="text-xs"
/>{" "}
{issue.name}

View File

@@ -165,7 +165,7 @@ const activityDetails: {
</>
);
},
icon: <Users2Icon size={12} color="#6b7280" aria-hidden="true" />,
icon: <Users2Icon size={12} className="text-custom-text-200" aria-hidden="true" />,
},
archived_at: {
message: (activity) => {
@@ -182,7 +182,7 @@ const activityDetails: {
</>
);
},
icon: <ArchiveIcon size={12} color="#6b7280" aria-hidden="true" />,
icon: <ArchiveIcon size={12} className="text-custom-text-200" aria-hidden="true" />,
},
attachment: {
message: (activity, showIssue) => {
@@ -219,7 +219,7 @@ const activityDetails: {
</>
);
},
icon: <PaperclipIcon size={12} color="#6b7280" aria-hidden="true" />,
icon: <PaperclipIcon size={12} className="text-custom-text-200" aria-hidden="true" />,
},
description: {
message: (activity, showIssue) => (
@@ -233,7 +233,7 @@ const activityDetails: {
)}
</>
),
icon: <MessageSquareIcon size={12} color="#6b7280" aria-hidden="true" />,
icon: <MessageSquareIcon size={12} className="text-custom-text-200" aria-hidden="true" />,
},
estimate_point: {
message: (activity, showIssue) => {
@@ -262,7 +262,7 @@ const activityDetails: {
</>
);
},
icon: <TriangleIcon size={12} color="#6b7280" aria-hidden="true" />,
icon: <TriangleIcon size={12} className="text-custom-text-200" aria-hidden="true" />,
},
issue: {
message: (activity) => {
@@ -279,7 +279,7 @@ const activityDetails: {
</>
);
},
icon: <LayersIcon width={12} height={12} color="#6b7280" aria-hidden="true" />,
icon: <LayersIcon width={12} height={12} className="text-custom-text-200" aria-hidden="true" />,
},
labels: {
message: (activity, showIssue, workspaceSlug) => {
@@ -320,7 +320,7 @@ const activityDetails: {
</>
);
},
icon: <TagIcon size={12} color="#6b7280" aria-hidden="true" />,
icon: <TagIcon size={12} className="text-custom-text-200" aria-hidden="true" />,
},
link: {
message: (activity, showIssue) => {
@@ -385,7 +385,7 @@ const activityDetails: {
</>
);
},
icon: <Link2Icon size={12} color="#6b7280" aria-hidden="true" />,
icon: <Link2Icon size={12} className="text-custom-text-200" aria-hidden="true" />,
},
cycles: {
message: (activity, showIssue, workspaceSlug) => {
@@ -435,7 +435,7 @@ const activityDetails: {
</>
);
},
icon: <ContrastIcon size={12} color="#6b7280" aria-hidden="true" />,
icon: <ContrastIcon size={12} className="text-custom-text-200" aria-hidden="true" />,
},
modules: {
message: (activity, showIssue, workspaceSlug) => {
@@ -482,7 +482,7 @@ const activityDetails: {
</>
);
},
icon: <DiceIcon className="h-3 w-3 !text-[#6b7280]" aria-hidden="true" />,
icon: <DiceIcon className="h-3 w-3 !text-custom-text-200" aria-hidden="true" />,
},
name: {
message: (activity, showIssue) => (
@@ -496,7 +496,7 @@ const activityDetails: {
)}
</>
),
icon: <MessageSquareIcon size={12} color="#6b7280" aria-hidden="true" />,
icon: <MessageSquareIcon size={12} className="text-custom-text-200" aria-hidden="true" />,
},
parent: {
message: (activity, showIssue) => {
@@ -527,7 +527,7 @@ const activityDetails: {
</>
);
},
icon: <UsersIcon className="h-3 w-3 !text-[#6b7280]" aria-hidden="true" />,
icon: <UsersIcon className="h-3 w-3 !text-custom-text-200" aria-hidden="true" />,
},
priority: {
message: (activity, showIssue) => (
@@ -544,7 +544,7 @@ const activityDetails: {
)}
</>
),
icon: <SignalMediumIcon size={12} color="#6b7280" aria-hidden="true" />,
icon: <SignalMediumIcon size={12} className="text-custom-text-200" aria-hidden="true" />,
},
relates_to: {
message: (activity, showIssue) => {
@@ -563,7 +563,7 @@ const activityDetails: {
</>
);
},
icon: <RelatedIcon height="12" width="12" color="#6b7280" />,
icon: <RelatedIcon height="12" width="12" className="text-custom-text-200" />,
},
blocking: {
message: (activity, showIssue) => {
@@ -582,7 +582,7 @@ const activityDetails: {
</>
);
},
icon: <BlockerIcon height="12" width="12" color="#6b7280" />,
icon: <BlockerIcon height="12" width="12" className="text-custom-text-200" />,
},
blocked_by: {
message: (activity, showIssue) => {
@@ -601,7 +601,7 @@ const activityDetails: {
</>
);
},
icon: <BlockedIcon height="12" width="12" color="#6b7280" />,
icon: <BlockedIcon height="12" width="12" className="text-custom-text-200" />,
},
duplicate: {
message: (activity, showIssue) => {
@@ -620,7 +620,7 @@ const activityDetails: {
</>
);
},
icon: <CopyPlus size={12} color="#6b7280" />,
icon: <CopyPlus size={12} className="text-custom-text-200" />,
},
state: {
message: (activity, showIssue) => (
@@ -634,7 +634,7 @@ const activityDetails: {
)}
</>
),
icon: <LayoutGridIcon size={12} color="#6b7280" aria-hidden="true" />,
icon: <LayoutGridIcon size={12} className="text-custom-text-200" aria-hidden="true" />,
},
start_date: {
message: (activity, showIssue) => {
@@ -666,7 +666,7 @@ const activityDetails: {
</>
);
},
icon: <Calendar size={12} color="#6b7280" aria-hidden="true" />,
icon: <Calendar size={12} className="text-custom-text-200" aria-hidden="true" />,
},
target_date: {
message: (activity, showIssue) => {
@@ -697,7 +697,7 @@ const activityDetails: {
</>
);
},
icon: <Calendar size={12} color="#6b7280" aria-hidden="true" />,
icon: <Calendar size={12} className="text-custom-text-200" aria-hidden="true" />,
},
inbox: {
message: (activity, showIssue) => (
@@ -712,7 +712,7 @@ const activityDetails: {
{activity.verb === "2" && ` from intake by marking a duplicate issue.`}
</>
),
icon: <Intake className="size-3" color="#6b7280" aria-hidden="true" />,
icon: <Intake className="size-3 text-custom-text-200" aria-hidden="true" />,
},
};
@@ -734,7 +734,7 @@ export const ActivityMessage = ({ activity, showIssue = false }: ActivityMessage
{activityDetails[activity.field as keyof typeof activityDetails]?.message(
activity,
showIssue,
workspaceSlug ? workspaceSlug.toString() : activity.workspace_detail?.slug ?? ""
workspaceSlug ? workspaceSlug.toString() : (activity.workspace_detail?.slug ?? "")
)}
</>
);

View File

@@ -2,6 +2,7 @@ import { observer } from "mobx-react";
import { Combobox } from "@headlessui/react";
// hooks
import { ISearchIssueResponse } from "@plane/types";
// plane web hooks
import { IssueIdentifier } from "@/plane-web/components/issues";
interface Props {
@@ -20,7 +21,7 @@ export const BulkDeleteIssuesModalItem: React.FC<Props> = observer((props: Props
as="div"
value={issue.id}
className={({ active }) =>
`flex cursor-pointer select-none items-center justify-between rounded-md px-3 py-2 ${
`flex cursor-pointer select-none items-center justify-between rounded-md px-3 py-2 my-0.5 ${
active ? "bg-custom-background-80 text-custom-text-100" : ""
}`
}

View File

@@ -6,22 +6,21 @@ import { useParams } from "next/navigation";
import { SubmitHandler, useForm } from "react-hook-form";
import { Search } from "lucide-react";
import { Combobox, Dialog, Transition } from "@headlessui/react";
//plane
// types
import { ISearchIssueResponse, IUser } from "@plane/types";
// ui
import { Button, Loader, TOAST_TYPE, setToast } from "@plane/ui";
//components
// components
import { EmptyState } from "@/components/empty-state";
//constants
// constants
import { EmptyStateType } from "@/constants/empty-state";
import { EIssuesStoreType } from "@/constants/issue";
//hooks
// hooks
import { useIssues } from "@/hooks/store";
import useDebounce from "@/hooks/use-debounce";
// services
import { ProjectService } from "@/services/project";
// ui
// icons
// components
// local components
import { BulkDeleteIssuesModalItem } from "./bulk-delete-issues-modal-item";
type FormInput = {

View File

@@ -10,6 +10,8 @@ import { Button, Loader, ToggleSwitch, Tooltip, TOAST_TYPE, setToast } from "@pl
// hooks
import useDebounce from "@/hooks/use-debounce";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web components
import { IssueIdentifier } from "@/plane-web/components/issues";
// services
import { ProjectService } from "@/services/project";
// components
@@ -149,7 +151,13 @@ export const ExistingIssuesListModal: React.FC<Props> = (props) => {
key={issue.id}
className="flex items-center gap-1 whitespace-nowrap rounded-md border border-custom-border-200 bg-custom-background-80 py-1 pl-2 text-xs text-custom-text-100"
>
{issue.project__identifier}-{issue.sequence_id}
<IssueIdentifier
projectId={issue.project_id}
issueTypeId={issue.type_id}
projectIdentifier={issue.project__identifier}
issueSequenceId={issue.sequence_id}
textContainerClassName="text-xs text-custom-text-200"
/>
<button
type="button"
className="group p-1"
@@ -232,7 +240,7 @@ export const ExistingIssuesListModal: React.FC<Props> = (props) => {
htmlFor={`issue-${issue.id}`}
value={issue}
className={({ active }) =>
`group flex w-full cursor-pointer select-none items-center justify-between gap-2 rounded-md px-3 py-2 text-custom-text-200 ${
`group flex w-full cursor-pointer select-none items-center justify-between gap-2 rounded-md px-3 py-2 my-0.5 text-custom-text-200 ${
active ? "bg-custom-background-80 text-custom-text-100" : ""
} ${selected ? "text-custom-text-100" : ""}`
}
@@ -245,8 +253,14 @@ export const ExistingIssuesListModal: React.FC<Props> = (props) => {
backgroundColor: issue.state__color,
}}
/>
<span className="flex-shrink-0 text-xs">
{issue.project__identifier}-{issue.sequence_id}
<span className="flex-shrink-0">
<IssueIdentifier
projectId={issue.project_id}
issueTypeId={issue.type_id}
projectIdentifier={issue.project__identifier}
issueSequenceId={issue.sequence_id}
textContainerClassName="text-xs text-custom-text-200"
/>
</span>
{issue.name}
</div>

View File

@@ -2,16 +2,16 @@
import isToday from "date-fns/isToday";
import { observer } from "mobx-react";
// types
import { TIssue, TWidgetIssue } from "@plane/types";
// hooks
// ui
import { Avatar, AvatarGroup, ControlLink, PriorityIcon } from "@plane/ui";
// helpers
import { findTotalDaysInRange, getDate, renderFormattedDate } from "@/helpers/date-time.helper";
// hooks
import { useIssueDetail, useMember, useProject } from "@/hooks/store";
// plane web components
import { IssueIdentifier } from "@/plane-web/components/issues";
// types
export type IssueListItemProps = {
issueId: string;
@@ -67,12 +67,12 @@ export const AssignedUpcomingIssueListItem: React.FC<IssueListItemProps> = obser
? blockedByIssues.length > 1
? `${blockedByIssues.length} blockers`
: blockedByIssueProjectDetails && (
<IssueIdentifier
issueId={blockedByIssues[0]?.id}
projectId={blockedByIssueProjectDetails?.id}
textContainerClassName="text-xs text-custom-text-200 font-medium"
/>
)
<IssueIdentifier
issueId={blockedByIssues[0]?.id}
projectId={blockedByIssueProjectDetails?.id}
textContainerClassName="text-xs text-custom-text-200 font-medium"
/>
)
: "-"}
</div>
</ControlLink>
@@ -126,12 +126,12 @@ export const AssignedOverdueIssueListItem: React.FC<IssueListItemProps> = observ
? blockedByIssues.length > 1
? `${blockedByIssues.length} blockers`
: blockedByIssueProjectDetails && (
<IssueIdentifier
issueId={blockedByIssues[0]?.id}
projectId={blockedByIssueProjectDetails?.id}
textContainerClassName="text-xs text-custom-text-200 font-medium"
/>
)
<IssueIdentifier
issueId={blockedByIssues[0]?.id}
projectId={blockedByIssueProjectDetails?.id}
textContainerClassName="text-xs text-custom-text-200 font-medium"
/>
)
: "-"}
</div>
</ControlLink>

View File

@@ -74,23 +74,23 @@ export const WidgetIssuesList: React.FC<WidgetIssuesListProps> = (props) => {
</Loader>
) : issuesList.length > 0 ? (
<>
<div className="mt-7 mx-6 border-b-[0.5px] border-custom-border-200 grid grid-cols-12 gap-1 text-xs text-custom-text-300 pb-1">
<div className="mt-7 mx-6 border-b-[0.5px] border-custom-border-200 grid grid-cols-12 gap-1 text-xs text-custom-text-300 pb-1">
<h6
className={cn("pl-1 flex items-center gap-1 col-span-7", {
"col-span-11": type === "assigned" && tab === "completed",
"col-span-9": type === "created" && tab === "completed",
className={cn("pl-1 flex items-center gap-1 col-span-7", {
"col-span-11": type === "assigned" && tab === "completed",
"col-span-9": type === "created" && tab === "completed",
})}
>
Issues
<span className="flex-shrink-0 bg-custom-primary-100/20 text-custom-primary-100 text-xs font-medium rounded-xl px-2 flex items-center text-center justify-center">
<span className="flex-shrink-0 bg-custom-primary-100/20 text-custom-primary-100 text-xs font-medium rounded-xl px-2 flex items-center text-center justify-center">
{widgetStats.count}
</span>
</h6>
<h6 className="text-center col-span-1">Priority</h6>
{["upcoming", "pending"].includes(tab) && <h6 className="text-center col-span-2">Due date</h6>}
{tab === "overdue" && <h6 className="text-center col-span-2">Due by</h6>}
{type === "assigned" && tab !== "completed" && <h6 className="text-center col-span-2">Blocked by</h6>}
{type === "created" && <h6 className="text-center col-span-2">Assigned to</h6>}
<h6 className="text-center col-span-1">Priority</h6>
{["upcoming", "pending"].includes(tab) && <h6 className="text-center col-span-2">Due date</h6>}
{tab === "overdue" && <h6 className="text-center col-span-2">Due by</h6>}
{type === "assigned" && tab !== "completed" && <h6 className="text-center col-span-2">Blocked by</h6>}
{type === "created" && <h6 className="text-center col-span-2">Assigned to</h6>}
</div>
<div className="px-4 pb-3 mt-2">
{issuesList.map((issue) => {

View File

@@ -2,7 +2,8 @@ import { FC } from "react";
import { observer } from "mobx-react";
// hooks
import { useIssueDetail } from "@/hooks/store";
// plane wev components
// plane web components
import { IssueAdditionalPropertiesActivity } from "@/plane-web/components/issues";
import { IssueActivityWorklog } from "@/plane-web/components/issues/worklog/activity/root";
// plane web constants
import { TActivityFilters, filterActivityOnSelectedFilters } from "@/plane-web/constants/issues";
@@ -56,6 +57,11 @@ export const IssueActivityCommentRoot: FC<TIssueActivityCommentRoot> = observer(
activityId={activityComment.id}
ends={index === 0 ? "top" : index === filteredActivityComments.length - 1 ? "bottom" : undefined}
/>
) : activityComment.activity_type === "ISSUE_ADDITIONAL_PROPERTIES_ACTIVITY" ? (
<IssueAdditionalPropertiesActivity
activityId={activityComment.id}
ends={index === 0 ? "top" : index === filteredActivityComments.length - 1 ? "bottom" : undefined}
/>
) : activityComment.activity_type === "WORKLOG" ? (
<IssueActivityWorklog
workspaceSlug={workspaceSlug}

View File

@@ -27,9 +27,9 @@ export const IssueArchivedAtActivity: FC<TIssueArchivedAtActivity> = observer((p
<IssueActivityBlockComponent
icon={
activity.new_value === "restore" ? (
<RotateCcw className="h-3.5 w-3.5" color="#6b7280" aria-hidden="true" />
<RotateCcw className="h-3.5 w-3.5 text-custom-text-200" aria-hidden="true" />
) : (
<ArchiveIcon className="h-3.5 w-3.5" color="#6b7280" aria-hidden="true" />
<ArchiveIcon className="h-3.5 w-3.5 text-custom-text-200" aria-hidden="true" />
)
}
activityId={activityId}

View File

@@ -21,7 +21,7 @@ export const IssueAssigneeActivity: FC<TIssueAssigneeActivity> = observer((props
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<Users className="h-3 w-3 flex-shrink-0" />}
icon={<Users className="h-3.5 w-3.5 flex-shrink-0 text-custom-text-200" />}
activityId={activityId}
ends={ends}
>

View File

@@ -20,7 +20,7 @@ export const IssueAttachmentActivity: FC<TIssueAttachmentActivity> = observer((p
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<Paperclip size={14} color="#6b7280" aria-hidden="true" />}
icon={<Paperclip size={14} className="text-custom-text-200" aria-hidden="true" />}
activityId={activityId}
ends={ends}
>

View File

@@ -23,7 +23,7 @@ export const IssueCycleActivity: FC<TIssueCycleActivity> = observer((props) => {
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<ContrastIcon className="h-4 w-4 flex-shrink-0 text-[#6b7280]" />}
icon={<ContrastIcon className="h-4 w-4 flex-shrink-0 text-custom-text-200" />}
activityId={activityId}
ends={ends}
>

View File

@@ -24,7 +24,7 @@ export const IssueDefaultActivity: FC<TIssueDefaultActivity> = observer((props)
return (
<IssueActivityBlockComponent
activityId={activityId}
icon={<LayersIcon width={12} height={12} color="#6b7280" aria-hidden="true" />}
icon={<LayersIcon width={14} height={14} className="text-custom-text-200" aria-hidden="true" />}
ends={ends}
>
<>{activity.verb === "created" ? " created the issue." : " deleted an issue."}</>

View File

@@ -20,7 +20,7 @@ export const IssueDescriptionActivity: FC<TIssueDescriptionActivity> = observer(
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<MessageSquare size={14} color="#6b7280" aria-hidden="true" />}
icon={<MessageSquare size={14} className="text-custom-text-200" aria-hidden="true" />}
activityId={activityId}
ends={ends}
>

View File

@@ -21,7 +21,7 @@ export const IssueEstimateActivity: FC<TIssueEstimateActivity> = observer((props
return (
<IssueActivityBlockComponent
icon={<Triangle size={14} color="#6b7280" aria-hidden="true" />}
icon={<Triangle size={14} className="text-custom-text-200" aria-hidden="true" />}
activityId={activityId}
ends={ends}
>

View File

@@ -36,7 +36,7 @@ export const IssueInboxActivity: FC<TIssueInboxActivity> = observer((props) => {
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<Intake className="h-4 w-4 flex-shrink-0" />}
icon={<Intake className="h-4 w-4 flex-shrink-0 text-custom-text-200" />}
activityId={activityId}
ends={ends}
>

View File

@@ -21,7 +21,7 @@ export const IssueLabelActivity: FC<TIssueLabelActivity> = observer((props) => {
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<Tag size={14} color="#6b7280" aria-hidden="true" />}
icon={<Tag size={14} className="text-custom-text-200" aria-hidden="true" />}
activityId={activityId}
ends={ends}
>

View File

@@ -20,7 +20,7 @@ export const IssueLinkActivity: FC<TIssueLinkActivity> = observer((props) => {
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<MessageSquare size={14} color="#6b7280" aria-hidden="true" />}
icon={<MessageSquare size={14} className="text-custom-text-200" aria-hidden="true" />}
activityId={activityId}
ends={ends}
>

View File

@@ -23,7 +23,7 @@ export const IssueModuleActivity: FC<TIssueModuleActivity> = observer((props) =>
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<DiceIcon className="h-4 w-4 flex-shrink-0 text-[#6b7280]" />}
icon={<DiceIcon className="h-4 w-4 flex-shrink-0 text-custom-text-200" />}
activityId={activityId}
ends={ends}
>

View File

@@ -20,7 +20,7 @@ export const IssueNameActivity: FC<TIssueNameActivity> = observer((props) => {
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<MessageSquare size={14} color="#6b7280" aria-hidden="true" />}
icon={<MessageSquare size={14} className="text-custom-text-200" aria-hidden="true" />}
activityId={activityId}
ends={ends}
>

View File

@@ -20,7 +20,7 @@ export const IssueParentActivity: FC<TIssueParentActivity> = observer((props) =>
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<LayoutPanelTop size={14} color="#6b7280" aria-hidden="true" />}
icon={<LayoutPanelTop size={14} className="text-custom-text-200" aria-hidden="true" />}
activityId={activityId}
ends={ends}
>

View File

@@ -20,7 +20,7 @@ export const IssuePriorityActivity: FC<TIssuePriorityActivity> = observer((props
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<Signal size={14} color="#6b7280" aria-hidden="true" />}
icon={<Signal size={14} className="text-custom-text-200" aria-hidden="true" />}
activityId={activityId}
ends={ends}
>

View File

@@ -22,7 +22,7 @@ export const IssueStartDateActivity: FC<TIssueStartDateActivity> = observer((pro
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<CalendarDays size={14} color="#6b7280" aria-hidden="true" />}
icon={<CalendarDays size={14} className="text-custom-text-200" aria-hidden="true" />}
activityId={activityId}
ends={ends}
>

View File

@@ -23,7 +23,7 @@ export const IssueStateActivity: FC<TIssueStateActivity> = observer((props) => {
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<DoubleCircleIcon className="h-4 w-4 flex-shrink-0" />}
icon={<DoubleCircleIcon className="h-4 w-4 flex-shrink-0 text-custom-text-200" />}
activityId={activityId}
ends={ends}
>

View File

@@ -22,7 +22,7 @@ export const IssueTargetDateActivity: FC<TIssueTargetDateActivity> = observer((p
if (!activity) return <></>;
return (
<IssueActivityBlockComponent
icon={<CalendarDays size={14} color="#6b7280" aria-hidden="true" />}
icon={<CalendarDays size={14} className="text-custom-text-200" aria-hidden="true" />}
activityId={activityId}
ends={ends}
>

View File

@@ -45,7 +45,7 @@ export const IssueCommentBlock: FC<TIssueCommentBlock> = observer((props) => {
</>
)}
<div className="absolute top-2 left-4 w-5 h-5 rounded-full overflow-hidden flex justify-center items-center bg-custom-background-80">
<MessageCircle className="w-3 h-3" color="#6b7280" />
<MessageCircle className="w-3 h-3 text-custom-text-200" />
</div>
</div>
<div className="w-full truncate relative flex ">

View File

@@ -67,7 +67,7 @@ export const IssueMainContent: React.FC<Props> = observer((props) => {
)}
<div className="mb-2.5 flex items-center gap-4">
<IssueIdentifier issueId={issueId} projectId={issue.project_id} iconSize={14} iconContainerSize={20} />
<IssueIdentifier issueId={issueId} projectId={issue.project_id} size="md" />
<IssueUpdateStatus isSubmitting={isSubmitting} />
</div>

View File

@@ -4,15 +4,17 @@ import React from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { Pencil, X } from "lucide-react";
// hooks
// components
import { TOAST_TYPE, Tooltip, setToast } from "@plane/ui";
import { ParentIssuesListModal } from "@/components/issues";
// ui
import { TOAST_TYPE, Tooltip, setToast } from "@plane/ui";
// components
import { ParentIssuesListModal } from "@/components/issues";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
import { useIssueDetail, useProject } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web components
import { IssueIdentifier } from "@/plane-web/components/issues";
// types
import { TIssueOperations } from "./root";
@@ -102,16 +104,23 @@ export const IssueParentSelect: React.FC<TIssueParentSelect> = observer((props)
disabled={disabled}
>
{issue.parent_id && parentIssue ? (
<div className="flex items-center gap-1 bg-green-500/20 text-green-700 rounded px-1.5 py-1">
<div className="flex items-center gap-1 bg-green-500/20 rounded px-1.5 py-1">
<Tooltip tooltipHeading="Title" tooltipContent={parentIssue.name} isMobile={isMobile}>
<Link
href={`/${workspaceSlug}/projects/${parentIssue.project_id}/issues/${parentIssue?.id}`}
target="_blank"
rel="noopener noreferrer"
className="text-xs font-medium"
onClick={(e) => e.stopPropagation()}
>
{parentIssueProjectDetails?.identifier}-{parentIssue.sequence_id}
{parentIssue?.project_id && parentIssueProjectDetails && (
<IssueIdentifier
projectId={parentIssue.project_id}
issueTypeId={parentIssue.type_id}
projectIdentifier={parentIssueProjectDetails?.identifier}
issueSequenceId={parentIssue.sequence_id}
textContainerClassName="text-xs font-medium text-green-700"
/>
)}
</Link>
</Tooltip>

View File

@@ -9,7 +9,9 @@ import { TIssue } from "@plane/types";
// ui
import { CustomMenu } from "@plane/ui";
// hooks
import { useIssues, useProject, useProjectState } from "@/hooks/store";
import { useIssues, useProjectState } from "@/hooks/store";
// plane web components
import { IssueIdentifier } from "@/plane-web/components/issues";
// types
import { TIssueOperations } from "../root";
import { IssueParentSiblings } from "./siblings";
@@ -26,7 +28,6 @@ export const IssueParentDetail: FC<TIssueParentDetail> = observer((props) => {
const { workspaceSlug, projectId, issueId, issue, issueOperations } = props;
// hooks
const { issueMap } = useIssues();
const { getProjectById } = useProject();
const { getProjectStates } = useProjectState();
const parentIssue = issueMap?.[issue.parent_id || ""] || undefined;
@@ -42,12 +43,16 @@ export const IssueParentDetail: FC<TIssueParentDetail> = observer((props) => {
<>
<div className="mb-5 flex w-min items-center gap-3 whitespace-nowrap rounded-md border border-custom-border-300 bg-custom-background-80 px-2.5 py-1 text-xs">
<Link href={`/${workspaceSlug}/projects/${parentIssue?.project_id}/issues/${parentIssue.id}`}>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<div className="flex items-center gap-2.5">
<span className="block h-2 w-2 rounded-full" style={{ backgroundColor: stateColor }} />
<span className="flex-shrink-0 text-custom-text-200">
{getProjectById(parentIssue.project_id)?.identifier}-{parentIssue?.sequence_id}
</span>
{parentIssue.project_id && (
<IssueIdentifier
projectId={parentIssue.project_id}
issueId={parentIssue.id}
textContainerClassName="text-xs text-custom-text-200"
/>
)}
</div>
<span className="truncate text-custom-text-100">{(parentIssue?.name ?? "").substring(0, 50)}</span>
</div>

View File

@@ -4,9 +4,11 @@ import { FC } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
// ui
import { CustomMenu, LayersIcon } from "@plane/ui";
import { CustomMenu } from "@plane/ui";
// hooks
import { useIssueDetail, useProject } from "@/hooks/store";
// plane web components
import { IssueIdentifier } from "@/plane-web/components/issues";
type TIssueParentSiblingItem = {
workspaceSlug: string;
@@ -31,10 +33,17 @@ export const IssueParentSiblingItem: FC<TIssueParentSiblingItem> = observer((pro
<CustomMenu.MenuItem key={issueDetail.id}>
<Link
href={`/${workspaceSlug}/projects/${issueDetail?.project_id as string}/issues/${issueDetail.id}`}
className="flex items-center gap-2 py-2"
className="flex items-center gap-2 py-0.5"
>
<LayersIcon className="h-4 w-4" />
{projectDetails?.identifier}-{issueDetail.sequence_id}
{issueDetail.project_id && projectDetails?.identifier && (
<IssueIdentifier
projectId={issueDetail.project_id}
issueTypeId={issueDetail.type_id}
projectIdentifier={projectDetails?.identifier}
issueSequenceId={issueDetail.sequence_id}
textContainerClassName="text-xs"
/>
)}
</Link>
</CustomMenu.MenuItem>
</>

View File

@@ -34,7 +34,7 @@ export const IssueParentSiblings: FC<TIssueParentSiblings> = observer((props) =>
const subIssueIds = (parentIssue && subIssuesByIssueId(parentIssue.id)) || undefined;
return (
<div>
<div className="my-1">
{isLoading ? (
<div className="flex items-center gap-2 whitespace-nowrap px-1 py-1 text-left text-xs text-custom-text-200">
Loading

View File

@@ -21,22 +21,22 @@ export type TRelationObject = { className: string; icon: (size: number) => React
export const issueRelationObject: Record<TIssueRelationTypes, TRelationObject> = {
relates_to: {
className: "bg-custom-background-80 text-custom-text-200",
icon: (size) => <RelatedIcon height={size} width={size} />,
icon: (size) => <RelatedIcon height={size} width={size} className="text-custom-text-200" />,
placeholder: "Add related issues",
},
blocking: {
className: "bg-yellow-500/20 text-yellow-700",
icon: (size) => <XCircle size={size} />,
icon: (size) => <XCircle size={size} className="text-custom-text-200" />,
placeholder: "None",
},
blocked_by: {
className: "bg-red-500/20 text-red-700",
icon: (size) => <CircleDot size={size} />,
icon: (size) => <CircleDot size={size} className="text-custom-text-200" />,
placeholder: "None",
},
duplicate: {
className: "bg-custom-background-80 text-custom-text-200",
icon: (size) => <CopyPlus size={size} />,
icon: (size) => <CopyPlus size={size} className="text-custom-text-200" />,
placeholder: "None",
},
};

View File

@@ -5,17 +5,19 @@ import { useState, useRef, forwardRef } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { MoreHorizontal } from "lucide-react";
// types
import { TIssue } from "@plane/types";
// components
// ui
import { Tooltip, ControlLink } from "@plane/ui";
// hooks
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
import { useIssueDetail, useProjectState } from "@/hooks/store";
import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
// helpers
// types
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web components
import { IssueIdentifier } from "@/plane-web/components/issues/issue-details";
// local components
import { TRenderQuickActions } from "../list/list-view-types";
type Props = {

View File

@@ -2,13 +2,14 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// hooks
// ui
import { Tooltip, ControlLink } from "@plane/ui";
// helpers
import { renderFormattedDate } from "@/helpers/date-time.helper";
// hooks
import { useIssueDetail, useProjectState } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web components
import { IssueIdentifier } from "@/plane-web/components/issues";
type Props = {

View File

@@ -5,25 +5,26 @@ import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// types
import { TIssue, IIssueDisplayProperties, IIssueMap } from "@plane/types";
// hooks
// ui
import { ControlLink, DropIndicator, TOAST_TYPE, Tooltip, setToast } from "@plane/ui";
// components
import RenderIfVisible from "@/components/core/render-if-visible-HOC";
import { HIGHLIGHT_CLASS } from "@/components/issues/issue-layouts/utils";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
import { useIssueDetail, useKanbanView } from "@/hooks/store";
import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
import { usePlatformOS } from "@/hooks/use-platform-os";
// components
// plane web components
import { IssueIdentifier } from "@/plane-web/components/issues";
// local components
import { TRenderQuickActions } from "../list/list-view-types";
import { IssueProperties } from "../properties/all-properties";
import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC";
import { getIssueBlockId } from "../utils";
// ui
// types
// helper
interface IssueBlockProps {
issueId: string;

View File

@@ -19,8 +19,9 @@ import { cn } from "@/helpers/common.helper";
import { useAppTheme, useIssueDetail, useProject } from "@/hooks/store";
import { TSelectionHelper } from "@/hooks/use-multiple-select";
import { usePlatformOS } from "@/hooks/use-platform-os";
// types
// plane web components
import { IssueIdentifier } from "@/plane-web/components/issues";
// types
import { TRenderQuickActions } from "./list-view-types";
interface IssueBlockProps {

View File

@@ -20,8 +20,9 @@ import { useIssueDetail, useProject } from "@/hooks/store";
import { TSelectionHelper } from "@/hooks/use-multiple-select";
import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
import { usePlatformOS } from "@/hooks/use-platform-os";
// local components
// plane web components
import { IssueIdentifier } from "@/plane-web/components/issues";
// local components
import { TRenderQuickActions } from "../list/list-view-types";
import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC";
import { IssueColumn } from "./issue-column";

View File

@@ -25,6 +25,8 @@ import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper"
import { getTabIndex } from "@/helpers/issue-modal.helper";
// hooks
import { useProjectEstimates, useProject } from "@/hooks/store";
// plane web components
import { IssueIdentifier } from "@/plane-web/components/issues";
type TIssueDefaultPropertiesProps = {
control: Control<TIssue>;
@@ -256,10 +258,15 @@ export const IssueDefaultProperties: React.FC<TIssueDefaultPropertiesProps> = ob
type="button"
className="flex cursor-pointer items-center justify-between gap-1 rounded border-[0.5px] border-custom-border-300 px-2 py-1.5 text-xs hover:bg-custom-background-80"
>
<LayoutPanelTop className="h-3 w-3 flex-shrink-0" />
<span className="whitespace-nowrap">
{selectedParentIssue && `${selectedParentIssue.project__identifier}-${selectedParentIssue.sequence_id}`}
</span>
{selectedParentIssue?.project_id && (
<IssueIdentifier
projectId={selectedParentIssue.project_id}
issueTypeId={selectedParentIssue.type_id}
projectIdentifier={selectedParentIssue?.project__identifier}
issueSequenceId={selectedParentIssue.sequence_id}
textContainerClassName="text-xs"
/>
)}
</button>
}
placement="bottom-start"

View File

@@ -8,6 +8,8 @@ import { X } from "lucide-react";
import { ISearchIssueResponse, TIssue } from "@plane/types";
// helpers
import { getTabIndex } from "@/helpers/issue-modal.helper";
// plane web components
import { IssueIdentifier } from "@/plane-web/components/issues";
type TIssueParentTagProps = {
control: Control<TIssue>;
@@ -33,7 +35,15 @@ export const IssueParentTag: React.FC<TIssueParentTagProps> = observer((props) =
}}
/>
<span className="flex-shrink-0 text-custom-text-200">
{selectedParentIssue.project__identifier}-{selectedParentIssue.sequence_id}
{selectedParentIssue?.project_id && (
<IssueIdentifier
projectId={selectedParentIssue.project_id}
issueTypeId={selectedParentIssue.type_id}
projectIdentifier={selectedParentIssue?.project__identifier}
issueSequenceId={selectedParentIssue.sequence_id}
textContainerClassName="text-xs"
/>
)}
</span>
<span className="truncate font-medium">{selectedParentIssue.name.substring(0, 50)}</span>
<button

View File

@@ -2,21 +2,23 @@
import React, { useEffect, useState } from "react";
import { useParams } from "next/navigation";
// headless ui
// icons
import { Rocket, Search } from "lucide-react";
// headless ui
import { Combobox, Dialog, Transition } from "@headlessui/react";
// services
// types
import { ISearchIssueResponse } from "@plane/types";
// ui
import { Loader, ToggleSwitch, Tooltip } from "@plane/ui";
// components
import { IssueSearchModalEmptyState } from "@/components/core";
// hooks
import useDebounce from "@/hooks/use-debounce";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web components
import { IssueIdentifier } from "@/plane-web/components/issues";
// services
import { ProjectService } from "@/services/project";
// hooks
// components
// ui
// icons
// types
type Props = {
isOpen: boolean;
@@ -182,7 +184,7 @@ export const ParentIssuesListModal: React.FC<Props> = ({
key={issue.id}
value={issue}
className={({ active, selected }) =>
`group flex w-full cursor-pointer select-none items-center justify-between gap-2 rounded-md px-3 py-2 text-custom-text-200 ${
`group flex w-full cursor-pointer select-none items-center justify-between gap-2 rounded-md px-3 py-2 my-0.5 text-custom-text-200 ${
active ? "bg-custom-background-80 text-custom-text-100" : ""
} ${selected ? "text-custom-text-100" : ""}`
}
@@ -194,8 +196,14 @@ export const ParentIssuesListModal: React.FC<Props> = ({
backgroundColor: issue.state__color,
}}
/>
<span className="flex-shrink-0 text-xs">
{issue.project__identifier}-{issue.sequence_id}
<span className="flex-shrink-0">
<IssueIdentifier
projectId={issue.project_id}
issueTypeId={issue.type_id}
projectIdentifier={issue.project__identifier}
issueSequenceId={issue.sequence_id}
textContainerClassName="text-xs text-custom-text-200"
/>
</span>{" "}
<span className="truncate">{issue.name}</span>
</div>

View File

@@ -56,7 +56,7 @@ export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = observer(
return (
<div className="space-y-2">
<IssueIdentifier issueId={issueId} projectId={issue.project_id} iconSize={14} iconContainerSize={20} />
<IssueIdentifier issueId={issueId} projectId={issue.project_id} size="md" />
<IssueTitleInput
workspaceSlug={workspaceSlug}
projectId={issue.project_id}

View File

@@ -241,7 +241,7 @@ export const IssueView: FC<IIssueView> = observer((props) => {
</div>
</div>
<div
className={`h-full !w-[400px] flex-shrink-0 border-l border-custom-border-200 p-4 py-5 overflow-hidden vertical-scrollbar scrollbar-sm ${
className={`h-full !w-[400px] flex-shrink-0 border-l border-custom-border-200 p-4 py-5 overflow-hidden vertical-scrollbar scrollbar-sm ${
is_archived ? "pointer-events-none" : ""
}`}
>

View File

@@ -10,6 +10,8 @@ import { RelationIssueProperty } from "@/components/issues/relations";
// hooks
import { useIssueDetail, useProject, useProjectState } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web components
import { IssueIdentifier } from "@/plane-web/components/issues";
// types
import { TRelationIssueOperations } from "../issue-detail-widgets/relations/helper";
@@ -91,15 +93,23 @@ export const RelationIssueListItem: FC<Props> = observer((props) => {
{issue && (
<div className="group relative flex min-h-11 h-full w-full items-center gap-3 px-1.5 py-1 transition-all hover:bg-custom-background-90">
<span className="size-5 flex-shrink-0" />
<div className="flex w-full cursor-pointer items-center gap-2">
<div className="flex w-full cursor-pointer items-center gap-3">
<div
className="h-2 w-2 flex-shrink-0 rounded-full"
style={{
backgroundColor: currentIssueStateDetail?.color ?? "#737373",
}}
/>
<div className="flex-shrink-0 text-xs text-custom-text-200">
{projectDetail?.identifier}-{issue?.sequence_id}
<div className="flex-shrink-0">
{projectDetail && (
<IssueIdentifier
projectId={projectDetail.id}
issueTypeId={issue.type_id}
projectIdentifier={projectDetail.identifier}
issueSequenceId={issue.sequence_id}
textContainerClassName="text-xs text-custom-text-200"
/>
)}
</div>
<ControlLink

View File

@@ -4,11 +4,16 @@ import React from "react";
import { observer } from "mobx-react";
import { ChevronRight, X, Pencil, Trash, Link as LinkIcon, Loader } from "lucide-react";
import { TIssue } from "@plane/types";
// components
// ui
import { ControlLink, CustomMenu, Tooltip } from "@plane/ui";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
import { useIssueDetail, useProject, useProjectState } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web components
import { IssueIdentifier } from "@/plane-web/components/issues";
// local components
import { IssueList } from "./issues-list";
import { IssueProperty } from "./properties";
// ui
@@ -117,15 +122,23 @@ export const IssueListItem: React.FC<ISubIssues> = observer((props) => {
)}
</div>
<div className="flex w-full cursor-pointer items-center gap-2">
<div className="flex w-full cursor-pointer items-center gap-3">
<div
className="h-2 w-2 flex-shrink-0 rounded-full"
style={{
backgroundColor: currentIssueStateDetail?.color ?? "#737373",
}}
/>
<div className="flex-shrink-0 text-xs text-custom-text-200">
{projectDetail?.identifier}-{issue?.sequence_id}
<div className="flex-shrink-0">
{projectDetail && (
<IssueIdentifier
projectId={projectDetail.id}
issueTypeId={issue.type_id}
projectIdentifier={projectDetail.identifier}
issueSequenceId={issue.sequence_id}
textContainerClassName="text-xs text-custom-text-200"
/>
)}
</div>
<ControlLink

View File

@@ -19,8 +19,9 @@ import {
useUser,
useCommandPalette,
} from "@/hooks/store";
// plane web hooks
import { useIssueTypes } from "@/plane-web/hooks/store";
// images
import { useFlag, useIssueTypes } from "@/plane-web/hooks/store";
import emptyProject from "@/public/empty-state/onboarding/dashboard-light.webp";
interface IProjectAuthWrapper {
@@ -46,7 +47,7 @@ export const ProjectAuthWrapper: FC<IProjectAuthWrapper> = observer((props) => {
const { fetchProjectStates } = useProjectState();
const { fetchProjectLabels } = useLabel();
const { getProjectEstimates } = useProjectEstimates();
const { fetchAllPropertiesAndOptions } = useIssueTypes();
const { isIssueTypeEnabledForProject, fetchAllPropertiesAndOptions } = useIssueTypes();
// router
const { workspaceSlug, projectId } = useParams();
@@ -103,10 +104,11 @@ export const ProjectAuthWrapper: FC<IProjectAuthWrapper> = observer((props) => {
{ revalidateIfStale: false, revalidateOnFocus: false }
);
const projectExists = projectId ? getProjectById(projectId.toString()) : null;
const isIssueTypeEnabled =
projectExists?.is_issue_type_enabled && useFlag(workspaceSlug?.toString(), "ISSUE_TYPE_DISPLAY");
const isIssueTypeEnabled = isIssueTypeEnabledForProject(
workspaceSlug?.toString(),
projectId?.toString(),
"ISSUE_TYPE_DISPLAY"
);
// fetching all issue types and properties
useSWR(
workspaceSlug && projectId && isIssueTypeEnabled
@@ -128,6 +130,7 @@ export const ProjectAuthWrapper: FC<IProjectAuthWrapper> = observer((props) => {
</div>
);
const projectExists = projectId ? getProjectById(projectId.toString()) : null;
// check if the user don't have permission to access the project
if (projectExists && projectId && hasPermissionToProject[projectId.toString()] === false) return <JoinProject />;

View File

@@ -6,6 +6,7 @@ import useSWR from "swr";
import { LogoSpinner } from "@/components/common";
import { cn } from "@/helpers/common.helper";
import { ServiceAPITokenService } from "@/plane-web/services/api_token.service";
import { Spinner } from "@plane/ui";
interface CustomIframeProps {
srcBase: string;
@@ -69,7 +70,7 @@ const SiloIframe: React.FC<CustomIframeProps> = ({ srcBase }) => {
hidden: isLoaded,
})}
>
<LogoSpinner />
<Spinner />
</div>
</div>
);

View File

@@ -2,10 +2,14 @@ import React, { useRef, useState } from "react";
import { Placement } from "@popperjs/core";
import { usePopper } from "react-popper";
import { Popover } from "@headlessui/react";
// types
import { TLogoProps } from "@plane/types";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
import { IssueTypeLogo } from "./issue-type-logo";
// local components
import { IssueTypeLogo, TIssueTypeLogoSize } from "./issue-type-logo";
import { LucideIconsList } from "./lucide-icons-list";
export type TIssueTypeIconPicker = {
@@ -19,8 +23,7 @@ export type TIssueTypeIconPicker = {
icon_props: TLogoProps["icon"];
onChange: (value: TLogoProps["icon"]) => void;
placement?: Placement;
size?: number;
containerSize?: number;
size?: TIssueTypeLogoSize;
};
export const IssueTypeIconPicker: React.FC<TIssueTypeIconPicker> = (props) => {
@@ -35,7 +38,6 @@ export const IssueTypeIconPicker: React.FC<TIssueTypeIconPicker> = (props) => {
icon_props,
onChange,
size,
containerSize,
placement = "bottom-start",
} = props;
// refs
@@ -69,12 +71,7 @@ export const IssueTypeIconPicker: React.FC<TIssueTypeIconPicker> = (props) => {
disabled={disabled}
onClick={() => handleToggle(!isOpen)}
>
<IssueTypeLogo
icon_props={icon_props}
size={size}
containerSize={containerSize}
containerClassName={iconContainerClassName}
/>
<IssueTypeLogo icon_props={icon_props} size={size} containerClassName={iconContainerClassName} />
</button>
</Popover.Button>
{isOpen && (

View File

@@ -1,5 +1,4 @@
import React, { FC } from "react";
import { OctagonAlert } from "lucide-react";
// types
import { TLogoProps } from "@plane/types";
// ui
@@ -7,16 +6,31 @@ import { LayersIcon, LUCIDE_ICONS_LIST } from "@plane/ui";
// helpers
import { cn } from "@/helpers/common.helper";
export type TIssueTypeLogoSize = "sm" | "md" | "lg" | "xl";
type Props = {
icon_props: TLogoProps["icon"];
size?: number;
containerSize?: number;
size?: TIssueTypeLogoSize;
containerClassName?: string;
isDefault?: boolean;
};
const iconSizeMap = {
sm: 12.5,
md: 14.5,
lg: 18,
xl: 25,
};
const containerSizeMap = {
sm: 18,
md: 21,
lg: 25.5,
xl: 35.5,
};
export const IssueTypeLogo: FC<Props> = (props) => {
const { icon_props, size = 11, containerSize = 18, containerClassName, isDefault = false } = props;
const { icon_props, size = "sm", containerClassName, isDefault = false } = props;
// derived values
const LucideIcon = LUCIDE_ICONS_LIST.find((item) => item.name === icon_props?.name);
@@ -27,16 +41,16 @@ export const IssueTypeLogo: FC<Props> = (props) => {
<>
<span
style={{
height: containerSize,
width: containerSize,
height: containerSizeMap[size],
width: containerSizeMap[size],
backgroundColor: icon_props?.background_color,
}}
className={cn("flex-shrink-0 grid place-items-center rounded bg-custom-background-80", containerClassName)} // fallback background color
>
{isDefault ? (
<LayersIcon
width={size}
height={size}
width={iconSizeMap[size]}
height={iconSizeMap[size]}
style={{
color: icon_props?.color ?? "#ffffff", // fallback color
}}
@@ -45,8 +59,8 @@ export const IssueTypeLogo: FC<Props> = (props) => {
LucideIcon && (
<LucideIcon.element
style={{
height: size,
width: size,
height: iconSizeMap[size],
width: iconSizeMap[size],
color: icon_props?.color ?? "#ffffff", // fallback color
}}
/>

View File

@@ -30,11 +30,11 @@ export const LucideIconsList: React.FC<TIconsListProps> = (props) => {
<div className="flex flex-col sticky top-0 bg-custom-background-100">
<div className="flex items-center px-2 py-[12px] w-full ">
<div
className={`relative flex items-center gap-2 bg-custom-background-90 h-10 rounded-lg w-full px-[30px] border ${isInputFocused ? "border-custom-primary-100" : "border-transparent"}`}
className={`relative flex items-center gap-2 bg-custom-background-90 h-8 rounded-lg w-full px-[30px] border ${isInputFocused ? "border-custom-primary-100" : "border-transparent"}`}
onFocus={() => setIsInputFocused(true)}
onBlur={() => setIsInputFocused(false)}
>
<Search className="absolute left-2.5 bottom-3 h-3.5 w-3.5 text-custom-text-400" />
<Search className="absolute left-2.5 bottom-2 h-3.5 w-3.5 text-custom-text-400" />
<Input
placeholder="Search"
value={query}

View File

@@ -60,8 +60,7 @@ export const CreateOrUpdateIssueTypeForm: React.FC<Props> = (props) => {
icon: value,
});
}}
size={28}
containerSize={40}
size="xl"
/>
<div className="space-y-1 flew-grow w-full">
<Input

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