mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
Merge branch 'develop' of github.com:makeplane/plane into refactor/space-app
This commit is contained in:
commit
ab4fb77bc4
@ -3,10 +3,11 @@ name: Build and Lint on Pull Request
|
|||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
pull_request:
|
pull_request:
|
||||||
types: ["opened", "synchronize"]
|
types: ["opened", "synchronize", "ready_for_review"]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
get-changed-files:
|
get-changed-files:
|
||||||
|
if: github.event.pull_request.draft == false
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
outputs:
|
||||||
apiserver_changed: ${{ steps.changed-files.outputs.apiserver_any_changed }}
|
apiserver_changed: ${{ steps.changed-files.outputs.apiserver_any_changed }}
|
||||||
|
@ -677,11 +677,11 @@ class DeployBoardViewSet(BaseViewSet):
|
|||||||
entity_identifier=project_id,
|
entity_identifier=project_id,
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
)
|
)
|
||||||
project_deploy_board.comments = comments
|
|
||||||
project_deploy_board.reactions = reactions
|
|
||||||
project_deploy_board.inbox = inbox
|
project_deploy_board.inbox = inbox
|
||||||
project_deploy_board.votes = votes
|
project_deploy_board.view_props = views
|
||||||
project_deploy_board.views = views
|
project_deploy_board.is_votes_enabled = votes
|
||||||
|
project_deploy_board.is_comments_enabled = comments
|
||||||
|
project_deploy_board.is_reactions_enabled = reactions
|
||||||
|
|
||||||
project_deploy_board.save()
|
project_deploy_board.save()
|
||||||
|
|
||||||
|
@ -4,6 +4,8 @@ import uuid
|
|||||||
|
|
||||||
# Django imports
|
# Django imports
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from django.core.validators import validate_email
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
# Third party imports
|
# Third party imports
|
||||||
from zxcvbn import zxcvbn
|
from zxcvbn import zxcvbn
|
||||||
@ -46,68 +48,71 @@ class Adapter:
|
|||||||
def authenticate(self):
|
def authenticate(self):
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
def complete_login_or_signup(self):
|
def sanitize_email(self, email):
|
||||||
email = self.user_data.get("email")
|
# Check if email is present
|
||||||
user = User.objects.filter(email=email).first()
|
if not email:
|
||||||
# Check if sign up case or login
|
|
||||||
is_signup = bool(user)
|
|
||||||
if not user:
|
|
||||||
# New user
|
|
||||||
(ENABLE_SIGNUP,) = get_configuration_value(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"key": "ENABLE_SIGNUP",
|
|
||||||
"default": os.environ.get("ENABLE_SIGNUP", "1"),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
ENABLE_SIGNUP == "0"
|
|
||||||
and not WorkspaceMemberInvite.objects.filter(
|
|
||||||
email=email,
|
|
||||||
).exists()
|
|
||||||
):
|
|
||||||
raise AuthenticationException(
|
|
||||||
error_code=AUTHENTICATION_ERROR_CODES["SIGNUP_DISABLED"],
|
|
||||||
error_message="SIGNUP_DISABLED",
|
|
||||||
payload={"email": email},
|
|
||||||
)
|
|
||||||
user = User(email=email, username=uuid.uuid4().hex)
|
|
||||||
|
|
||||||
if self.user_data.get("user").get("is_password_autoset"):
|
|
||||||
user.set_password(uuid.uuid4().hex)
|
|
||||||
user.is_password_autoset = True
|
|
||||||
user.is_email_verified = True
|
|
||||||
else:
|
|
||||||
# Validate password
|
|
||||||
results = zxcvbn(self.code)
|
|
||||||
if results["score"] < 3:
|
|
||||||
raise AuthenticationException(
|
|
||||||
error_code=AUTHENTICATION_ERROR_CODES[
|
|
||||||
"INVALID_PASSWORD"
|
|
||||||
],
|
|
||||||
error_message="INVALID_PASSWORD",
|
|
||||||
payload={"email": email},
|
|
||||||
)
|
|
||||||
|
|
||||||
user.set_password(self.code)
|
|
||||||
user.is_password_autoset = False
|
|
||||||
|
|
||||||
avatar = self.user_data.get("user", {}).get("avatar", "")
|
|
||||||
first_name = self.user_data.get("user", {}).get("first_name", "")
|
|
||||||
last_name = self.user_data.get("user", {}).get("last_name", "")
|
|
||||||
user.avatar = avatar if avatar else ""
|
|
||||||
user.first_name = first_name if first_name else ""
|
|
||||||
user.last_name = last_name if last_name else ""
|
|
||||||
user.save()
|
|
||||||
Profile.objects.create(user=user)
|
|
||||||
|
|
||||||
if not user.is_active:
|
|
||||||
raise AuthenticationException(
|
raise AuthenticationException(
|
||||||
AUTHENTICATION_ERROR_CODES["USER_ACCOUNT_DEACTIVATED"],
|
error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL"],
|
||||||
error_message="USER_ACCOUNT_DEACTIVATED",
|
error_message="INVALID_EMAIL",
|
||||||
|
payload={"email": email},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Sanitize email
|
||||||
|
email = str(email).lower().strip()
|
||||||
|
|
||||||
|
# validate email
|
||||||
|
try:
|
||||||
|
validate_email(email)
|
||||||
|
except ValidationError:
|
||||||
|
raise AuthenticationException(
|
||||||
|
error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL"],
|
||||||
|
error_message="INVALID_EMAIL",
|
||||||
|
payload={"email": email},
|
||||||
|
)
|
||||||
|
# Return email
|
||||||
|
return email
|
||||||
|
|
||||||
|
def validate_password(self, email):
|
||||||
|
"""Validate password strength"""
|
||||||
|
results = zxcvbn(self.code)
|
||||||
|
if results["score"] < 3:
|
||||||
|
raise AuthenticationException(
|
||||||
|
error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"],
|
||||||
|
error_message="INVALID_PASSWORD",
|
||||||
|
payload={"email": email},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
def __check_signup(self, email):
|
||||||
|
"""Check if sign up is enabled or not and raise exception if not enabled"""
|
||||||
|
|
||||||
|
# Get configuration value
|
||||||
|
(ENABLE_SIGNUP,) = get_configuration_value(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"key": "ENABLE_SIGNUP",
|
||||||
|
"default": os.environ.get("ENABLE_SIGNUP", "1"),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if sign up is disabled and invite is present or not
|
||||||
|
if (
|
||||||
|
ENABLE_SIGNUP == "0"
|
||||||
|
and not WorkspaceMemberInvite.objects.filter(
|
||||||
|
email=email,
|
||||||
|
).exists()
|
||||||
|
):
|
||||||
|
# Raise exception
|
||||||
|
raise AuthenticationException(
|
||||||
|
error_code=AUTHENTICATION_ERROR_CODES["SIGNUP_DISABLED"],
|
||||||
|
error_message="SIGNUP_DISABLED",
|
||||||
|
payload={"email": email},
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def save_user_data(self, user):
|
||||||
# Update user details
|
# Update user details
|
||||||
user.last_login_medium = self.provider
|
user.last_login_medium = self.provider
|
||||||
user.last_active = timezone.now()
|
user.last_active = timezone.now()
|
||||||
@ -116,7 +121,63 @@ class Adapter:
|
|||||||
user.last_login_uagent = self.request.META.get("HTTP_USER_AGENT")
|
user.last_login_uagent = self.request.META.get("HTTP_USER_AGENT")
|
||||||
user.token_updated_at = timezone.now()
|
user.token_updated_at = timezone.now()
|
||||||
user.save()
|
user.save()
|
||||||
|
return user
|
||||||
|
|
||||||
|
def complete_login_or_signup(self):
|
||||||
|
# Get email
|
||||||
|
email = self.user_data.get("email")
|
||||||
|
|
||||||
|
# Sanitize email
|
||||||
|
email = self.sanitize_email(email)
|
||||||
|
|
||||||
|
# Check if the user is present
|
||||||
|
user = User.objects.filter(email=email).first()
|
||||||
|
# Check if sign up case or login
|
||||||
|
is_signup = bool(user)
|
||||||
|
# If user is not present, create a new user
|
||||||
|
if not user:
|
||||||
|
# New user
|
||||||
|
self.__check_signup(email)
|
||||||
|
|
||||||
|
# Initialize user
|
||||||
|
user = User(email=email, username=uuid.uuid4().hex)
|
||||||
|
|
||||||
|
# Check if password is autoset
|
||||||
|
if self.user_data.get("user").get("is_password_autoset"):
|
||||||
|
user.set_password(uuid.uuid4().hex)
|
||||||
|
user.is_password_autoset = True
|
||||||
|
user.is_email_verified = True
|
||||||
|
|
||||||
|
# Validate password
|
||||||
|
else:
|
||||||
|
# Validate password
|
||||||
|
self.validate_password(email)
|
||||||
|
# Set password
|
||||||
|
user.set_password(self.code)
|
||||||
|
user.is_password_autoset = False
|
||||||
|
|
||||||
|
# Set user details
|
||||||
|
avatar = self.user_data.get("user", {}).get("avatar", "")
|
||||||
|
first_name = self.user_data.get("user", {}).get("first_name", "")
|
||||||
|
last_name = self.user_data.get("user", {}).get("last_name", "")
|
||||||
|
user.avatar = avatar if avatar else ""
|
||||||
|
user.first_name = first_name if first_name else ""
|
||||||
|
user.last_name = last_name if last_name else ""
|
||||||
|
user.save()
|
||||||
|
|
||||||
|
# Create profile
|
||||||
|
Profile.objects.create(user=user)
|
||||||
|
|
||||||
|
if not user.is_active:
|
||||||
|
raise AuthenticationException(
|
||||||
|
AUTHENTICATION_ERROR_CODES["USER_ACCOUNT_DEACTIVATED"],
|
||||||
|
error_message="USER_ACCOUNT_DEACTIVATED",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save user data
|
||||||
|
user = self.save_user_data(user=user)
|
||||||
|
|
||||||
|
# Call callback if present
|
||||||
if self.callback:
|
if self.callback:
|
||||||
self.callback(
|
self.callback(
|
||||||
user,
|
user,
|
||||||
@ -124,7 +185,9 @@ class Adapter:
|
|||||||
self.request,
|
self.request,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Create or update account if token data is present
|
||||||
if self.token_data:
|
if self.token_data:
|
||||||
self.create_update_account(user=user)
|
self.create_update_account(user=user)
|
||||||
|
|
||||||
|
# Return user
|
||||||
return user
|
return user
|
||||||
|
@ -58,6 +58,8 @@ AUTHENTICATION_ERROR_CODES = {
|
|||||||
"ADMIN_USER_DEACTIVATED": 5190,
|
"ADMIN_USER_DEACTIVATED": 5190,
|
||||||
# Rate limit
|
# Rate limit
|
||||||
"RATE_LIMIT_EXCEEDED": 5900,
|
"RATE_LIMIT_EXCEEDED": 5900,
|
||||||
|
# Unknown
|
||||||
|
"AUTHENTICATION_FAILED": 5999,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -81,11 +81,11 @@ class OauthAdapter(Adapter):
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
except requests.RequestException:
|
except requests.RequestException:
|
||||||
code = (
|
if self.provider == "google":
|
||||||
"GOOGLE_OAUTH_PROVIDER_ERROR"
|
code = "GOOGLE_OAUTH_PROVIDER_ERROR"
|
||||||
if self.provider == "google"
|
if self.provider == "github":
|
||||||
else "GITHUB_OAUTH_PROVIDER_ERROR"
|
code = "GITHUB_OAUTH_PROVIDER_ERROR"
|
||||||
)
|
|
||||||
raise AuthenticationException(
|
raise AuthenticationException(
|
||||||
error_code=AUTHENTICATION_ERROR_CODES[code],
|
error_code=AUTHENTICATION_ERROR_CODES[code],
|
||||||
error_message=str(code),
|
error_message=str(code),
|
||||||
|
@ -4,6 +4,7 @@ from plane.db.models import (
|
|||||||
WorkspaceMember,
|
WorkspaceMember,
|
||||||
WorkspaceMemberInvite,
|
WorkspaceMemberInvite,
|
||||||
)
|
)
|
||||||
|
from plane.utils.cache import invalidate_cache_directly
|
||||||
|
|
||||||
|
|
||||||
def process_workspace_project_invitations(user):
|
def process_workspace_project_invitations(user):
|
||||||
@ -26,6 +27,16 @@ def process_workspace_project_invitations(user):
|
|||||||
ignore_conflicts=True,
|
ignore_conflicts=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
[
|
||||||
|
invalidate_cache_directly(
|
||||||
|
path=f"/api/workspaces/{str(workspace_member_invite.workspace.slug)}/members/",
|
||||||
|
url_params=False,
|
||||||
|
user=False,
|
||||||
|
multiple=True,
|
||||||
|
)
|
||||||
|
for workspace_member_invite in workspace_member_invites
|
||||||
|
]
|
||||||
|
|
||||||
# Check if user has any project invites
|
# Check if user has any project invites
|
||||||
project_member_invites = ProjectMemberInvite.objects.filter(
|
project_member_invites = ProjectMemberInvite.objects.filter(
|
||||||
email=user.email, accepted=True
|
email=user.email, accepted=True
|
||||||
|
@ -28,6 +28,7 @@ from plane.db.models import (
|
|||||||
Project,
|
Project,
|
||||||
State,
|
State,
|
||||||
User,
|
User,
|
||||||
|
EstimatePoint,
|
||||||
)
|
)
|
||||||
from plane.settings.redis import redis_instance
|
from plane.settings.redis import redis_instance
|
||||||
from plane.utils.exception_logger import log_exception
|
from plane.utils.exception_logger import log_exception
|
||||||
@ -448,21 +449,37 @@ def track_estimate_points(
|
|||||||
if current_instance.get("estimate_point") != requested_data.get(
|
if current_instance.get("estimate_point") != requested_data.get(
|
||||||
"estimate_point"
|
"estimate_point"
|
||||||
):
|
):
|
||||||
|
old_estimate = (
|
||||||
|
EstimatePoint.objects.filter(
|
||||||
|
pk=current_instance.get("estimate_point")
|
||||||
|
).first()
|
||||||
|
if current_instance.get("estimate_point") is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
new_estimate = (
|
||||||
|
EstimatePoint.objects.filter(
|
||||||
|
pk=requested_data.get("estimate_point")
|
||||||
|
).first()
|
||||||
|
if requested_data.get("estimate_point") is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
issue_activities.append(
|
issue_activities.append(
|
||||||
IssueActivity(
|
IssueActivity(
|
||||||
issue_id=issue_id,
|
issue_id=issue_id,
|
||||||
actor_id=actor_id,
|
actor_id=actor_id,
|
||||||
verb="updated",
|
verb="updated",
|
||||||
old_value=(
|
old_identifier=(
|
||||||
current_instance.get("estimate_point")
|
current_instance.get("estimate_point")
|
||||||
if current_instance.get("estimate_point") is not None
|
if current_instance.get("estimate_point") is not None
|
||||||
else ""
|
else None
|
||||||
),
|
),
|
||||||
new_value=(
|
new_identifier=(
|
||||||
requested_data.get("estimate_point")
|
requested_data.get("estimate_point")
|
||||||
if requested_data.get("estimate_point") is not None
|
if requested_data.get("estimate_point") is not None
|
||||||
else ""
|
else None
|
||||||
),
|
),
|
||||||
|
old_value=old_estimate.value if old_estimate else None,
|
||||||
|
new_value=new_estimate.value if new_estimate else None,
|
||||||
field="estimate_point",
|
field="estimate_point",
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
workspace_id=workspace_id,
|
workspace_id=workspace_id,
|
||||||
|
260
apiserver/plane/db/migrations/0067_issue_estimate.py
Normal file
260
apiserver/plane/db/migrations/0067_issue_estimate.py
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
# # Generated by Django 4.2.7 on 2024-05-24 09:47
|
||||||
|
# Python imports
|
||||||
|
import uuid
|
||||||
|
from uuid import uuid4
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import plane.db.models.deploy_board
|
||||||
|
|
||||||
|
|
||||||
|
def issue_estimate_point(apps, schema_editor):
|
||||||
|
Issue = apps.get_model("db", "Issue")
|
||||||
|
Project = apps.get_model("db", "Project")
|
||||||
|
EstimatePoint = apps.get_model("db", "EstimatePoint")
|
||||||
|
IssueActivity = apps.get_model("db", "IssueActivity")
|
||||||
|
updated_estimate_point = []
|
||||||
|
updated_issue_activity = []
|
||||||
|
|
||||||
|
# loop through all the projects
|
||||||
|
for project in Project.objects.filter(estimate__isnull=False):
|
||||||
|
estimate_points = EstimatePoint.objects.filter(
|
||||||
|
estimate=project.estimate, project=project
|
||||||
|
)
|
||||||
|
|
||||||
|
for issue_activity in IssueActivity.objects.filter(
|
||||||
|
field="estimate_point", project=project
|
||||||
|
):
|
||||||
|
if issue_activity.new_value:
|
||||||
|
new_identifier = estimate_points.filter(
|
||||||
|
key=issue_activity.new_value
|
||||||
|
).first().id
|
||||||
|
issue_activity.new_identifier = new_identifier
|
||||||
|
new_value = estimate_points.filter(
|
||||||
|
key=issue_activity.new_value
|
||||||
|
).first().value
|
||||||
|
issue_activity.new_value = new_value
|
||||||
|
|
||||||
|
if issue_activity.old_value:
|
||||||
|
old_identifier = estimate_points.filter(
|
||||||
|
key=issue_activity.old_value
|
||||||
|
).first().id
|
||||||
|
issue_activity.old_identifier = old_identifier
|
||||||
|
old_value = estimate_points.filter(
|
||||||
|
key=issue_activity.old_value
|
||||||
|
).first().value
|
||||||
|
issue_activity.old_value = old_value
|
||||||
|
updated_issue_activity.append(issue_activity)
|
||||||
|
|
||||||
|
for issue in Issue.objects.filter(
|
||||||
|
point__isnull=False, project=project
|
||||||
|
):
|
||||||
|
# get the estimate id for the corresponding estimate point in the issue
|
||||||
|
estimate = estimate_points.filter(key=issue.point).first()
|
||||||
|
issue.estimate_point = estimate
|
||||||
|
updated_estimate_point.append(issue)
|
||||||
|
|
||||||
|
Issue.objects.bulk_update(
|
||||||
|
updated_estimate_point, ["estimate_point"], batch_size=1000
|
||||||
|
)
|
||||||
|
IssueActivity.objects.bulk_update(
|
||||||
|
updated_issue_activity,
|
||||||
|
["new_value", "old_value", "new_identifier", "old_identifier"],
|
||||||
|
batch_size=1000,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def last_used_estimate(apps, schema_editor):
|
||||||
|
Project = apps.get_model("db", "Project")
|
||||||
|
Estimate = apps.get_model("db", "Estimate")
|
||||||
|
|
||||||
|
# Get all estimate ids used in projects
|
||||||
|
estimate_ids = Project.objects.filter(estimate__isnull=False).values_list(
|
||||||
|
"estimate", flat=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update all matching estimates
|
||||||
|
Estimate.objects.filter(id__in=estimate_ids).update(last_used=True)
|
||||||
|
|
||||||
|
|
||||||
|
def populate_deploy_board(apps, schema_editor):
|
||||||
|
DeployBoard = apps.get_model("db", "DeployBoard")
|
||||||
|
ProjectDeployBoard = apps.get_model("db", "ProjectDeployBoard")
|
||||||
|
|
||||||
|
DeployBoard.objects.bulk_create(
|
||||||
|
[
|
||||||
|
DeployBoard(
|
||||||
|
entity_identifier=deploy_board.project_id,
|
||||||
|
project_id=deploy_board.project_id,
|
||||||
|
entity_name="project",
|
||||||
|
anchor=uuid4().hex,
|
||||||
|
is_comments_enabled=deploy_board.comments,
|
||||||
|
is_reactions_enabled=deploy_board.reactions,
|
||||||
|
inbox=deploy_board.inbox,
|
||||||
|
is_votes_enabled=deploy_board.votes,
|
||||||
|
view_props=deploy_board.views,
|
||||||
|
workspace_id=deploy_board.workspace_id,
|
||||||
|
created_at=deploy_board.created_at,
|
||||||
|
updated_at=deploy_board.updated_at,
|
||||||
|
created_by_id=deploy_board.created_by_id,
|
||||||
|
updated_by_id=deploy_board.updated_by_id,
|
||||||
|
)
|
||||||
|
for deploy_board in ProjectDeployBoard.objects.all()
|
||||||
|
],
|
||||||
|
batch_size=100,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("db", "0066_account_id_token_cycle_logo_props_module_logo_props"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="DeployBoard",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"created_at",
|
||||||
|
models.DateTimeField(
|
||||||
|
auto_now_add=True, verbose_name="Created At"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"updated_at",
|
||||||
|
models.DateTimeField(
|
||||||
|
auto_now=True, verbose_name="Last Modified At"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.UUIDField(
|
||||||
|
db_index=True,
|
||||||
|
default=uuid.uuid4,
|
||||||
|
editable=False,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
unique=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("entity_identifier", models.UUIDField(null=True)),
|
||||||
|
(
|
||||||
|
"entity_name",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("project", "Project"),
|
||||||
|
("issue", "Issue"),
|
||||||
|
("module", "Module"),
|
||||||
|
("cycle", "Task"),
|
||||||
|
("page", "Page"),
|
||||||
|
("view", "View"),
|
||||||
|
],
|
||||||
|
max_length=30,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"anchor",
|
||||||
|
models.CharField(
|
||||||
|
db_index=True,
|
||||||
|
default=plane.db.models.deploy_board.get_anchor,
|
||||||
|
max_length=255,
|
||||||
|
unique=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("is_comments_enabled", models.BooleanField(default=False)),
|
||||||
|
("is_reactions_enabled", models.BooleanField(default=False)),
|
||||||
|
("is_votes_enabled", models.BooleanField(default=False)),
|
||||||
|
("view_props", models.JSONField(default=dict)),
|
||||||
|
(
|
||||||
|
"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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"inbox",
|
||||||
|
models.ForeignKey(
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="board_inbox",
|
||||||
|
to="db.inbox",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"project",
|
||||||
|
models.ForeignKey(
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="project_%(class)s",
|
||||||
|
to="db.project",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"updated_by",
|
||||||
|
models.ForeignKey(
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="%(class)s_updated_by",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
verbose_name="Last Modified By",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"workspace",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="workspace_%(class)s",
|
||||||
|
to="db.workspace",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Deploy Board",
|
||||||
|
"verbose_name_plural": "Deploy Boards",
|
||||||
|
"db_table": "deploy_boards",
|
||||||
|
"ordering": ("-created_at",),
|
||||||
|
"unique_together": {("entity_name", "entity_identifier")},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="estimate",
|
||||||
|
name="last_used",
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
# Rename the existing field
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name="issue",
|
||||||
|
old_name="estimate_point",
|
||||||
|
new_name="point",
|
||||||
|
),
|
||||||
|
# Add a new field with the original name as a foreign key
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="issue",
|
||||||
|
name="estimate_point",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="issue_estimates",
|
||||||
|
to="db.EstimatePoint",
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="estimate",
|
||||||
|
name="type",
|
||||||
|
field=models.CharField(default="categories", max_length=255),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="estimatepoint",
|
||||||
|
name="value",
|
||||||
|
field=models.CharField(max_length=255),
|
||||||
|
),
|
||||||
|
migrations.RunPython(issue_estimate_point),
|
||||||
|
migrations.RunPython(last_used_estimate),
|
||||||
|
migrations.RunPython(populate_deploy_board),
|
||||||
|
]
|
@ -4,6 +4,7 @@ from .asset import FileAsset
|
|||||||
from .base import BaseModel
|
from .base import BaseModel
|
||||||
from .cycle import Cycle, CycleFavorite, CycleIssue, CycleUserProperties
|
from .cycle import Cycle, CycleFavorite, CycleIssue, CycleUserProperties
|
||||||
from .dashboard import Dashboard, DashboardWidget, Widget
|
from .dashboard import Dashboard, DashboardWidget, Widget
|
||||||
|
from .deploy_board import DeployBoard
|
||||||
from .estimate import Estimate, EstimatePoint
|
from .estimate import Estimate, EstimatePoint
|
||||||
from .exporter import ExporterHistory
|
from .exporter import ExporterHistory
|
||||||
from .importer import Importer
|
from .importer import Importer
|
||||||
|
@ -30,15 +30,15 @@ class DeployBoard(WorkspaceBaseModel):
|
|||||||
anchor = models.CharField(
|
anchor = models.CharField(
|
||||||
max_length=255, default=get_anchor, unique=True, db_index=True
|
max_length=255, default=get_anchor, unique=True, db_index=True
|
||||||
)
|
)
|
||||||
comments = models.BooleanField(default=False)
|
is_comments_enabled = models.BooleanField(default=False)
|
||||||
reactions = models.BooleanField(default=False)
|
is_reactions_enabled = models.BooleanField(default=False)
|
||||||
inbox = models.ForeignKey(
|
inbox = models.ForeignKey(
|
||||||
"db.Inbox",
|
"db.Inbox",
|
||||||
related_name="board_inbox",
|
related_name="board_inbox",
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
null=True,
|
null=True,
|
||||||
)
|
)
|
||||||
votes = models.BooleanField(default=False)
|
is_votes_enabled = models.BooleanField(default=False)
|
||||||
view_props = models.JSONField(default=dict)
|
view_props = models.JSONField(default=dict)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
@ -11,7 +11,8 @@ class Estimate(ProjectBaseModel):
|
|||||||
description = models.TextField(
|
description = models.TextField(
|
||||||
verbose_name="Estimate Description", blank=True
|
verbose_name="Estimate Description", blank=True
|
||||||
)
|
)
|
||||||
type = models.CharField(max_length=255, default="Categories")
|
type = models.CharField(max_length=255, default="categories")
|
||||||
|
last_used = models.BooleanField(default=False)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
"""Return name of the estimate"""
|
"""Return name of the estimate"""
|
||||||
@ -35,7 +36,7 @@ class EstimatePoint(ProjectBaseModel):
|
|||||||
default=0, validators=[MinValueValidator(0), MaxValueValidator(12)]
|
default=0, validators=[MinValueValidator(0), MaxValueValidator(12)]
|
||||||
)
|
)
|
||||||
description = models.TextField(blank=True)
|
description = models.TextField(blank=True)
|
||||||
value = models.CharField(max_length=20)
|
value = models.CharField(max_length=255)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
"""Return name of the estimate"""
|
"""Return name of the estimate"""
|
||||||
|
@ -119,11 +119,18 @@ class Issue(ProjectBaseModel):
|
|||||||
blank=True,
|
blank=True,
|
||||||
related_name="state_issue",
|
related_name="state_issue",
|
||||||
)
|
)
|
||||||
estimate_point = models.IntegerField(
|
point = models.IntegerField(
|
||||||
validators=[MinValueValidator(0), MaxValueValidator(12)],
|
validators=[MinValueValidator(0), MaxValueValidator(12)],
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
)
|
)
|
||||||
|
estimate_point = models.ForeignKey(
|
||||||
|
"db.EstimatePoint",
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name="issue_estimates",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
name = models.CharField(max_length=255, verbose_name="Issue Name")
|
name = models.CharField(max_length=255, verbose_name="Issue Name")
|
||||||
description = models.JSONField(blank=True, default=dict)
|
description = models.JSONField(blank=True, default=dict)
|
||||||
description_html = models.TextField(blank=True, default="<p></p>")
|
description_html = models.TextField(blank=True, default="<p></p>")
|
||||||
|
@ -260,6 +260,8 @@ def get_default_views():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# DEPRECATED TODO:
|
||||||
|
# used to get the old anchors for the project deploy boards
|
||||||
class ProjectDeployBoard(ProjectBaseModel):
|
class ProjectDeployBoard(ProjectBaseModel):
|
||||||
anchor = models.CharField(
|
anchor = models.CharField(
|
||||||
max_length=255, default=get_anchor, unique=True, db_index=True
|
max_length=255, default=get_anchor, unique=True, db_index=True
|
||||||
|
@ -66,7 +66,7 @@ def invalidate_cache_directly(
|
|||||||
custom_path = path if path is not None else request.get_full_path()
|
custom_path = path if path is not None else request.get_full_path()
|
||||||
auth_header = (
|
auth_header = (
|
||||||
None
|
None
|
||||||
if request.user.is_anonymous
|
if request and request.user.is_anonymous
|
||||||
else str(request.user.id) if user else None
|
else str(request.user.id) if user else None
|
||||||
)
|
)
|
||||||
key = generate_cache_key(custom_path, auth_header)
|
key = generate_cache_key(custom_path, auth_header)
|
||||||
|
@ -112,7 +112,7 @@ export const useEditor = ({
|
|||||||
if (value === null || value === undefined) return;
|
if (value === null || value === undefined) return;
|
||||||
if (editor && !editor.isDestroyed && !editor.storage.image.uploadInProgress) {
|
if (editor && !editor.isDestroyed && !editor.storage.image.uploadInProgress) {
|
||||||
try {
|
try {
|
||||||
editor.commands.setContent(value);
|
editor.commands.setContent(value, false, { preserveWhitespace: "full" });
|
||||||
const currentSavedSelection = savedSelectionRef.current;
|
const currentSavedSelection = savedSelectionRef.current;
|
||||||
if (currentSavedSelection) {
|
if (currentSavedSelection) {
|
||||||
const docLength = editor.state.doc.content.size;
|
const docLength = editor.state.doc.content.size;
|
||||||
|
2
packages/types/src/issues/issue.d.ts
vendored
2
packages/types/src/issues/issue.d.ts
vendored
@ -15,7 +15,7 @@ export type TIssue = {
|
|||||||
priority: TIssuePriorities;
|
priority: TIssuePriorities;
|
||||||
label_ids: string[];
|
label_ids: string[];
|
||||||
assignee_ids: string[];
|
assignee_ids: string[];
|
||||||
estimate_point: number | null;
|
estimate_point: string | null;
|
||||||
|
|
||||||
sub_issues_count: number;
|
sub_issues_count: number;
|
||||||
attachment_count: number;
|
attachment_count: number;
|
||||||
|
@ -7,10 +7,11 @@ export type TControlLink = React.AnchorHTMLAttributes<HTMLAnchorElement> & {
|
|||||||
target?: string;
|
target?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
draggable?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ControlLink = React.forwardRef<HTMLAnchorElement, TControlLink>((props, ref) => {
|
export const ControlLink = React.forwardRef<HTMLAnchorElement, TControlLink>((props, ref) => {
|
||||||
const { href, onClick, children, target = "_self", disabled = false, className, ...rest } = props;
|
const { href, onClick, children, target = "_self", disabled = false, className, draggable = false, ...rest } = props;
|
||||||
const LEFT_CLICK_EVENT_CODE = 0;
|
const LEFT_CLICK_EVENT_CODE = 0;
|
||||||
|
|
||||||
const handleOnClick = (event: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
|
const handleOnClick = (event: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
|
||||||
@ -33,7 +34,15 @@ export const ControlLink = React.forwardRef<HTMLAnchorElement, TControlLink>((pr
|
|||||||
if (disabled) return <>{children}</>;
|
if (disabled) return <>{children}</>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a href={href} target={target} onClick={handleOnClick} {...rest} ref={ref} className={className}>
|
<a
|
||||||
|
href={href}
|
||||||
|
target={target}
|
||||||
|
onClick={handleOnClick}
|
||||||
|
{...rest}
|
||||||
|
ref={ref}
|
||||||
|
className={className}
|
||||||
|
draggable={draggable}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
|
@ -24,7 +24,7 @@ import { Tooltip, BlockedIcon, BlockerIcon, RelatedIcon, LayersIcon, DiceIcon }
|
|||||||
// helpers
|
// helpers
|
||||||
import { renderFormattedDate } from "@/helpers/date-time.helper";
|
import { renderFormattedDate } from "@/helpers/date-time.helper";
|
||||||
import { capitalizeFirstLetter } from "@/helpers/string.helper";
|
import { capitalizeFirstLetter } from "@/helpers/string.helper";
|
||||||
import { useEstimate, useLabel } from "@/hooks/store";
|
import { useLabel } from "@/hooks/store";
|
||||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||||
// types
|
// types
|
||||||
|
|
||||||
@ -97,22 +97,6 @@ const LabelPill = observer(({ labelId, workspaceSlug }: { labelId: string; works
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const EstimatePoint = observer((props: { point: string }) => {
|
|
||||||
const { point } = props;
|
|
||||||
const { areEstimatesEnabledForCurrentProject, getEstimatePointValue } = useEstimate();
|
|
||||||
const currentPoint = Number(point) + 1;
|
|
||||||
|
|
||||||
const estimateValue = getEstimatePointValue(Number(point), null);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span className="font-medium text-custom-text-100 whitespace-nowrap">
|
|
||||||
{areEstimatesEnabledForCurrentProject
|
|
||||||
? estimateValue
|
|
||||||
: `${currentPoint} ${currentPoint > 1 ? "points" : "point"}`}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const inboxActivityMessage = {
|
const inboxActivityMessage = {
|
||||||
declined: {
|
declined: {
|
||||||
showIssue: "declined issue",
|
showIssue: "declined issue",
|
||||||
@ -267,7 +251,7 @@ const activityDetails: {
|
|||||||
else
|
else
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
set the estimate point to <EstimatePoint point={activity.new_value} />
|
set the estimate point to {activity.new_value}
|
||||||
{showIssue && (
|
{showIssue && (
|
||||||
<>
|
<>
|
||||||
{" "}
|
{" "}
|
||||||
|
@ -19,15 +19,15 @@ type Props = TDropdownProps & {
|
|||||||
button?: ReactNode;
|
button?: ReactNode;
|
||||||
dropdownArrow?: boolean;
|
dropdownArrow?: boolean;
|
||||||
dropdownArrowClassName?: string;
|
dropdownArrowClassName?: string;
|
||||||
onChange: (val: number | null) => void;
|
onChange: (val: string | null) => void;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
value: number | null;
|
value: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type DropdownOptions =
|
type DropdownOptions =
|
||||||
| {
|
| {
|
||||||
value: number | null;
|
value: string | null;
|
||||||
query: string;
|
query: string;
|
||||||
content: JSX.Element;
|
content: JSX.Element;
|
||||||
}[]
|
}[]
|
||||||
@ -80,7 +80,7 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
|
|||||||
const activeEstimate = getProjectActiveEstimateDetails(projectId);
|
const activeEstimate = getProjectActiveEstimateDetails(projectId);
|
||||||
|
|
||||||
const options: DropdownOptions = sortBy(activeEstimate?.points ?? [], "key")?.map((point) => ({
|
const options: DropdownOptions = sortBy(activeEstimate?.points ?? [], "key")?.map((point) => ({
|
||||||
value: point.key,
|
value: point.id,
|
||||||
query: `${point?.value}`,
|
query: `${point?.value}`,
|
||||||
content: (
|
content: (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@ -120,7 +120,7 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
|
|||||||
setQuery,
|
setQuery,
|
||||||
});
|
});
|
||||||
|
|
||||||
const dropdownOnChange = (val: number | null) => {
|
const dropdownOnChange = (val: string | null) => {
|
||||||
onChange(val);
|
onChange(val);
|
||||||
handleClose();
|
handleClose();
|
||||||
};
|
};
|
||||||
|
@ -162,7 +162,7 @@ export const GanttChartMainContent: React.FC<Props> = observer((props) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<IssueBulkOperationsRoot />
|
<IssueBulkOperationsRoot selectionHelpers={helpers} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</MultipleSelectGroup>
|
</MultipleSelectGroup>
|
||||||
|
@ -3,9 +3,11 @@ import { observer } from "mobx-react";
|
|||||||
import { BulkOperationsUpgradeBanner } from "@/components/issues";
|
import { BulkOperationsUpgradeBanner } from "@/components/issues";
|
||||||
// hooks
|
// hooks
|
||||||
import { useMultipleSelectStore } from "@/hooks/store";
|
import { useMultipleSelectStore } from "@/hooks/store";
|
||||||
|
import { TSelectionHelper } from "@/hooks/use-multiple-select";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
selectionHelpers: TSelectionHelper;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const IssueBulkOperationsRoot: React.FC<Props> = observer((props) => {
|
export const IssueBulkOperationsRoot: React.FC<Props> = observer((props) => {
|
||||||
|
@ -2,7 +2,7 @@ import { FC } from "react";
|
|||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { Triangle } from "lucide-react";
|
import { Triangle } from "lucide-react";
|
||||||
// hooks
|
// hooks
|
||||||
import { useEstimate, useIssueDetail } from "@/hooks/store";
|
import { useIssueDetail } from "@/hooks/store";
|
||||||
// components
|
// components
|
||||||
import { IssueActivityBlockComponent, IssueLink } from "./";
|
import { IssueActivityBlockComponent, IssueLink } from "./";
|
||||||
|
|
||||||
@ -14,15 +14,11 @@ export const IssueEstimateActivity: FC<TIssueEstimateActivity> = observer((props
|
|||||||
const {
|
const {
|
||||||
activity: { getActivityById },
|
activity: { getActivityById },
|
||||||
} = useIssueDetail();
|
} = useIssueDetail();
|
||||||
const { areEstimatesEnabledForCurrentProject, getEstimatePointValue } = useEstimate();
|
|
||||||
|
|
||||||
const activity = getActivityById(activityId);
|
const activity = getActivityById(activityId);
|
||||||
|
|
||||||
if (!activity) return <></>;
|
if (!activity) return <></>;
|
||||||
|
|
||||||
const estimateValue = getEstimatePointValue(Number(activity.new_value), null);
|
|
||||||
const currentPoint = Number(activity.new_value) + 1;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<IssueActivityBlockComponent
|
<IssueActivityBlockComponent
|
||||||
icon={<Triangle size={14} color="#6b7280" aria-hidden="true" />}
|
icon={<Triangle size={14} color="#6b7280" aria-hidden="true" />}
|
||||||
@ -31,15 +27,7 @@ export const IssueEstimateActivity: FC<TIssueEstimateActivity> = observer((props
|
|||||||
>
|
>
|
||||||
<>
|
<>
|
||||||
{activity.new_value ? `set the estimate point to ` : `removed the estimate point `}
|
{activity.new_value ? `set the estimate point to ` : `removed the estimate point `}
|
||||||
{activity.new_value && (
|
{activity.new_value ? activity.new_value : activity?.old_value || ""}
|
||||||
<>
|
|
||||||
<span className="font-medium text-custom-text-100">
|
|
||||||
{areEstimatesEnabledForCurrentProject
|
|
||||||
? estimateValue
|
|
||||||
: `${currentPoint} ${currentPoint > 1 ? "points" : "point"}`}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{showIssue && (activity.new_value ? ` to ` : ` from `)}
|
{showIssue && (activity.new_value ? ` to ` : ` from `)}
|
||||||
{showIssue && <IssueLink activityId={activityId} />}.
|
{showIssue && <IssueLink activityId={activityId} />}.
|
||||||
</>
|
</>
|
||||||
|
@ -318,7 +318,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
|
|||||||
<span>Estimate</span>
|
<span>Estimate</span>
|
||||||
</div>
|
</div>
|
||||||
<EstimateDropdown
|
<EstimateDropdown
|
||||||
value={issue?.estimate_point !== null ? issue.estimate_point : null}
|
value={issue?.estimate_point != null ? issue.estimate_point : null}
|
||||||
onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { estimate_point: val })}
|
onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { estimate_point: val })}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
disabled={!isEditable}
|
disabled={!isEditable}
|
||||||
|
@ -6,7 +6,7 @@ import { ChevronRight } from "lucide-react";
|
|||||||
// types
|
// types
|
||||||
import { TIssue, IIssueDisplayProperties, TIssueMap } from "@plane/types";
|
import { TIssue, IIssueDisplayProperties, TIssueMap } from "@plane/types";
|
||||||
// ui
|
// ui
|
||||||
import { Spinner, Tooltip, ControlLink, DragHandle } from "@plane/ui";
|
import { Spinner, Tooltip, ControlLink, setToast, TOAST_TYPE } from "@plane/ui";
|
||||||
// components
|
// components
|
||||||
import { MultipleSelectEntityAction } from "@/components/core";
|
import { MultipleSelectEntityAction } from "@/components/core";
|
||||||
import { IssueProperties } from "@/components/issues/issue-layouts/properties";
|
import { IssueProperties } from "@/components/issues/issue-layouts/properties";
|
||||||
@ -57,7 +57,6 @@ export const IssueBlock = observer((props: IssueBlockProps) => {
|
|||||||
} = props;
|
} = props;
|
||||||
// ref
|
// ref
|
||||||
const issueRef = useRef<HTMLDivElement | null>(null);
|
const issueRef = useRef<HTMLDivElement | null>(null);
|
||||||
const dragHandleRef = useRef(null);
|
|
||||||
// hooks
|
// hooks
|
||||||
const { workspaceSlug, projectId } = useAppRouter();
|
const { workspaceSlug, projectId } = useAppRouter();
|
||||||
const { getProjectIdentifierById } = useProject();
|
const { getProjectIdentifierById } = useProject();
|
||||||
@ -78,14 +77,12 @@ export const IssueBlock = observer((props: IssueBlockProps) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const element = issueRef.current;
|
const element = issueRef.current;
|
||||||
const dragHandleElement = dragHandleRef.current;
|
|
||||||
|
|
||||||
if (!element || !dragHandleElement) return;
|
if (!element) return;
|
||||||
|
|
||||||
return combine(
|
return combine(
|
||||||
draggable({
|
draggable({
|
||||||
element,
|
element,
|
||||||
dragHandle: dragHandleElement,
|
|
||||||
canDrag: () => canDrag,
|
canDrag: () => canDrag,
|
||||||
getInitialData: () => ({ id: issueId, type: "ISSUE", groupId }),
|
getInitialData: () => ({ id: issueId, type: "ISSUE", groupId }),
|
||||||
onDragStart: () => {
|
onDragStart: () => {
|
||||||
@ -96,7 +93,7 @@ export const IssueBlock = observer((props: IssueBlockProps) => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}, [issueRef?.current, canDrag, issueId, groupId, dragHandleRef?.current, setIsCurrentBlockDragging]);
|
}, [canDrag, issueId, groupId, setIsCurrentBlockDragging]);
|
||||||
|
|
||||||
if (!issue) return null;
|
if (!issue) return null;
|
||||||
|
|
||||||
@ -135,20 +132,19 @@ export const IssueBlock = observer((props: IssueBlockProps) => {
|
|||||||
"bg-custom-background-80": isCurrentBlockDragging,
|
"bg-custom-background-80": isCurrentBlockDragging,
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
|
onDragStart={() => {
|
||||||
|
if (!canDrag) {
|
||||||
|
setToast({
|
||||||
|
type: TOAST_TYPE.WARNING,
|
||||||
|
title: "Cannot move issue",
|
||||||
|
message: "Drag and drop is disabled for the current grouping",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex w-full truncate">
|
<div className="flex w-full truncate">
|
||||||
<div className="flex flex-grow items-center gap-3 truncate">
|
<div className="flex flex-grow items-center gap-1.5 truncate">
|
||||||
<div className="flex items-center gap-0.5">
|
<div className="flex items-center gap-2" style={isSubIssue ? { marginLeft } : {}}>
|
||||||
{/* drag handle */}
|
|
||||||
<div className="size-4 flex items-center group/drag-handle">
|
|
||||||
<DragHandle
|
|
||||||
ref={dragHandleRef}
|
|
||||||
disabled={!canDrag}
|
|
||||||
className={cn("opacity-0 group-hover/drag-handle:opacity-100", {
|
|
||||||
"opacity-100": isCurrentBlockDragging,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/* select checkbox */}
|
{/* select checkbox */}
|
||||||
{projectId && canEditIssueProperties && (
|
{projectId && canEditIssueProperties && (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
@ -177,8 +173,14 @@ export const IssueBlock = observer((props: IssueBlockProps) => {
|
|||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
{displayProperties && displayProperties?.key && (
|
||||||
|
<div className="flex-shrink-0 text-xs font-medium text-custom-text-300">
|
||||||
|
{projectIdentifier}-{issue.sequence_id}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* sub-issues chevron */}
|
{/* sub-issues chevron */}
|
||||||
<div className="size-4 grid place-items-center flex-shrink-0" style={isSubIssue ? { marginLeft } : {}}>
|
<div className="size-4 grid place-items-center flex-shrink-0">
|
||||||
{subIssuesCount > 0 && (
|
{subIssuesCount > 0 && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -194,11 +196,6 @@ export const IssueBlock = observer((props: IssueBlockProps) => {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{displayProperties && displayProperties?.key && (
|
|
||||||
<div className="flex-shrink-0 text-xs font-medium text-custom-text-300">
|
|
||||||
{projectIdentifier}-{issue.sequence_id}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{issue?.tempId !== undefined && (
|
{issue?.tempId !== undefined && (
|
||||||
<div className="absolute left-0 top-0 z-[99999] h-full w-full animate-pulse bg-custom-background-100/20" />
|
<div className="absolute left-0 top-0 z-[99999] h-full w-full animate-pulse bg-custom-background-100/20" />
|
||||||
@ -206,7 +203,12 @@ export const IssueBlock = observer((props: IssueBlockProps) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{issue?.is_draft ? (
|
{issue?.is_draft ? (
|
||||||
<Tooltip tooltipContent={issue.name} isMobile={isMobile} position="top-left">
|
<Tooltip
|
||||||
|
tooltipContent={issue.name}
|
||||||
|
isMobile={isMobile}
|
||||||
|
position="top-left"
|
||||||
|
disabled={isCurrentBlockDragging}
|
||||||
|
>
|
||||||
<p className="truncate">{issue.name}</p>
|
<p className="truncate">{issue.name}</p>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : (
|
) : (
|
||||||
|
@ -170,7 +170,7 @@ const GroupByList: React.FC<IGroupByList> = observer((props) => {
|
|||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<IssueBulkOperationsRoot />
|
<IssueBulkOperationsRoot selectionHelpers={helpers} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</MultipleSelectGroup>
|
</MultipleSelectGroup>
|
||||||
|
@ -83,7 +83,7 @@ export const HeaderGroupByCard = observer((props: IHeaderGroupByCard) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="group/list-header relative w-full flex-shrink-0 flex items-center gap-2.5 py-1.5 pl-3.5">
|
<div className="group/list-header relative w-full flex-shrink-0 flex items-center gap-2 py-1.5">
|
||||||
{canSelectIssues && (
|
{canSelectIssues && (
|
||||||
<div className="flex-shrink-0 flex items-center w-3.5">
|
<div className="flex-shrink-0 flex items-center w-3.5">
|
||||||
<MultipleSelectGroupAction
|
<MultipleSelectGroupAction
|
||||||
@ -98,7 +98,7 @@ export const HeaderGroupByCard = observer((props: IHeaderGroupByCard) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex-shrink-0 grid place-items-center overflow-hidden pl-3">
|
<div className="flex-shrink-0 grid place-items-center overflow-hidden">
|
||||||
{icon ?? <CircleDashed className="size-3.5" strokeWidth={2} />}
|
{icon ?? <CircleDashed className="size-3.5" strokeWidth={2} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -193,7 +193,7 @@ export const ListGroup = observer((props: Props) => {
|
|||||||
"border-custom-error-200": isDraggingOverColumn && !!group.isDropDisabled,
|
"border-custom-error-200": isDraggingOverColumn && !!group.isDropDisabled,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<div className="sticky top-0 z-[2] w-full flex-shrink-0 border-b border-custom-border-200 bg-custom-background-90 px-3 py-1">
|
<div className="sticky top-0 z-[2] w-full flex-shrink-0 border-b border-custom-border-200 bg-custom-background-90 pl-2 pr-3 py-1">
|
||||||
<HeaderGroupByCard
|
<HeaderGroupByCard
|
||||||
groupID={group.id}
|
groupID={group.id}
|
||||||
icon={group.icon}
|
icon={group.icon}
|
||||||
|
@ -152,7 +152,7 @@ export const ListQuickAddIssueForm: FC<IListQuickAddIssueForm> = observer((props
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className="flex w-full cursor-pointer items-center gap-2.5 p-6 py-3 text-custom-text-350 hover:text-custom-text-300"
|
className="flex w-full cursor-pointer items-center gap-2 px-2 py-3 text-custom-text-350 hover:text-custom-text-300"
|
||||||
onClick={() => setIsOpen(true)}
|
onClick={() => setIsOpen(true)}
|
||||||
>
|
>
|
||||||
<PlusIcon className="h-3.5 w-3.5 stroke-2" />
|
<PlusIcon className="h-3.5 w-3.5 stroke-2" />
|
||||||
|
@ -220,7 +220,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEstimate = (value: number | null) => {
|
const handleEstimate = (value: string | null) => {
|
||||||
updateIssue &&
|
updateIssue &&
|
||||||
updateIssue(issue.project_id, issue.id, { estimate_point: value }).then(() => {
|
updateIssue(issue.project_id, issue.id, { estimate_point: value }).then(() => {
|
||||||
captureIssueEvent({
|
captureIssueEvent({
|
||||||
|
@ -107,7 +107,7 @@ export const SpreadsheetView: React.FC<Props> = observer((props) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<IssueBulkOperationsRoot />
|
<IssueBulkOperationsRoot selectionHelpers={helpers} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</MultipleSelectGroup>
|
</MultipleSelectGroup>
|
||||||
|
@ -495,7 +495,7 @@ export const handleGroupDragDrop = async (
|
|||||||
// update updatedIssue values based on the source and destination groupIds
|
// update updatedIssue values based on the source and destination groupIds
|
||||||
if (source.groupId && destination.groupId && source.groupId !== destination.groupId) {
|
if (source.groupId && destination.groupId && source.groupId !== destination.groupId) {
|
||||||
const groupKey = ISSUE_FILTER_DEFAULT_DATA[groupBy];
|
const groupKey = ISSUE_FILTER_DEFAULT_DATA[groupBy];
|
||||||
let groupValue = clone(sourceIssue[groupKey]);
|
let groupValue: any = clone(sourceIssue[groupKey]);
|
||||||
|
|
||||||
// If groupValues is an array, remove source groupId and add destination groupId
|
// If groupValues is an array, remove source groupId and add destination groupId
|
||||||
if (Array.isArray(groupValue)) {
|
if (Array.isArray(groupValue)) {
|
||||||
@ -515,7 +515,7 @@ export const handleGroupDragDrop = async (
|
|||||||
// update updatedIssue values based on the source and destination subGroupIds
|
// update updatedIssue values based on the source and destination subGroupIds
|
||||||
if (subGroupBy && source.subGroupId && destination.subGroupId && source.subGroupId !== destination.subGroupId) {
|
if (subGroupBy && source.subGroupId && destination.subGroupId && source.subGroupId !== destination.subGroupId) {
|
||||||
const subGroupKey = ISSUE_FILTER_DEFAULT_DATA[subGroupBy];
|
const subGroupKey = ISSUE_FILTER_DEFAULT_DATA[subGroupBy];
|
||||||
let subGroupValue = clone(sourceIssue[subGroupKey]);
|
let subGroupValue: any = clone(sourceIssue[subGroupKey]);
|
||||||
|
|
||||||
// If subGroupValue is an array, remove source subGroupId and add destination subGroupId
|
// If subGroupValue is an array, remove source subGroupId and add destination subGroupId
|
||||||
if (Array.isArray(subGroupValue)) {
|
if (Array.isArray(subGroupValue)) {
|
||||||
|
@ -5,22 +5,30 @@ import { observer } from "mobx-react";
|
|||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { ChevronDown, ChevronRight, Plus } from "lucide-react";
|
import { ChevronDown, ChevronRight, Plus } from "lucide-react";
|
||||||
import { Disclosure, Transition } from "@headlessui/react";
|
import { Disclosure, Transition } from "@headlessui/react";
|
||||||
|
// types
|
||||||
import { IProject } from "@plane/types";
|
import { IProject } from "@plane/types";
|
||||||
// hooks
|
// ui
|
||||||
import { TOAST_TYPE, setToast } from "@plane/ui";
|
import { TOAST_TYPE, setToast } from "@plane/ui";
|
||||||
|
// components
|
||||||
import { CreateProjectModal, ProjectSidebarListItem } from "@/components/project";
|
import { CreateProjectModal, ProjectSidebarListItem } from "@/components/project";
|
||||||
|
// constants
|
||||||
import { EUserWorkspaceRoles } from "@/constants/workspace";
|
import { EUserWorkspaceRoles } from "@/constants/workspace";
|
||||||
|
// helpers
|
||||||
import { cn } from "@/helpers/common.helper";
|
import { cn } from "@/helpers/common.helper";
|
||||||
import { orderJoinedProjects } from "@/helpers/project.helper";
|
import { orderJoinedProjects } from "@/helpers/project.helper";
|
||||||
import { copyUrlToClipboard } from "@/helpers/string.helper";
|
import { copyUrlToClipboard } from "@/helpers/string.helper";
|
||||||
|
// hooks
|
||||||
import { useAppTheme, useCommandPalette, useEventTracker, useProject, useUser } from "@/hooks/store";
|
import { useAppTheme, useCommandPalette, useEventTracker, useProject, useUser } from "@/hooks/store";
|
||||||
// ui
|
|
||||||
// components
|
|
||||||
// helpers
|
|
||||||
// constants
|
|
||||||
|
|
||||||
export const ProjectSidebarList: FC = observer(() => {
|
export const ProjectSidebarList: FC = observer(() => {
|
||||||
|
// get local storage data for isFavoriteProjectsListOpen and isAllProjectsListOpen
|
||||||
|
const isFavProjectsListOpenInLocalStorage = localStorage.getItem("isFavoriteProjectsListOpen");
|
||||||
|
const isAllProjectsListOpenInLocalStorage = localStorage.getItem("isAllProjectsListOpen");
|
||||||
// states
|
// states
|
||||||
|
const [isFavoriteProjectsListOpen, setIsFavoriteProjectsListOpen] = useState(
|
||||||
|
isFavProjectsListOpenInLocalStorage === "true"
|
||||||
|
);
|
||||||
|
const [isAllProjectsListOpen, setIsAllProjectsListOpen] = useState(isAllProjectsListOpenInLocalStorage === "true");
|
||||||
const [isFavoriteProjectCreate, setIsFavoriteProjectCreate] = useState(false);
|
const [isFavoriteProjectCreate, setIsFavoriteProjectCreate] = useState(false);
|
||||||
const [isProjectModalOpen, setIsProjectModalOpen] = useState(false);
|
const [isProjectModalOpen, setIsProjectModalOpen] = useState(false);
|
||||||
const [isScrolled, setIsScrolled] = useState(false); // scroll animation state
|
const [isScrolled, setIsScrolled] = useState(false); // scroll animation state
|
||||||
@ -122,6 +130,16 @@ export const ProjectSidebarList: FC = observer(() => {
|
|||||||
);
|
);
|
||||||
}, [containerRef]);
|
}, [containerRef]);
|
||||||
|
|
||||||
|
const toggleListDisclosure = (isOpen: boolean, type: "all" | "favorite") => {
|
||||||
|
if (type === "all") {
|
||||||
|
setIsAllProjectsListOpen(isOpen);
|
||||||
|
localStorage.setItem("isAllProjectsListOpen", isOpen.toString());
|
||||||
|
} else {
|
||||||
|
setIsFavoriteProjectsListOpen(isOpen);
|
||||||
|
localStorage.setItem("isFavoriteProjectsListOpen", isOpen.toString());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{workspaceSlug && (
|
{workspaceSlug && (
|
||||||
@ -147,42 +165,48 @@ export const ProjectSidebarList: FC = observer(() => {
|
|||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
{favoriteProjects && favoriteProjects.length > 0 && (
|
{favoriteProjects && favoriteProjects.length > 0 && (
|
||||||
<Disclosure as="div" className="flex flex-col" defaultOpen>
|
<Disclosure as="div" className="flex flex-col" defaultOpen={isFavoriteProjectCreate}>
|
||||||
{({ open }) => (
|
<>
|
||||||
<>
|
{!isCollapsed && (
|
||||||
{!isCollapsed && (
|
<div className="group flex w-full items-center justify-between rounded p-1.5 text-xs text-custom-sidebar-text-400 hover:bg-custom-sidebar-background-80">
|
||||||
<div className="group flex w-full items-center justify-between rounded p-1.5 text-xs text-custom-sidebar-text-400 hover:bg-custom-sidebar-background-80">
|
<Disclosure.Button
|
||||||
<Disclosure.Button
|
as="button"
|
||||||
as="button"
|
type="button"
|
||||||
type="button"
|
className="group flex w-full items-center gap-1 whitespace-nowrap rounded px-1.5 text-left text-sm font-semibold text-custom-sidebar-text-400 hover:bg-custom-sidebar-background-80"
|
||||||
className="group flex w-full items-center gap-1 whitespace-nowrap rounded px-1.5 text-left text-sm font-semibold text-custom-sidebar-text-400 hover:bg-custom-sidebar-background-80"
|
onClick={() => toggleListDisclosure(!isFavoriteProjectsListOpen, "favorite")}
|
||||||
>
|
>
|
||||||
Favorites
|
Favorites
|
||||||
{open ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
{isFavoriteProjectsListOpen ? (
|
||||||
</Disclosure.Button>
|
<ChevronDown className="h-3.5 w-3.5" />
|
||||||
{isAuthorizedUser && (
|
) : (
|
||||||
<button
|
<ChevronRight className="h-3.5 w-3.5" />
|
||||||
className="opacity-0 group-hover:opacity-100"
|
|
||||||
onClick={() => {
|
|
||||||
setTrackElement("APP_SIDEBAR_FAVORITES_BLOCK");
|
|
||||||
setIsFavoriteProjectCreate(true);
|
|
||||||
setIsProjectModalOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Plus className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</Disclosure.Button>
|
||||||
)}
|
{isAuthorizedUser && (
|
||||||
<Transition
|
<button
|
||||||
enter="transition duration-100 ease-out"
|
className="opacity-0 group-hover:opacity-100"
|
||||||
enterFrom="transform scale-95 opacity-0"
|
onClick={() => {
|
||||||
enterTo="transform scale-100 opacity-100"
|
setTrackElement("APP_SIDEBAR_FAVORITES_BLOCK");
|
||||||
leave="transition duration-75 ease-out"
|
setIsFavoriteProjectCreate(true);
|
||||||
leaveFrom="transform scale-100 opacity-100"
|
setIsProjectModalOpen(true);
|
||||||
leaveTo="transform scale-95 opacity-0"
|
}}
|
||||||
>
|
>
|
||||||
<Disclosure.Panel as="div" className="space-y-2">
|
<Plus className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Transition
|
||||||
|
show={isFavoriteProjectsListOpen}
|
||||||
|
enter="transition duration-100 ease-out"
|
||||||
|
enterFrom="transform scale-95 opacity-0"
|
||||||
|
enterTo="transform scale-100 opacity-100"
|
||||||
|
leave="transition duration-75 ease-out"
|
||||||
|
leaveFrom="transform scale-100 opacity-100"
|
||||||
|
leaveTo="transform scale-95 opacity-0"
|
||||||
|
>
|
||||||
|
{isFavoriteProjectsListOpen && (
|
||||||
|
<Disclosure.Panel as="div" className={`space-y-2`} static>
|
||||||
{favoriteProjects.map((projectId, index) => (
|
{favoriteProjects.map((projectId, index) => (
|
||||||
<ProjectSidebarListItem
|
<ProjectSidebarListItem
|
||||||
key={projectId}
|
key={projectId}
|
||||||
@ -195,50 +219,56 @@ export const ProjectSidebarList: FC = observer(() => {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Disclosure.Panel>
|
</Disclosure.Panel>
|
||||||
</Transition>
|
)}
|
||||||
</>
|
</Transition>
|
||||||
)}
|
</>
|
||||||
</Disclosure>
|
</Disclosure>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{joinedProjects && joinedProjects.length > 0 && (
|
{joinedProjects && joinedProjects.length > 0 && (
|
||||||
<Disclosure as="div" className="flex flex-col" defaultOpen>
|
<Disclosure as="div" className="flex flex-col" defaultOpen={isAllProjectsListOpen}>
|
||||||
{({ open }) => (
|
<>
|
||||||
<>
|
{!isCollapsed && (
|
||||||
{!isCollapsed && (
|
<div className="group flex w-full items-center justify-between rounded p-1.5 text-xs text-custom-sidebar-text-400 hover:bg-custom-sidebar-background-80">
|
||||||
<div className="group flex w-full items-center justify-between rounded p-1.5 text-xs text-custom-sidebar-text-400 hover:bg-custom-sidebar-background-80">
|
<Disclosure.Button
|
||||||
<Disclosure.Button
|
as="button"
|
||||||
as="button"
|
type="button"
|
||||||
type="button"
|
className="group flex w-full items-center gap-1 whitespace-nowrap rounded px-1.5 text-left text-sm font-semibold text-custom-sidebar-text-400 hover:bg-custom-sidebar-background-80"
|
||||||
className="group flex w-full items-center gap-1 whitespace-nowrap rounded px-1.5 text-left text-sm font-semibold text-custom-sidebar-text-400 hover:bg-custom-sidebar-background-80"
|
onClick={() => toggleListDisclosure(!isAllProjectsListOpen, "all")}
|
||||||
>
|
>
|
||||||
Your projects
|
Your projects
|
||||||
{open ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
{isAllProjectsListOpen ? (
|
||||||
</Disclosure.Button>
|
<ChevronDown className="h-3.5 w-3.5" />
|
||||||
{isAuthorizedUser && (
|
) : (
|
||||||
<button
|
<ChevronRight className="h-3.5 w-3.5" />
|
||||||
className="opacity-0 group-hover:opacity-100"
|
|
||||||
onClick={() => {
|
|
||||||
setTrackElement("Sidebar");
|
|
||||||
setIsFavoriteProjectCreate(false);
|
|
||||||
setIsProjectModalOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Plus className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</Disclosure.Button>
|
||||||
)}
|
{isAuthorizedUser && (
|
||||||
<Transition
|
<button
|
||||||
enter="transition duration-100 ease-out"
|
className="opacity-0 group-hover:opacity-100"
|
||||||
enterFrom="transform scale-95 opacity-0"
|
onClick={() => {
|
||||||
enterTo="transform scale-100 opacity-100"
|
setTrackElement("Sidebar");
|
||||||
leave="transition duration-75 ease-out"
|
setIsFavoriteProjectCreate(false);
|
||||||
leaveFrom="transform scale-100 opacity-100"
|
setIsProjectModalOpen(true);
|
||||||
leaveTo="transform scale-95 opacity-0"
|
}}
|
||||||
>
|
>
|
||||||
<Disclosure.Panel as="div">
|
<Plus className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Transition
|
||||||
|
show={isAllProjectsListOpen}
|
||||||
|
enter="transition duration-100 ease-out"
|
||||||
|
enterFrom="transform scale-95 opacity-0"
|
||||||
|
enterTo="transform scale-100 opacity-100"
|
||||||
|
leave="transition duration-75 ease-out"
|
||||||
|
leaveFrom="transform scale-100 opacity-100"
|
||||||
|
leaveTo="transform scale-95 opacity-0"
|
||||||
|
>
|
||||||
|
{isAllProjectsListOpen && (
|
||||||
|
<Disclosure.Panel as="div" static>
|
||||||
{joinedProjects.map((projectId, index) => (
|
{joinedProjects.map((projectId, index) => (
|
||||||
<ProjectSidebarListItem
|
<ProjectSidebarListItem
|
||||||
key={projectId}
|
key={projectId}
|
||||||
@ -250,9 +280,9 @@ export const ProjectSidebarList: FC = observer(() => {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Disclosure.Panel>
|
</Disclosure.Panel>
|
||||||
</Transition>
|
)}
|
||||||
</>
|
</Transition>
|
||||||
)}
|
</>
|
||||||
</Disclosure>
|
</Disclosure>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
@ -33,6 +33,7 @@ export const useMultipleSelect = (props: Props) => {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
// store hooks
|
// store hooks
|
||||||
const {
|
const {
|
||||||
|
selectedEntityIds,
|
||||||
updateSelectedEntityDetails,
|
updateSelectedEntityDetails,
|
||||||
bulkUpdateSelectedEntityDetails,
|
bulkUpdateSelectedEntityDetails,
|
||||||
getActiveEntityDetails,
|
getActiveEntityDetails,
|
||||||
@ -45,6 +46,7 @@ export const useMultipleSelect = (props: Props) => {
|
|||||||
clearSelection,
|
clearSelection,
|
||||||
getIsEntitySelected,
|
getIsEntitySelected,
|
||||||
getIsEntityActive,
|
getIsEntityActive,
|
||||||
|
getEntityDetailsFromEntityID,
|
||||||
} = useMultipleSelectStore();
|
} = useMultipleSelectStore();
|
||||||
|
|
||||||
const groups = useMemo(() => Object.keys(entities), [entities]);
|
const groups = useMemo(() => Object.keys(entities), [entities]);
|
||||||
@ -248,10 +250,6 @@ export const useMultipleSelect = (props: Props) => {
|
|||||||
(groupID: string) => {
|
(groupID: string) => {
|
||||||
const groupEntities = entitiesList.filter((entity) => entity.groupID === groupID);
|
const groupEntities = entitiesList.filter((entity) => entity.groupID === groupID);
|
||||||
const groupSelectionStatus = isGroupSelected(groupID);
|
const groupSelectionStatus = isGroupSelected(groupID);
|
||||||
// groupEntities.map((entity) => {
|
|
||||||
// console.log("group click");
|
|
||||||
// handleEntitySelection(entity, false, groupSelectionStatus === "empty" ? "force-add" : "force-remove");
|
|
||||||
// });
|
|
||||||
handleEntitySelection(groupEntities, false, groupSelectionStatus === "empty" ? "force-add" : "force-remove");
|
handleEntitySelection(groupEntities, false, groupSelectionStatus === "empty" ? "force-add" : "force-remove");
|
||||||
},
|
},
|
||||||
[entitiesList, handleEntitySelection, isGroupSelected]
|
[entitiesList, handleEntitySelection, isGroupSelected]
|
||||||
@ -346,6 +344,19 @@ export const useMultipleSelect = (props: Props) => {
|
|||||||
};
|
};
|
||||||
}, [clearSelection, router.events]);
|
}, [clearSelection, router.events]);
|
||||||
|
|
||||||
|
// when entities list change, remove entityIds from the selected entities array, which are not present in the new list
|
||||||
|
useEffect(() => {
|
||||||
|
selectedEntityIds.map((entityID) => {
|
||||||
|
const isEntityPresent = entitiesList.find((en) => en.entityID === entityID);
|
||||||
|
if (!isEntityPresent) {
|
||||||
|
const entityDetails = getEntityDetailsFromEntityID(entityID);
|
||||||
|
if (entityDetails) {
|
||||||
|
handleEntitySelection(entityDetails);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [entitiesList, getEntityDetailsFromEntityID, handleEntitySelection, selectedEntityIds]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description helper functions for selection
|
* @description helper functions for selection
|
||||||
*/
|
*/
|
||||||
|
@ -19,7 +19,7 @@ export interface IEstimateStore {
|
|||||||
activeEstimateDetails: IEstimate | null;
|
activeEstimateDetails: IEstimate | null;
|
||||||
// computed actions
|
// computed actions
|
||||||
areEstimatesEnabledForProject: (projectId: string) => boolean;
|
areEstimatesEnabledForProject: (projectId: string) => boolean;
|
||||||
getEstimatePointValue: (estimateKey: number | null, projectId: string | null) => string;
|
getEstimatePointValue: (estimateKey: string | null, projectId: string | null) => string;
|
||||||
getProjectEstimateById: (estimateId: string) => IEstimate | null;
|
getProjectEstimateById: (estimateId: string) => IEstimate | null;
|
||||||
getProjectActiveEstimateDetails: (projectId: string) => IEstimate | null;
|
getProjectActiveEstimateDetails: (projectId: string) => IEstimate | null;
|
||||||
// fetch actions
|
// fetch actions
|
||||||
@ -110,10 +110,10 @@ export class EstimateStore implements IEstimateStore {
|
|||||||
/**
|
/**
|
||||||
* @description returns the point value for the given estimate key to display in the UI
|
* @description returns the point value for the given estimate key to display in the UI
|
||||||
*/
|
*/
|
||||||
getEstimatePointValue = computedFn((estimateKey: number | null, projectId: string | null) => {
|
getEstimatePointValue = computedFn((estimateKey: string | null, projectId: string | null) => {
|
||||||
if (estimateKey === null) return "None";
|
if (estimateKey === null) return "None";
|
||||||
const activeEstimate = projectId ? this.getProjectActiveEstimateDetails(projectId) : this.activeEstimateDetails;
|
const activeEstimate = projectId ? this.getProjectActiveEstimateDetails(projectId) : this.activeEstimateDetails;
|
||||||
return activeEstimate?.points?.find((point) => point.key === estimateKey)?.value || "None";
|
return activeEstimate?.points?.find((point) => point.id === estimateKey)?.value || "None";
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -4,10 +4,10 @@ import sortBy from "lodash/sortBy";
|
|||||||
import uniq from "lodash/uniq";
|
import uniq from "lodash/uniq";
|
||||||
import update from "lodash/update";
|
import update from "lodash/update";
|
||||||
import { action, makeObservable, observable, runInAction } from "mobx";
|
import { action, makeObservable, observable, runInAction } from "mobx";
|
||||||
|
import { TIssueActivityComment, TIssueActivity, TIssueActivityMap, TIssueActivityIdMap } from "@plane/types";
|
||||||
// services
|
// services
|
||||||
import { IssueActivityService } from "@/services/issue";
|
import { IssueActivityService } from "@/services/issue";
|
||||||
// types
|
// types
|
||||||
import { TIssueActivityComment, TIssueActivity, TIssueActivityMap, TIssueActivityIdMap } from "@plane/types";
|
|
||||||
import { IIssueDetail } from "./root.store";
|
import { IIssueDetail } from "./root.store";
|
||||||
|
|
||||||
export type TActivityLoader = "fetch" | "mutate" | undefined;
|
export type TActivityLoader = "fetch" | "mutate" | undefined;
|
||||||
@ -117,10 +117,10 @@ export class IssueActivityStore implements IIssueActivityStore {
|
|||||||
this.loader = loaderType;
|
this.loader = loaderType;
|
||||||
|
|
||||||
let props = {};
|
let props = {};
|
||||||
const _activityIds = this.getActivitiesByIssueId(issueId);
|
const currentActivityIds = this.getActivitiesByIssueId(issueId);
|
||||||
if (_activityIds && _activityIds.length > 0) {
|
if (currentActivityIds && currentActivityIds.length > 0) {
|
||||||
const _activity = this.getActivityById(_activityIds[_activityIds.length - 1]);
|
const currentActivity = this.getActivityById(currentActivityIds[currentActivityIds.length - 1]);
|
||||||
if (_activity) props = { created_at__gt: _activity.created_at };
|
if (currentActivity) props = { created_at__gt: currentActivity.created_at };
|
||||||
}
|
}
|
||||||
|
|
||||||
const activities = await this.issueActivityService.getIssueActivities(workspaceSlug, projectId, issueId, props);
|
const activities = await this.issueActivityService.getIssueActivities(workspaceSlug, projectId, issueId, props);
|
||||||
@ -128,9 +128,9 @@ export class IssueActivityStore implements IIssueActivityStore {
|
|||||||
const activityIds = activities.map((activity) => activity.id);
|
const activityIds = activities.map((activity) => activity.id);
|
||||||
|
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
update(this.activities, issueId, (_activityIds) => {
|
update(this.activities, issueId, (currentActivityIds) => {
|
||||||
if (!_activityIds) return activityIds;
|
if (!currentActivityIds) return activityIds;
|
||||||
return uniq(concat(_activityIds, activityIds));
|
return uniq(concat(currentActivityIds, activityIds));
|
||||||
});
|
});
|
||||||
activities.forEach((activity) => {
|
activities.forEach((activity) => {
|
||||||
set(this.activityMap, activity.id, activity);
|
set(this.activityMap, activity.id, activity);
|
||||||
|
@ -19,6 +19,7 @@ export type IMultipleSelectStore = {
|
|||||||
getPreviousActiveEntity: () => TEntityDetails | null;
|
getPreviousActiveEntity: () => TEntityDetails | null;
|
||||||
getNextActiveEntity: () => TEntityDetails | null;
|
getNextActiveEntity: () => TEntityDetails | null;
|
||||||
getActiveEntityDetails: () => TEntityDetails | null;
|
getActiveEntityDetails: () => TEntityDetails | null;
|
||||||
|
getEntityDetailsFromEntityID: (entityID: string) => TEntityDetails | null;
|
||||||
// entity actions
|
// entity actions
|
||||||
updateSelectedEntityDetails: (entityDetails: TEntityDetails, action: "add" | "remove") => void;
|
updateSelectedEntityDetails: (entityDetails: TEntityDetails, action: "add" | "remove") => void;
|
||||||
bulkUpdateSelectedEntityDetails: (entitiesList: TEntityDetails[], action: "add" | "remove") => void;
|
bulkUpdateSelectedEntityDetails: (entitiesList: TEntityDetails[], action: "add" | "remove") => void;
|
||||||
@ -119,6 +120,16 @@ export class MultipleSelectStore implements IMultipleSelectStore {
|
|||||||
*/
|
*/
|
||||||
getActiveEntityDetails = computedFn(() => this.activeEntityDetails);
|
getActiveEntityDetails = computedFn(() => this.activeEntityDetails);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description get the entity details from entityID
|
||||||
|
* @param {string} entityID
|
||||||
|
* @returns {TEntityDetails | null}
|
||||||
|
*/
|
||||||
|
getEntityDetailsFromEntityID = computedFn(
|
||||||
|
(entityID: string): TEntityDetails | null =>
|
||||||
|
this.selectedEntityDetails.find((en) => en.entityID === entityID) ?? null
|
||||||
|
);
|
||||||
|
|
||||||
// entity actions
|
// entity actions
|
||||||
/**
|
/**
|
||||||
* @description add or remove entities
|
* @description add or remove entities
|
||||||
@ -159,8 +170,11 @@ export class MultipleSelectStore implements IMultipleSelectStore {
|
|||||||
if (entitiesList.length > 0) this.updateLastSelectedEntityDetails(entitiesList[entitiesList.length - 1]);
|
if (entitiesList.length > 0) this.updateLastSelectedEntityDetails(entitiesList[entitiesList.length - 1]);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
const newEntities = differenceWith(this.selectedEntityDetails, entitiesList, (obj1, obj2) =>
|
||||||
|
isEqual(obj1.entityID, obj2.entityID)
|
||||||
|
);
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
this.selectedEntityDetails = differenceWith(this.selectedEntityDetails, entitiesList, isEqual);
|
this.selectedEntityDetails = newEntities;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
Loading…
Reference in New Issue
Block a user