diff --git a/.github/workflows/build-test-pull-request.yml b/.github/workflows/build-test-pull-request.yml index 5b94b215a..2e6f9c642 100644 --- a/.github/workflows/build-test-pull-request.yml +++ b/.github/workflows/build-test-pull-request.yml @@ -3,10 +3,11 @@ name: Build and Lint on Pull Request on: workflow_dispatch: pull_request: - types: ["opened", "synchronize"] + types: ["opened", "synchronize", "ready_for_review"] jobs: get-changed-files: + if: github.event.pull_request.draft == false runs-on: ubuntu-latest outputs: apiserver_changed: ${{ steps.changed-files.outputs.apiserver_any_changed }} diff --git a/admin/app/authentication/page.tsx b/admin/app/authentication/page.tsx index d1e6fb0ba..c44b74b49 100644 --- a/admin/app/authentication/page.tsx +++ b/admin/app/authentication/page.tsx @@ -7,12 +7,12 @@ import { useTheme } from "next-themes"; import useSWR from "swr"; import { Mails, KeyRound } from "lucide-react"; import { TInstanceConfigurationKeys } from "@plane/types"; -import { Loader, setPromiseToast } from "@plane/ui"; +import { Loader, ToggleSwitch, setPromiseToast } from "@plane/ui"; // components import { PageHeader } from "@/components/core"; // hooks // helpers -import { resolveGeneralTheme } from "@/helpers/common.helper"; +import { cn, resolveGeneralTheme } from "@/helpers/common.helper"; import { useInstance } from "@/hooks/store"; // images import githubLightModeImage from "@/public/logos/github-black.png"; @@ -45,6 +45,8 @@ const InstanceAuthenticationPage = observer(() => { const [isSubmitting, setIsSubmitting] = useState(false); // theme const { resolvedTheme } = useTheme(); + // derived values + const enableSignUpConfig = formattedConfig?.ENABLE_SIGNUP ?? ""; const updateConfig = async (key: TInstanceConfigurationKeys, value: string) => { setIsSubmitting(true); @@ -129,7 +131,34 @@ const InstanceAuthenticationPage = observer(() => {
{formattedConfig ? (
-
Authentication modes
+
Sign-up configuration
+
+
+
+
+ Allow anyone to sign up without invite +
+
+ Toggling this off will disable self sign ups. +
+
+
+
+
+ { + Boolean(parseInt(enableSignUpConfig)) === true + ? updateConfig("ENABLE_SIGNUP", "0") + : updateConfig("ENABLE_SIGNUP", "1"); + }} + size="sm" + disabled={isSubmitting} + /> +
+
+
+
Authentication modes
{authenticationMethodsCard.map((method) => ( { return (
/projects//project-deploy-boards/", - ProjectDeployBoardViewSet.as_view( + DeployBoardViewSet.as_view( { "get": "list", "post": "create", @@ -167,7 +167,7 @@ urlpatterns = [ ), path( "workspaces//projects//project-deploy-boards//", - ProjectDeployBoardViewSet.as_view( + DeployBoardViewSet.as_view( { "get": "retrieve", "patch": "partial_update", diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py index ee31ebc18..8da0268b9 100644 --- a/apiserver/plane/app/views/__init__.py +++ b/apiserver/plane/app/views/__init__.py @@ -4,7 +4,7 @@ from .project.base import ( ProjectUserViewsEndpoint, ProjectFavoritesViewSet, ProjectPublicCoverImagesEndpoint, - ProjectDeployBoardViewSet, + DeployBoardViewSet, ProjectArchiveUnarchiveEndpoint, ) diff --git a/apiserver/plane/app/views/project/base.py b/apiserver/plane/app/views/project/base.py index 39db11871..a62d6d6dd 100644 --- a/apiserver/plane/app/views/project/base.py +++ b/apiserver/plane/app/views/project/base.py @@ -28,7 +28,7 @@ from plane.app.views.base import BaseViewSet, BaseAPIView from plane.app.serializers import ( ProjectSerializer, ProjectListSerializer, - ProjectDeployBoardSerializer, + DeployBoardSerializer, ) from plane.app.permissions import ( @@ -46,7 +46,7 @@ from plane.db.models import ( Module, Cycle, Inbox, - ProjectDeployBoard, + DeployBoard, IssueProperty, Issue, ) @@ -138,7 +138,7 @@ class ProjectViewSet(BaseViewSet): ) .annotate( is_deployed=Exists( - ProjectDeployBoard.objects.filter( + DeployBoard.objects.filter( project_id=OuterRef("pk"), workspace__slug=self.kwargs.get("slug"), ) @@ -639,12 +639,12 @@ class ProjectPublicCoverImagesEndpoint(BaseAPIView): return Response(files, status=status.HTTP_200_OK) -class ProjectDeployBoardViewSet(BaseViewSet): +class DeployBoardViewSet(BaseViewSet): permission_classes = [ ProjectMemberPermission, ] - serializer_class = ProjectDeployBoardSerializer - model = ProjectDeployBoard + serializer_class = DeployBoardSerializer + model = DeployBoard def get_queryset(self): return ( @@ -673,17 +673,17 @@ class ProjectDeployBoardViewSet(BaseViewSet): }, ) - project_deploy_board, _ = ProjectDeployBoard.objects.get_or_create( + project_deploy_board, _ = DeployBoard.objects.get_or_create( anchor=f"{slug}/{project_id}", project_id=project_id, ) - project_deploy_board.comments = comments - project_deploy_board.reactions = reactions project_deploy_board.inbox = inbox - project_deploy_board.votes = votes - project_deploy_board.views = views + project_deploy_board.view_props = 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() - serializer = ProjectDeployBoardSerializer(project_deploy_board) + serializer = DeployBoardSerializer(project_deploy_board) return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apiserver/plane/authentication/adapter/base.py b/apiserver/plane/authentication/adapter/base.py index 7b899e63c..5876e934f 100644 --- a/apiserver/plane/authentication/adapter/base.py +++ b/apiserver/plane/authentication/adapter/base.py @@ -4,6 +4,8 @@ import uuid # Django imports from django.utils import timezone +from django.core.validators import validate_email +from django.core.exceptions import ValidationError # Third party imports from zxcvbn import zxcvbn @@ -46,68 +48,71 @@ class Adapter: def authenticate(self): raise NotImplementedError - def complete_login_or_signup(self): - email = self.user_data.get("email") - user = User.objects.filter(email=email).first() - # 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: + def sanitize_email(self, email): + # Check if email is present + if not email: raise AuthenticationException( - AUTHENTICATION_ERROR_CODES["USER_ACCOUNT_DEACTIVATED"], - error_message="USER_ACCOUNT_DEACTIVATED", + error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL"], + 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 user.last_login_medium = self.provider user.last_active = timezone.now() @@ -116,7 +121,63 @@ class Adapter: user.last_login_uagent = self.request.META.get("HTTP_USER_AGENT") user.token_updated_at = timezone.now() 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: self.callback( user, @@ -124,7 +185,9 @@ class Adapter: self.request, ) + # Create or update account if token data is present if self.token_data: self.create_update_account(user=user) + # Return user return user diff --git a/apiserver/plane/authentication/adapter/error.py b/apiserver/plane/authentication/adapter/error.py index 7b12db945..55ff10988 100644 --- a/apiserver/plane/authentication/adapter/error.py +++ b/apiserver/plane/authentication/adapter/error.py @@ -58,6 +58,8 @@ AUTHENTICATION_ERROR_CODES = { "ADMIN_USER_DEACTIVATED": 5190, # Rate limit "RATE_LIMIT_EXCEEDED": 5900, + # Unknown + "AUTHENTICATION_FAILED": 5999, } diff --git a/apiserver/plane/authentication/adapter/oauth.py b/apiserver/plane/authentication/adapter/oauth.py index a917c002a..b1a92e79e 100644 --- a/apiserver/plane/authentication/adapter/oauth.py +++ b/apiserver/plane/authentication/adapter/oauth.py @@ -81,11 +81,11 @@ class OauthAdapter(Adapter): response.raise_for_status() return response.json() except requests.RequestException: - code = ( - "GOOGLE_OAUTH_PROVIDER_ERROR" - if self.provider == "google" - else "GITHUB_OAUTH_PROVIDER_ERROR" - ) + if self.provider == "google": + code = "GOOGLE_OAUTH_PROVIDER_ERROR" + if self.provider == "github": + code = "GITHUB_OAUTH_PROVIDER_ERROR" + raise AuthenticationException( error_code=AUTHENTICATION_ERROR_CODES[code], error_message=str(code), diff --git a/apiserver/plane/authentication/utils/workspace_project_join.py b/apiserver/plane/authentication/utils/workspace_project_join.py index 8910ec637..3b6f231ed 100644 --- a/apiserver/plane/authentication/utils/workspace_project_join.py +++ b/apiserver/plane/authentication/utils/workspace_project_join.py @@ -4,6 +4,7 @@ from plane.db.models import ( WorkspaceMember, WorkspaceMemberInvite, ) +from plane.utils.cache import invalidate_cache_directly def process_workspace_project_invitations(user): @@ -26,6 +27,16 @@ def process_workspace_project_invitations(user): 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 project_member_invites = ProjectMemberInvite.objects.filter( email=user.email, accepted=True diff --git a/apiserver/plane/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py index 007b3e48c..67cda14af 100644 --- a/apiserver/plane/bgtasks/issue_activites_task.py +++ b/apiserver/plane/bgtasks/issue_activites_task.py @@ -28,6 +28,7 @@ from plane.db.models import ( Project, State, User, + EstimatePoint, ) from plane.settings.redis import redis_instance 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( "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( IssueActivity( issue_id=issue_id, actor_id=actor_id, verb="updated", - old_value=( + old_identifier=( current_instance.get("estimate_point") if current_instance.get("estimate_point") is not None - else "" + else None ), - new_value=( + new_identifier=( requested_data.get("estimate_point") 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", project_id=project_id, workspace_id=workspace_id, diff --git a/apiserver/plane/db/migrations/0067_issue_estimate.py b/apiserver/plane/db/migrations/0067_issue_estimate.py new file mode 100644 index 000000000..b341f9864 --- /dev/null +++ b/apiserver/plane/db/migrations/0067_issue_estimate.py @@ -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), + ] diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py index b11ce7aa3..36718d515 100644 --- a/apiserver/plane/db/models/__init__.py +++ b/apiserver/plane/db/models/__init__.py @@ -4,6 +4,7 @@ from .asset import FileAsset from .base import BaseModel from .cycle import Cycle, CycleFavorite, CycleIssue, CycleUserProperties from .dashboard import Dashboard, DashboardWidget, Widget +from .deploy_board import DeployBoard from .estimate import Estimate, EstimatePoint from .exporter import ExporterHistory from .importer import Importer @@ -53,7 +54,6 @@ from .page import Page, PageFavorite, PageLabel, PageLog from .project import ( Project, ProjectBaseModel, - ProjectDeployBoard, ProjectFavorite, ProjectIdentifier, ProjectMember, diff --git a/apiserver/plane/db/models/asset.py b/apiserver/plane/db/models/asset.py index 7dd2f2c91..86e5ceef8 100644 --- a/apiserver/plane/db/models/asset.py +++ b/apiserver/plane/db/models/asset.py @@ -12,6 +12,7 @@ from .base import BaseModel def get_upload_path(instance, filename): + filename = filename[:50] if instance.workspace_id is not None: return f"{instance.workspace.id}/{uuid4().hex}-{filename}" return f"user-{uuid4().hex}-{filename}" diff --git a/apiserver/plane/db/models/deploy_board.py b/apiserver/plane/db/models/deploy_board.py new file mode 100644 index 000000000..41ffbc7c1 --- /dev/null +++ b/apiserver/plane/db/models/deploy_board.py @@ -0,0 +1,53 @@ +# Python imports +from uuid import uuid4 + +# Django imports +from django.db import models + +# Module imports +from .workspace import WorkspaceBaseModel + + +def get_anchor(): + return uuid4().hex + + +class DeployBoard(WorkspaceBaseModel): + TYPE_CHOICES = ( + ("project", "Project"), + ("issue", "Issue"), + ("module", "Module"), + ("cycle", "Task"), + ("page", "Page"), + ("view", "View"), + ) + + entity_identifier = models.UUIDField(null=True) + entity_name = models.CharField( + max_length=30, + choices=TYPE_CHOICES, + ) + anchor = models.CharField( + max_length=255, default=get_anchor, unique=True, db_index=True + ) + is_comments_enabled = models.BooleanField(default=False) + is_reactions_enabled = models.BooleanField(default=False) + inbox = models.ForeignKey( + "db.Inbox", + related_name="board_inbox", + on_delete=models.SET_NULL, + null=True, + ) + is_votes_enabled = models.BooleanField(default=False) + view_props = models.JSONField(default=dict) + + def __str__(self): + """Return name of the deploy board""" + return f"{self.entity_identifier} <{self.entity_name}>" + + class Meta: + unique_together = ["entity_name", "entity_identifier"] + verbose_name = "Deploy Board" + verbose_name_plural = "Deploy Boards" + db_table = "deploy_boards" + ordering = ("-created_at",) diff --git a/apiserver/plane/db/models/estimate.py b/apiserver/plane/db/models/estimate.py index ec936b300..0713d774f 100644 --- a/apiserver/plane/db/models/estimate.py +++ b/apiserver/plane/db/models/estimate.py @@ -11,7 +11,7 @@ class Estimate(ProjectBaseModel): description = models.TextField( 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): @@ -36,7 +36,7 @@ class EstimatePoint(ProjectBaseModel): default=0, validators=[MinValueValidator(0), MaxValueValidator(12)] ) description = models.TextField(blank=True) - value = models.CharField(max_length=20) + value = models.CharField(max_length=255) def __str__(self): """Return name of the estimate""" diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py index 8eefdc8b3..2b07bd77b 100644 --- a/apiserver/plane/db/models/issue.py +++ b/apiserver/plane/db/models/issue.py @@ -127,7 +127,7 @@ class Issue(ProjectBaseModel): estimate_point = models.ForeignKey( "db.EstimatePoint", on_delete=models.SET_NULL, - related_name="issue_estimate", + related_name="issue_estimates", null=True, blank=True, ) diff --git a/apiserver/plane/db/models/project.py b/apiserver/plane/db/models/project.py index 49fca1323..ba8dbf580 100644 --- a/apiserver/plane/db/models/project.py +++ b/apiserver/plane/db/models/project.py @@ -260,6 +260,8 @@ def get_default_views(): } +# DEPRECATED TODO: +# used to get the old anchors for the project deploy boards class ProjectDeployBoard(ProjectBaseModel): anchor = models.CharField( max_length=255, default=get_anchor, unique=True, db_index=True diff --git a/apiserver/plane/space/views/inbox.py b/apiserver/plane/space/views/inbox.py index 9f681c160..d15e7aa39 100644 --- a/apiserver/plane/space/views/inbox.py +++ b/apiserver/plane/space/views/inbox.py @@ -18,7 +18,7 @@ from plane.db.models import ( State, IssueLink, IssueAttachment, - ProjectDeployBoard, + DeployBoard, ) from plane.app.serializers import ( IssueSerializer, @@ -39,7 +39,7 @@ class InboxIssuePublicViewSet(BaseViewSet): ] def get_queryset(self): - project_deploy_board = ProjectDeployBoard.objects.get( + project_deploy_board = DeployBoard.objects.get( workspace__slug=self.kwargs.get("slug"), project_id=self.kwargs.get("project_id"), ) @@ -59,7 +59,7 @@ class InboxIssuePublicViewSet(BaseViewSet): return InboxIssue.objects.none() def list(self, request, slug, project_id, inbox_id): - project_deploy_board = ProjectDeployBoard.objects.get( + project_deploy_board = DeployBoard.objects.get( workspace__slug=slug, project_id=project_id ) if project_deploy_board.inbox is None: @@ -118,7 +118,7 @@ class InboxIssuePublicViewSet(BaseViewSet): ) def create(self, request, slug, project_id, inbox_id): - project_deploy_board = ProjectDeployBoard.objects.get( + project_deploy_board = DeployBoard.objects.get( workspace__slug=slug, project_id=project_id ) if project_deploy_board.inbox is None: @@ -189,7 +189,7 @@ class InboxIssuePublicViewSet(BaseViewSet): return Response(serializer.data, status=status.HTTP_200_OK) def partial_update(self, request, slug, project_id, inbox_id, pk): - project_deploy_board = ProjectDeployBoard.objects.get( + project_deploy_board = DeployBoard.objects.get( workspace__slug=slug, project_id=project_id ) if project_deploy_board.inbox is None: @@ -256,7 +256,7 @@ class InboxIssuePublicViewSet(BaseViewSet): ) def retrieve(self, request, slug, project_id, inbox_id, pk): - project_deploy_board = ProjectDeployBoard.objects.get( + project_deploy_board = DeployBoard.objects.get( workspace__slug=slug, project_id=project_id ) if project_deploy_board.inbox is None: @@ -280,7 +280,7 @@ class InboxIssuePublicViewSet(BaseViewSet): return Response(serializer.data, status=status.HTTP_200_OK) def destroy(self, request, slug, project_id, inbox_id, pk): - project_deploy_board = ProjectDeployBoard.objects.get( + project_deploy_board = DeployBoard.objects.get( workspace__slug=slug, project_id=project_id ) if project_deploy_board.inbox is None: diff --git a/apiserver/plane/space/views/issue.py b/apiserver/plane/space/views/issue.py index 8c4d6e150..7ffdf0911 100644 --- a/apiserver/plane/space/views/issue.py +++ b/apiserver/plane/space/views/issue.py @@ -44,7 +44,7 @@ from plane.db.models import ( ProjectMember, IssueReaction, CommentReaction, - ProjectDeployBoard, + DeployBoard, IssueVote, ProjectPublicMember, ) @@ -76,7 +76,7 @@ class IssueCommentPublicViewSet(BaseViewSet): def get_queryset(self): try: - project_deploy_board = ProjectDeployBoard.objects.get( + project_deploy_board = DeployBoard.objects.get( workspace__slug=self.kwargs.get("slug"), project_id=self.kwargs.get("project_id"), ) @@ -103,11 +103,11 @@ class IssueCommentPublicViewSet(BaseViewSet): .distinct() ).order_by("created_at") return IssueComment.objects.none() - except ProjectDeployBoard.DoesNotExist: + except DeployBoard.DoesNotExist: return IssueComment.objects.none() def create(self, request, slug, project_id, issue_id): - project_deploy_board = ProjectDeployBoard.objects.get( + project_deploy_board = DeployBoard.objects.get( workspace__slug=slug, project_id=project_id ) @@ -151,7 +151,7 @@ class IssueCommentPublicViewSet(BaseViewSet): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def partial_update(self, request, slug, project_id, issue_id, pk): - project_deploy_board = ProjectDeployBoard.objects.get( + project_deploy_board = DeployBoard.objects.get( workspace__slug=slug, project_id=project_id ) @@ -184,7 +184,7 @@ class IssueCommentPublicViewSet(BaseViewSet): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def destroy(self, request, slug, project_id, issue_id, pk): - project_deploy_board = ProjectDeployBoard.objects.get( + project_deploy_board = DeployBoard.objects.get( workspace__slug=slug, project_id=project_id ) @@ -221,7 +221,7 @@ class IssueReactionPublicViewSet(BaseViewSet): def get_queryset(self): try: - project_deploy_board = ProjectDeployBoard.objects.get( + project_deploy_board = DeployBoard.objects.get( workspace__slug=self.kwargs.get("slug"), project_id=self.kwargs.get("project_id"), ) @@ -236,11 +236,11 @@ class IssueReactionPublicViewSet(BaseViewSet): .distinct() ) return IssueReaction.objects.none() - except ProjectDeployBoard.DoesNotExist: + except DeployBoard.DoesNotExist: return IssueReaction.objects.none() def create(self, request, slug, project_id, issue_id): - project_deploy_board = ProjectDeployBoard.objects.get( + project_deploy_board = DeployBoard.objects.get( workspace__slug=slug, project_id=project_id ) @@ -280,7 +280,7 @@ class IssueReactionPublicViewSet(BaseViewSet): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def destroy(self, request, slug, project_id, issue_id, reaction_code): - project_deploy_board = ProjectDeployBoard.objects.get( + project_deploy_board = DeployBoard.objects.get( workspace__slug=slug, project_id=project_id ) @@ -319,7 +319,7 @@ class CommentReactionPublicViewSet(BaseViewSet): def get_queryset(self): try: - project_deploy_board = ProjectDeployBoard.objects.get( + project_deploy_board = DeployBoard.objects.get( workspace__slug=self.kwargs.get("slug"), project_id=self.kwargs.get("project_id"), ) @@ -334,11 +334,11 @@ class CommentReactionPublicViewSet(BaseViewSet): .distinct() ) return CommentReaction.objects.none() - except ProjectDeployBoard.DoesNotExist: + except DeployBoard.DoesNotExist: return CommentReaction.objects.none() def create(self, request, slug, project_id, comment_id): - project_deploy_board = ProjectDeployBoard.objects.get( + project_deploy_board = DeployBoard.objects.get( workspace__slug=slug, project_id=project_id ) @@ -380,7 +380,7 @@ class CommentReactionPublicViewSet(BaseViewSet): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def destroy(self, request, slug, project_id, comment_id, reaction_code): - project_deploy_board = ProjectDeployBoard.objects.get( + project_deploy_board = DeployBoard.objects.get( workspace__slug=slug, project_id=project_id ) if not project_deploy_board.reactions: @@ -421,7 +421,7 @@ class IssueVotePublicViewSet(BaseViewSet): def get_queryset(self): try: - project_deploy_board = ProjectDeployBoard.objects.get( + project_deploy_board = DeployBoard.objects.get( workspace__slug=self.kwargs.get("slug"), project_id=self.kwargs.get("project_id"), ) @@ -434,7 +434,7 @@ class IssueVotePublicViewSet(BaseViewSet): .filter(project_id=self.kwargs.get("project_id")) ) return IssueVote.objects.none() - except ProjectDeployBoard.DoesNotExist: + except DeployBoard.DoesNotExist: return IssueVote.objects.none() def create(self, request, slug, project_id, issue_id): @@ -513,7 +513,7 @@ class ProjectIssuesPublicEndpoint(BaseAPIView): ] def get(self, request, slug, project_id): - if not ProjectDeployBoard.objects.filter( + if not DeployBoard.objects.filter( workspace__slug=slug, project_id=project_id ).exists(): return Response( diff --git a/apiserver/plane/space/views/project.py b/apiserver/plane/space/views/project.py index 10a3c3879..2cace08da 100644 --- a/apiserver/plane/space/views/project.py +++ b/apiserver/plane/space/views/project.py @@ -11,10 +11,10 @@ from rest_framework.permissions import AllowAny # Module imports from .base import BaseAPIView -from plane.app.serializers import ProjectDeployBoardSerializer +from plane.app.serializers import DeployBoardSerializer from plane.db.models import ( Project, - ProjectDeployBoard, + DeployBoard, ) @@ -24,10 +24,10 @@ class ProjectDeployBoardPublicSettingsEndpoint(BaseAPIView): ] def get(self, request, slug, project_id): - project_deploy_board = ProjectDeployBoard.objects.get( + project_deploy_board = DeployBoard.objects.get( workspace__slug=slug, project_id=project_id ) - serializer = ProjectDeployBoardSerializer(project_deploy_board) + serializer = DeployBoardSerializer(project_deploy_board) return Response(serializer.data, status=status.HTTP_200_OK) @@ -41,7 +41,7 @@ class WorkspaceProjectDeployBoardEndpoint(BaseAPIView): Project.objects.filter(workspace__slug=slug) .annotate( is_public=Exists( - ProjectDeployBoard.objects.filter( + DeployBoard.objects.filter( workspace__slug=slug, project_id=OuterRef("pk") ) ) diff --git a/apiserver/plane/utils/cache.py b/apiserver/plane/utils/cache.py index 071051129..bda942899 100644 --- a/apiserver/plane/utils/cache.py +++ b/apiserver/plane/utils/cache.py @@ -66,7 +66,7 @@ def invalidate_cache_directly( custom_path = path if path is not None else request.get_full_path() auth_header = ( None - if request.user.is_anonymous + if request and request.user.is_anonymous else str(request.user.id) if user else None ) key = generate_cache_key(custom_path, auth_header) diff --git a/packages/editor/core/src/hooks/use-editor.tsx b/packages/editor/core/src/hooks/use-editor.tsx index 76071791b..563cb5122 100644 --- a/packages/editor/core/src/hooks/use-editor.tsx +++ b/packages/editor/core/src/hooks/use-editor.tsx @@ -112,7 +112,7 @@ export const useEditor = ({ if (value === null || value === undefined) return; if (editor && !editor.isDestroyed && !editor.storage.image.uploadInProgress) { try { - editor.commands.setContent(value); + editor.commands.setContent(value, false, { preserveWhitespace: "full" }); const currentSavedSelection = savedSelectionRef.current; if (currentSavedSelection) { const docLength = editor.state.doc.content.size; diff --git a/packages/editor/core/src/ui/plugins/image/image-upload-handler.ts b/packages/editor/core/src/ui/plugins/image/image-upload-handler.ts index 0be22e0dd..eb7021819 100644 --- a/packages/editor/core/src/ui/plugins/image/image-upload-handler.ts +++ b/packages/editor/core/src/ui/plugins/image/image-upload-handler.ts @@ -50,9 +50,25 @@ export async function startImageUpload( }; try { + const fileNameTrimmed = trimFileName(file.name); + const fileWithTrimmedName = new File([file], fileNameTrimmed, { type: file.type }); + + const resolvedPos = view.state.doc.resolve(pos ?? 0); + const nodeBefore = resolvedPos.nodeBefore; + + // if the image is at the start of the line i.e. when nodeBefore is null + if (nodeBefore === null) { + if (pos) { + // so that the image is not inserted at the next line, else incase the + // image is inserted at any line where there's some content, the + // position is kept as it is to be inserted at the next line + pos -= 1; + } + } + view.focus(); - const src = await uploadAndValidateImage(file, uploadFile); + const src = await uploadAndValidateImage(fileWithTrimmedName, uploadFile); if (src == null) { throw new Error("Resolved image URL is undefined."); @@ -112,3 +128,14 @@ async function uploadAndValidateImage(file: File, uploadFile: UploadImage): Prom throw error; } } + +function trimFileName(fileName: string, maxLength = 100) { + if (fileName.length > maxLength) { + const extension = fileName.split(".").pop(); + const nameWithoutExtension = fileName.slice(0, -(extension?.length ?? 0 + 1)); + const allowedNameLength = maxLength - (extension?.length ?? 0) - 1; // -1 for the dot + return `${nameWithoutExtension.slice(0, allowedNameLength)}.${extension}`; + } + + return fileName; +} diff --git a/packages/ui/package.json b/packages/ui/package.json index a4075b3cd..ed0932339 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -20,12 +20,15 @@ "postcss": "postcss styles/globals.css -o styles/output.css --watch" }, "dependencies": { + "@atlaskit/pragmatic-drag-and-drop": "^1.1.10", + "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3", "@blueprintjs/core": "^4.16.3", "@blueprintjs/popover2": "^1.13.3", "@headlessui/react": "^2.0.3", "@popperjs/core": "^2.11.8", "clsx": "^2.0.0", "emoji-picker-react": "^4.5.16", + "lodash": "^4.17.21", "lucide-react": "^0.379.0", "react-color": "^2.19.3", "react-dom": "^18.2.0", diff --git a/packages/ui/src/control-link/control-link.tsx b/packages/ui/src/control-link/control-link.tsx index df1958476..83f3157cc 100644 --- a/packages/ui/src/control-link/control-link.tsx +++ b/packages/ui/src/control-link/control-link.tsx @@ -7,10 +7,11 @@ export type TControlLink = React.AnchorHTMLAttributes & { target?: string; disabled?: boolean; className?: string; + draggable?: boolean; }; export const ControlLink = React.forwardRef((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 handleOnClick = (event: React.MouseEvent) => { @@ -33,7 +34,15 @@ export const ControlLink = React.forwardRef((pr if (disabled) return <>{children}; return ( - + {children} ); diff --git a/packages/ui/src/sortable/draggable.tsx b/packages/ui/src/sortable/draggable.tsx index a56afe073..1e7bd98f4 100644 --- a/packages/ui/src/sortable/draggable.tsx +++ b/packages/ui/src/sortable/draggable.tsx @@ -37,7 +37,7 @@ const Draggable = ({ children, data, className }: Props) => { onDrop: () => { setIsDraggedOver(false); }, - canDrop: ({ source }) => !isEqual(source.data, data), + canDrop: ({ source }) => !isEqual(source.data, data) && source.data.__uuid__ === data.__uuid__, getData: ({ input, element }) => attachClosestEdge(data, { input, @@ -53,7 +53,7 @@ const Draggable = ({ children, data, className }: Props) => {
{} {children} - {} + {}
); }; diff --git a/packages/ui/src/sortable/sortable.tsx b/packages/ui/src/sortable/sortable.tsx index 967965f2a..0b1a27d5f 100644 --- a/packages/ui/src/sortable/sortable.tsx +++ b/packages/ui/src/sortable/sortable.tsx @@ -1,4 +1,4 @@ -import React, { Fragment, useEffect } from "react"; +import React, { Fragment, useEffect, useMemo } from "react"; import { monitorForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; import { Draggable } from "./draggable"; @@ -8,6 +8,7 @@ type Props = { onChange: (data: T[]) => void; keyExtractor: (item: T, index: number) => string; containerClassName?: string; + id?: string; }; const moveItem = ( @@ -43,7 +44,7 @@ const moveItem = ( return newData; }; -export const Sortable = ({ data, render, onChange, keyExtractor, containerClassName }: Props) => { +export const Sortable = ({ data, render, onChange, keyExtractor, containerClassName, id }: Props) => { useEffect(() => { const unsubscribe = monitorForElements({ onDrop({ source, location }) { @@ -57,11 +58,16 @@ export const Sortable = ({ data, render, onChange, keyExtractor, containerCl return () => { if (unsubscribe) unsubscribe(); }; - }, [data, onChange]); + }, [data, keyExtractor, onChange]); + + const enhancedData = useMemo(() => { + const uuid = id ? id : Math.random().toString(36).substring(7); + return data.map((item) => ({ ...item, __uuid__: uuid })); + }, [data, id]); return ( <> - {data.map((item, index) => ( + {enhancedData.map((item, index) => ( {render(item, index)} diff --git a/web/components/core/activity.tsx b/web/components/core/activity.tsx index ed0915c6e..5def2d7a9 100644 --- a/web/components/core/activity.tsx +++ b/web/components/core/activity.tsx @@ -24,10 +24,7 @@ import { Tooltip, BlockedIcon, BlockerIcon, RelatedIcon, LayersIcon, DiceIcon } // helpers import { renderFormattedDate } from "@/helpers/date-time.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"; // types @@ -100,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 ( -// -// {areEstimatesEnabledForCurrentProject -// ? estimateValue -// : `${currentPoint} ${currentPoint > 1 ? "points" : "point"}`} -// -// ); -// }); - const inboxActivityMessage = { declined: { showIssue: "declined issue", @@ -270,8 +251,7 @@ const activityDetails: { else return ( <> - set the estimate point to - {/* */} + set the estimate point to {activity.new_value} {showIssue && ( <> {" "} diff --git a/web/components/gantt-chart/chart/main-content.tsx b/web/components/gantt-chart/chart/main-content.tsx index e3b972237..e5bd7afbf 100644 --- a/web/components/gantt-chart/chart/main-content.tsx +++ b/web/components/gantt-chart/chart/main-content.tsx @@ -162,7 +162,7 @@ export const GanttChartMainContent: React.FC = observer((props) => { )}
- + )} diff --git a/web/components/inbox/root.tsx b/web/components/inbox/root.tsx index 7d4aff955..759dce50c 100644 --- a/web/components/inbox/root.tsx +++ b/web/components/inbox/root.tsx @@ -1,6 +1,5 @@ -import { FC, useState } from "react"; +import { FC, useEffect, useState } from "react"; import { observer } from "mobx-react"; -import useSWR from "swr"; import { Inbox, PanelLeft } from "lucide-react"; // components import { EmptyState } from "@/components/empty-state"; @@ -10,6 +9,7 @@ import { InboxLayoutLoader } from "@/components/ui"; import { EmptyStateType } from "@/constants/empty-state"; // helpers import { cn } from "@/helpers/common.helper"; +import { EInboxIssueCurrentTab } from "@/helpers/inbox.helper"; // hooks import { useProjectInbox } from "@/hooks/store"; @@ -18,25 +18,25 @@ type TInboxIssueRoot = { projectId: string; inboxIssueId: string | undefined; inboxAccessible: boolean; + navigationTab?: EInboxIssueCurrentTab | undefined; }; export const InboxIssueRoot: FC = observer((props) => { - const { workspaceSlug, projectId, inboxIssueId, inboxAccessible } = props; + const { workspaceSlug, projectId, inboxIssueId, inboxAccessible, navigationTab } = props; // states const [isMobileSidebar, setIsMobileSidebar] = useState(true); // hooks - const { loader, error, fetchInboxIssues } = useProjectInbox(); + const { loader, error, currentTab, handleCurrentTab, fetchInboxIssues } = useProjectInbox(); - useSWR( - inboxAccessible && workspaceSlug && projectId ? `PROJECT_INBOX_ISSUES_${workspaceSlug}_${projectId}` : null, - async () => { - inboxAccessible && - workspaceSlug && - projectId && - (await fetchInboxIssues(workspaceSlug.toString(), projectId.toString())); - }, - { revalidateOnFocus: false, revalidateIfStale: false } - ); + useEffect(() => { + if (!inboxAccessible || !workspaceSlug || !projectId) return; + if (navigationTab && navigationTab !== currentTab) { + handleCurrentTab(navigationTab); + } else { + fetchInboxIssues(workspaceSlug.toString(), projectId.toString()); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [inboxAccessible, workspaceSlug, projectId]); // loader if (loader === "init-loading") diff --git a/web/components/inbox/sidebar/root.tsx b/web/components/inbox/sidebar/root.tsx index ed6d0cdd2..f0cd9657b 100644 --- a/web/components/inbox/sidebar/root.tsx +++ b/web/components/inbox/sidebar/root.tsx @@ -1,4 +1,4 @@ -import { FC, useCallback, useRef } from "react"; +import { FC, useCallback, useRef, useState } from "react"; import { observer } from "mobx-react"; import { useRouter } from "next/router"; import { TInboxIssueCurrentTab } from "@plane/types"; @@ -37,7 +37,7 @@ export const InboxSidebar: FC = observer((props) => { const { workspaceSlug, projectId, setIsMobileSidebar } = props; // ref const containerRef = useRef(null); - const elementRef = useRef(null); + const [elementRef, setElementRef] = useState(null); // store const { currentProjectDetails } = useProject(); const { @@ -72,8 +72,10 @@ export const InboxSidebar: FC = observer((props) => { currentTab === option?.key ? `text-custom-primary-100` : `hover:text-custom-text-200` )} onClick={() => { - if (currentTab != option?.key) handleCurrentTab(option?.key); - router.push(`/${workspaceSlug}/projects/${projectId}/inbox?currentTab=${option?.key}`); + if (currentTab != option?.key) { + handleCurrentTab(option?.key); + router.push(`/${workspaceSlug}/projects/${projectId}/inbox?currentTab=${option?.key}`); + } }} >
{option?.label}
@@ -126,14 +128,14 @@ export const InboxSidebar: FC = observer((props) => { />
)} - {inboxIssuePaginationInfo?.next_page_results && ( -
+
+ {inboxIssuePaginationInfo?.next_page_results && ( + )}
- )}
)} diff --git a/web/components/issues/bulk-operations/root.tsx b/web/components/issues/bulk-operations/root.tsx index 957f18609..f92e02279 100644 --- a/web/components/issues/bulk-operations/root.tsx +++ b/web/components/issues/bulk-operations/root.tsx @@ -3,9 +3,11 @@ import { observer } from "mobx-react"; import { BulkOperationsUpgradeBanner } from "@/components/issues"; // hooks import { useMultipleSelectStore } from "@/hooks/store"; +import { TSelectionHelper } from "@/hooks/use-multiple-select"; type Props = { className?: string; + selectionHelpers: TSelectionHelper; }; export const IssueBulkOperationsRoot: React.FC = observer((props) => { diff --git a/web/components/issues/issue-detail/issue-activity/activity/actions/estimate.tsx b/web/components/issues/issue-detail/issue-activity/activity/actions/estimate.tsx index c7046b479..ef6736546 100644 --- a/web/components/issues/issue-detail/issue-activity/activity/actions/estimate.tsx +++ b/web/components/issues/issue-detail/issue-activity/activity/actions/estimate.tsx @@ -2,10 +2,7 @@ import { FC } from "react"; import { observer } from "mobx-react"; import { Triangle } from "lucide-react"; // hooks -import { - // useEstimate, - useIssueDetail, -} from "@/hooks/store"; +import { useIssueDetail } from "@/hooks/store"; // components import { IssueActivityBlockComponent, IssueLink } from "./"; @@ -17,17 +14,11 @@ export const IssueEstimateActivity: FC = observer((props const { activity: { getActivityById }, } = useIssueDetail(); - // const { areEstimatesEnabledForCurrentProject, getEstimatePointValue } = useEstimate(); - const areEstimatesEnabledForCurrentProject = false; const activity = getActivityById(activityId); if (!activity) return <>; - // const estimateValue = getEstimatePointValue(Number(activity.new_value), null); - const estimateValue = "None"; - const currentPoint = Number(activity.new_value) + 1; - return (