Merge branch 'develop' of github.com:makeplane/plane into refactor/space-app

This commit is contained in:
NarayanBavisetti 2024-06-05 18:20:21 +05:30
commit ab4fb77bc4
36 changed files with 659 additions and 254 deletions

View File

@ -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 }}

View File

@ -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()

View File

@ -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,13 +48,45 @@ 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 raise AuthenticationException(
is_signup = bool(user) error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL"],
if not user: error_message="INVALID_EMAIL",
# New user 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( (ENABLE_SIGNUP,) = get_configuration_value(
[ [
{ {
@ -61,53 +95,24 @@ class Adapter:
}, },
] ]
) )
# Check if sign up is disabled and invite is present or not
if ( if (
ENABLE_SIGNUP == "0" ENABLE_SIGNUP == "0"
and not WorkspaceMemberInvite.objects.filter( and not WorkspaceMemberInvite.objects.filter(
email=email, email=email,
).exists() ).exists()
): ):
# Raise exception
raise AuthenticationException( raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["SIGNUP_DISABLED"], error_code=AUTHENTICATION_ERROR_CODES["SIGNUP_DISABLED"],
error_message="SIGNUP_DISABLED", error_message="SIGNUP_DISABLED",
payload={"email": email}, payload={"email": email},
) )
user = User(email=email, username=uuid.uuid4().hex)
if self.user_data.get("user").get("is_password_autoset"): return True
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(
AUTHENTICATION_ERROR_CODES["USER_ACCOUNT_DEACTIVATED"],
error_message="USER_ACCOUNT_DEACTIVATED",
)
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

View File

@ -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,
} }

View File

@ -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),

View File

@ -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

View File

@ -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,

View 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),
]

View File

@ -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

View File

@ -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):

View File

@ -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"""

View File

@ -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>")

View File

@ -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

View File

@ -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)

View File

@ -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;

View File

@ -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;

View File

@ -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>
); );

View File

@ -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 && (
<> <>
{" "} {" "}

View File

@ -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();
}; };

View File

@ -162,7 +162,7 @@ export const GanttChartMainContent: React.FC<Props> = observer((props) => {
)} )}
</div> </div>
</div> </div>
<IssueBulkOperationsRoot /> <IssueBulkOperationsRoot selectionHelpers={helpers} />
</> </>
)} )}
</MultipleSelectGroup> </MultipleSelectGroup>

View File

@ -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) => {

View File

@ -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} />}.
</> </>

View File

@ -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}

View File

@ -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>
) : ( ) : (

View File

@ -170,7 +170,7 @@ const GroupByList: React.FC<IGroupByList> = observer((props) => {
) )
)} )}
</div> </div>
<IssueBulkOperationsRoot /> <IssueBulkOperationsRoot selectionHelpers={helpers} />
</> </>
)} )}
</MultipleSelectGroup> </MultipleSelectGroup>

View File

@ -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>

View File

@ -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}

View File

@ -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" />

View File

@ -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({

View File

@ -107,7 +107,7 @@ export const SpreadsheetView: React.FC<Props> = observer((props) => {
)} )}
</div> </div>
</div> </div>
<IssueBulkOperationsRoot /> <IssueBulkOperationsRoot selectionHelpers={helpers} />
</> </>
)} )}
</MultipleSelectGroup> </MultipleSelectGroup>

View File

@ -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)) {

View File

@ -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,8 +165,7 @@ 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">
@ -156,9 +173,14 @@ export const ProjectSidebarList: FC = observer(() => {
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 ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
</Disclosure.Button> </Disclosure.Button>
{isAuthorizedUser && ( {isAuthorizedUser && (
<button <button
@ -175,6 +197,7 @@ export const ProjectSidebarList: FC = observer(() => {
</div> </div>
)} )}
<Transition <Transition
show={isFavoriteProjectsListOpen}
enter="transition duration-100 ease-out" enter="transition duration-100 ease-out"
enterFrom="transform scale-95 opacity-0" enterFrom="transform scale-95 opacity-0"
enterTo="transform scale-100 opacity-100" enterTo="transform scale-100 opacity-100"
@ -182,7 +205,8 @@ export const ProjectSidebarList: FC = observer(() => {
leaveFrom="transform scale-100 opacity-100" leaveFrom="transform scale-100 opacity-100"
leaveTo="transform scale-95 opacity-0" leaveTo="transform scale-95 opacity-0"
> >
<Disclosure.Panel as="div" className="space-y-2"> {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,16 +219,15 @@ 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">
@ -212,9 +235,14 @@ export const ProjectSidebarList: FC = observer(() => {
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 ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
</Disclosure.Button> </Disclosure.Button>
{isAuthorizedUser && ( {isAuthorizedUser && (
<button <button
@ -231,6 +259,7 @@ export const ProjectSidebarList: FC = observer(() => {
</div> </div>
)} )}
<Transition <Transition
show={isAllProjectsListOpen}
enter="transition duration-100 ease-out" enter="transition duration-100 ease-out"
enterFrom="transform scale-95 opacity-0" enterFrom="transform scale-95 opacity-0"
enterTo="transform scale-100 opacity-100" enterTo="transform scale-100 opacity-100"
@ -238,7 +267,8 @@ export const ProjectSidebarList: FC = observer(() => {
leaveFrom="transform scale-100 opacity-100" leaveFrom="transform scale-100 opacity-100"
leaveTo="transform scale-95 opacity-0" leaveTo="transform scale-95 opacity-0"
> >
<Disclosure.Panel as="div"> {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>

View File

@ -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
*/ */

View File

@ -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";
}); });
/** /**

View File

@ -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);

View File

@ -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;
}); });
} }
}; };